create-windy 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/cli.js +1012 -329
- package/package.json +1 -1
- package/template/.windy-template.json +3 -3
- package/template/README.md +8 -2
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +2 -0
- package/template/apps/agent-server/tests/test_multimodal.py +30 -0
- package/template/apps/agent-server/tests/test_postgres_store.py +1 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +1 -0
- package/template/apps/server/src/agent/remote-runtime.test.ts +1 -0
- package/template/apps/server/src/agent/run-audit.ts +1 -0
- package/template/apps/server/src/agent/run-contracts.ts +1 -0
- package/template/apps/server/src/agent/run-facade.ts +2 -2
- package/template/apps/server/src/agent/run-operation-registry.ts +7 -0
- package/template/apps/server/src/agent/run-output-schema.ts +15 -0
- package/template/apps/server/src/agent/run-release.ts +11 -0
- package/template/apps/server/src/agent/supervisor-output-schema.test.ts +95 -0
- package/template/apps/server/src/agent/vision-input.ts +37 -2
- package/template/apps/server/src/license/pairing-http.ts +138 -0
- package/template/apps/server/src/license/pairing-routes.test.ts +282 -0
- package/template/apps/server/src/license/pairing-routes.ts +257 -0
- package/template/apps/server/src/license/pairing-service.test.ts +320 -0
- package/template/apps/server/src/license/pairing-service.ts +321 -0
- package/template/apps/server/src/license/restriction.test.ts +11 -0
- package/template/apps/server/src/license/restriction.ts +5 -0
- package/template/apps/server/src/license/routes.ts +8 -1
- package/template/apps/server/src/license/runtime-service.ts +20 -0
- package/template/apps/server/src/runtime-development.ts +1 -0
- package/template/apps/web/package.json +6 -4
- package/template/apps/web/src/components/license/LicensePairingCard.vue +177 -0
- package/template/apps/web/src/components/license/LicensePairingCard.webtest.ts +113 -0
- package/template/apps/web/src/composables/useLicensePairing.ts +168 -0
- package/template/apps/web/src/composables/useLicenseRuntime.ts +11 -0
- package/template/apps/web/src/layout/AppSidebar.vue +1 -0
- package/template/apps/web/src/pages/license/LicensePage.vue +8 -0
- package/template/apps/web/src/pages/ui/UiComponentPage.vue +7 -0
- package/template/apps/web/src/pages/ui/UiComponentPage.webtest.ts +94 -0
- package/template/apps/web/src/pages/ui/component-catalog.ts +248 -0
- package/template/apps/web/src/pages/ui/components/ComponentPreviewCard.vue +50 -0
- package/template/apps/web/src/pages/ui/components/DataComponentShowcases.vue +131 -0
- package/template/apps/web/src/pages/ui/components/FeedbackComponentShowcases.vue +158 -0
- package/template/apps/web/src/pages/ui/components/FoundationComponentShowcases.vue +129 -0
- package/template/apps/web/src/pages/ui/components/NavigationComponentShowcases.vue +155 -0
- package/template/apps/web/src/pages/ui/components/ProjectComponentShowcases.vue +31 -0
- package/template/apps/web/src/pages/ui/components/UiComponentGallery.vue +238 -0
- package/template/apps/web/src/router/index.ts +2 -0
- package/template/apps/web/src/router/manifest-routes.webtest.ts +1 -0
- package/template/apps/web/src/router/platform-admin-route-bindings.ts +3 -1
- package/template/apps/web/src/services/license-api.ts +60 -0
- package/template/apps/web/src/style.webtest.ts +5 -6
- package/template/docs/architecture/ai-runtime.md +5 -0
- package/template/docs/development/business-web-routes.md +3 -1
- package/template/docs/development/ui-component-gallery.md +44 -0
- package/template/docs/license/license-pairing-activation.md +67 -0
- package/template/docs/operations/incremental-delivery.md +10 -9
- package/template/docs/platform/agent-supervisor-output-contract.md +80 -0
- package/template/packages/ai-client/package.json +1 -1
- package/template/packages/ai-client/src/agent/contracts/final-answer.test.ts +67 -0
- package/template/packages/ai-client/src/agent/contracts/final-answer.ts +53 -0
- package/template/packages/ai-client/src/agent/contracts/index.ts +1 -0
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-admin-routes.ts +8 -0
- package/template/packages/modules/src/system-admin-ui-catalog.ts +6 -2
- package/template/packages/modules/src/system-agent-supervisor.test.ts +48 -0
- package/template/packages/modules/src/system-agent-supervisor.ts +101 -0
- package/template/packages/modules/src/system-api-control-plane.ts +37 -0
- package/template/packages/modules/src/system-features.ts +5 -0
- package/template/packages/modules/src/system-license-pairing.test.ts +44 -0
- package/template/packages/modules/src/system-module-catalog.ts +10 -19
- package/template/packages/modules/src/system-module-menu-selection.ts +15 -0
- package/template/packages/modules/src/system-module-schema-catalog.ts +14 -0
- package/template/packages/modules/src/system-modules.ts +13 -0
- package/template/packages/modules/src/system-permissions.ts +2 -0
- package/template/packages/modules/src/system-ui-components.test.ts +36 -0
- package/template/packages/server-sdk/index.ts +5 -0
- package/template/packages/server-sdk/package.json +3 -1
- package/template/packages/server-sdk/src/ai-operation-registration.ts +4 -0
- package/template/packages/server-sdk/src/system-agent-supervisor.test.ts +87 -0
- package/template/packages/server-sdk/src/system-agent-supervisor.ts +89 -0
|
@@ -2,6 +2,7 @@ import type { RequestGuardContext } from "../guards.js";
|
|
|
2
2
|
import type { RouteApp } from "../system/routes.js";
|
|
3
3
|
import type { PlatformLicenseRuntime } from "./runtime-service.js";
|
|
4
4
|
import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
|
|
5
|
+
import { registerLicensePairingRoutes } from "./pairing-routes.js";
|
|
5
6
|
import {
|
|
6
7
|
boundedJsonBodyErrorResponse,
|
|
7
8
|
decodeOfflineActivationCode,
|
|
@@ -15,8 +16,12 @@ interface LicenseRouteContext {
|
|
|
15
16
|
request: Request;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
type PlatformLicenseRouteApp = Pick<RouteApp, "get" | "post"> & {
|
|
20
|
+
options(path: string, handler: (context: unknown) => unknown): unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
18
23
|
export function registerPlatformLicenseRoutes(
|
|
19
|
-
app:
|
|
24
|
+
app: PlatformLicenseRouteApp,
|
|
20
25
|
licenseRuntime: PlatformLicenseRuntime,
|
|
21
26
|
authorizer: StaticRouteAuthorizer,
|
|
22
27
|
) {
|
|
@@ -95,6 +100,8 @@ export function registerPlatformLicenseRoutes(
|
|
|
95
100
|
{ parse: "none" },
|
|
96
101
|
);
|
|
97
102
|
|
|
103
|
+
registerLicensePairingRoutes(app, licenseRuntime, authorizer);
|
|
104
|
+
|
|
98
105
|
return app;
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
LicenseInstaller,
|
|
7
7
|
MemoryLicenseStorage,
|
|
8
8
|
parseSignedLicense,
|
|
9
|
+
validateLicenseDocument,
|
|
9
10
|
WindyLicenseClient,
|
|
10
11
|
type AtomicLicenseStore,
|
|
11
12
|
type LicenseInstallResult,
|
|
12
13
|
type LicensePublicKeyMap,
|
|
14
|
+
type LicenseRuntimeValidationResult,
|
|
13
15
|
type LicenseStorage,
|
|
14
16
|
} from "@southwind-ai/license";
|
|
15
17
|
import type {
|
|
@@ -161,6 +163,24 @@ export class PlatformLicenseRuntime implements LicenseEntitlementResolver {
|
|
|
161
163
|
return operation;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
async validateCandidate(
|
|
167
|
+
rawLicense: string,
|
|
168
|
+
): Promise<LicenseRuntimeValidationResult> {
|
|
169
|
+
return validateLicenseDocument(rawLicense, {
|
|
170
|
+
productCode: this.options.productCode,
|
|
171
|
+
machineCode: this.options.machineCode,
|
|
172
|
+
publicKeys: this.options.publicKeys,
|
|
173
|
+
now: this.now(),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pairingTarget(): { productCode: string; machineCode: string } {
|
|
178
|
+
return {
|
|
179
|
+
productCode: this.options.productCode,
|
|
180
|
+
machineCode: this.options.machineCode,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
private async performInstall(
|
|
165
185
|
rawLicense: string,
|
|
166
186
|
context: LicenseInstallActorContext,
|
|
@@ -14,16 +14,17 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@antv/g2": "^5.4.8",
|
|
16
16
|
"@lucide/vue": "^1.23.0",
|
|
17
|
+
"@southwind-ai/example-work-order": "workspace:*",
|
|
18
|
+
"@southwind-ai/license": "workspace:*",
|
|
19
|
+
"@southwind-ai/shared": "workspace:*",
|
|
17
20
|
"@tailwindcss/vite": "^4.3.2",
|
|
18
21
|
"@tanstack/vue-form": "^1.33.0",
|
|
19
22
|
"@tanstack/vue-table": "^8.21.3",
|
|
20
23
|
"@vueuse/core": "^14.3.0",
|
|
21
|
-
"@southwind-ai/example-work-order": "workspace:*",
|
|
22
|
-
"@southwind-ai/license": "workspace:*",
|
|
23
|
-
"@southwind-ai/shared": "workspace:*",
|
|
24
24
|
"class-variance-authority": "^0.7.1",
|
|
25
25
|
"clsx": "^2.1.1",
|
|
26
26
|
"pinia": "^3.0.4",
|
|
27
|
+
"qrcode": "^1.5.4",
|
|
27
28
|
"reka-ui": "^2.10.1",
|
|
28
29
|
"tailwind-merge": "^3.6.0",
|
|
29
30
|
"tailwindcss": "^4.3.2",
|
|
@@ -33,9 +34,10 @@
|
|
|
33
34
|
"vue-sonner": "^2.0.9"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@southwind-ai/modules": "workspace:*",
|
|
38
|
+
"@types/qrcode": "^1.5.6",
|
|
36
39
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
37
40
|
"@vue/test-utils": "^2.4.11",
|
|
38
|
-
"@southwind-ai/modules": "workspace:*",
|
|
39
41
|
"happy-dom": "^20.10.6",
|
|
40
42
|
"tw-animate-css": "^1.4.0",
|
|
41
43
|
"vite": "^8.1.4",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
KeyRoundIcon,
|
|
5
|
+
LoaderCircleIcon,
|
|
6
|
+
QrCodeIcon,
|
|
7
|
+
RefreshCwIcon,
|
|
8
|
+
SmartphoneIcon,
|
|
9
|
+
} from "@lucide/vue";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
12
|
+
import { useLicensePairing } from "@/composables/useLicensePairing";
|
|
13
|
+
import { useLicenseRuntime } from "@/composables/useLicenseRuntime";
|
|
14
|
+
import { formatLicenseDateTime } from "./license-display";
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
installed: [];
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const runtime = useLicenseRuntime();
|
|
21
|
+
const model = useLicensePairing({
|
|
22
|
+
confirm: runtime.confirmPairing,
|
|
23
|
+
});
|
|
24
|
+
const statusText = computed(() => {
|
|
25
|
+
const status = model.snapshot.value?.status;
|
|
26
|
+
if (status === "delivered")
|
|
27
|
+
return "手机已回传签名 License,请输入手机上的 8 位配对码。";
|
|
28
|
+
if (status === "expired") return "本次激活会话已过期,请重新生成二维码。";
|
|
29
|
+
if (status === "locked") return "配对码错误次数过多,本次会话已锁定。";
|
|
30
|
+
if (status === "consumed") return "配对激活完成,新 License 已生效。";
|
|
31
|
+
return model.session.value
|
|
32
|
+
? "等待手机扫描并完成签发…"
|
|
33
|
+
: "客户端无需连接互联网;手机扫码签发后会自动回传。";
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function confirm(): Promise<void> {
|
|
37
|
+
if (await model.confirm()) emit("installed");
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<Card>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle class="flex items-center gap-2 text-base">
|
|
45
|
+
<SmartphoneIcon class="size-4" />手机扫码快速激活
|
|
46
|
+
</CardTitle>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
<CardContent class="space-y-4">
|
|
49
|
+
<p class="text-sm text-muted-foreground">{{ statusText }}</p>
|
|
50
|
+
|
|
51
|
+
<Button
|
|
52
|
+
v-if="!model.session.value"
|
|
53
|
+
:disabled="model.creating.value"
|
|
54
|
+
data-testid="create-license-pairing"
|
|
55
|
+
@click="model.create"
|
|
56
|
+
>
|
|
57
|
+
<LoaderCircleIcon
|
|
58
|
+
v-if="model.creating.value"
|
|
59
|
+
class="animate-spin"
|
|
60
|
+
data-icon="inline-start"
|
|
61
|
+
/>
|
|
62
|
+
<QrCodeIcon v-else data-icon="inline-start" />
|
|
63
|
+
{{ model.creating.value ? "正在生成" : "生成激活二维码" }}
|
|
64
|
+
</Button>
|
|
65
|
+
|
|
66
|
+
<div
|
|
67
|
+
v-else
|
|
68
|
+
class="grid items-start gap-5 md:grid-cols-[minmax(0,20rem)_1fr]"
|
|
69
|
+
>
|
|
70
|
+
<div class="rounded-xl border bg-white p-3">
|
|
71
|
+
<img
|
|
72
|
+
v-if="model.qrDataUrl.value"
|
|
73
|
+
:src="model.qrDataUrl.value"
|
|
74
|
+
class="aspect-square w-full"
|
|
75
|
+
alt="License 手机配对激活二维码"
|
|
76
|
+
data-testid="license-pairing-qr"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="space-y-4">
|
|
80
|
+
<div class="rounded-lg border bg-muted/40 p-3 text-sm">
|
|
81
|
+
<p class="text-muted-foreground">会话剩余时间</p>
|
|
82
|
+
<p class="font-mono text-lg" data-testid="pairing-countdown">
|
|
83
|
+
{{ model.secondsRemaining.value }} 秒
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<dl
|
|
88
|
+
v-if="model.snapshot.value?.license"
|
|
89
|
+
class="grid gap-2 rounded-lg border p-3 text-sm"
|
|
90
|
+
data-testid="pairing-license-summary"
|
|
91
|
+
>
|
|
92
|
+
<div class="flex items-center justify-between gap-3">
|
|
93
|
+
<dt class="text-muted-foreground">License 版本</dt>
|
|
94
|
+
<dd class="font-medium">
|
|
95
|
+
{{ model.snapshot.value.license.licenseVersion.name }}
|
|
96
|
+
</dd>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="flex items-center justify-between gap-3">
|
|
99
|
+
<dt class="text-muted-foreground">有效期至</dt>
|
|
100
|
+
<dd class="font-medium">
|
|
101
|
+
{{
|
|
102
|
+
formatLicenseDateTime(model.snapshot.value.license.expiresAt)
|
|
103
|
+
}}
|
|
104
|
+
</dd>
|
|
105
|
+
</div>
|
|
106
|
+
</dl>
|
|
107
|
+
|
|
108
|
+
<form
|
|
109
|
+
v-if="model.ready.value"
|
|
110
|
+
class="space-y-3"
|
|
111
|
+
data-testid="license-pairing-form"
|
|
112
|
+
@submit.prevent="confirm"
|
|
113
|
+
>
|
|
114
|
+
<label class="block space-y-2 text-sm font-medium">
|
|
115
|
+
<span>手机配对码</span>
|
|
116
|
+
<input
|
|
117
|
+
:value="model.pairingCode.value"
|
|
118
|
+
inputmode="text"
|
|
119
|
+
autocomplete="one-time-code"
|
|
120
|
+
autocapitalize="characters"
|
|
121
|
+
spellcheck="false"
|
|
122
|
+
maxlength="8"
|
|
123
|
+
class="h-12 w-full rounded-md border border-input bg-background px-3 text-center font-mono text-xl tracking-[0.35em] uppercase outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
124
|
+
placeholder="8 位数字或字母"
|
|
125
|
+
data-testid="license-pairing-code"
|
|
126
|
+
@input="
|
|
127
|
+
model.updateCode(($event.target as HTMLInputElement).value)
|
|
128
|
+
"
|
|
129
|
+
/>
|
|
130
|
+
</label>
|
|
131
|
+
<Button
|
|
132
|
+
type="submit"
|
|
133
|
+
:disabled="
|
|
134
|
+
model.confirming.value || model.pairingCode.value.length !== 8
|
|
135
|
+
"
|
|
136
|
+
data-testid="confirm-license-pairing"
|
|
137
|
+
>
|
|
138
|
+
<LoaderCircleIcon
|
|
139
|
+
v-if="model.confirming.value"
|
|
140
|
+
class="animate-spin"
|
|
141
|
+
data-icon="inline-start"
|
|
142
|
+
/>
|
|
143
|
+
<KeyRoundIcon v-else data-icon="inline-start" />
|
|
144
|
+
{{ model.confirming.value ? "正在校验并安装" : "确认配对并安装" }}
|
|
145
|
+
</Button>
|
|
146
|
+
</form>
|
|
147
|
+
|
|
148
|
+
<Button
|
|
149
|
+
v-if="
|
|
150
|
+
model.snapshot.value?.status === 'expired' ||
|
|
151
|
+
model.snapshot.value?.status === 'locked'
|
|
152
|
+
"
|
|
153
|
+
variant="outline"
|
|
154
|
+
:disabled="model.creating.value"
|
|
155
|
+
@click="model.create"
|
|
156
|
+
>
|
|
157
|
+
<LoaderCircleIcon
|
|
158
|
+
v-if="model.creating.value"
|
|
159
|
+
class="animate-spin"
|
|
160
|
+
data-icon="inline-start"
|
|
161
|
+
/>
|
|
162
|
+
<RefreshCwIcon v-else data-icon="inline-start" />重新生成
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<p
|
|
168
|
+
v-if="model.error.value"
|
|
169
|
+
class="text-sm text-destructive"
|
|
170
|
+
role="alert"
|
|
171
|
+
data-testid="license-pairing-error"
|
|
172
|
+
>
|
|
173
|
+
{{ model.error.value }}
|
|
174
|
+
</p>
|
|
175
|
+
</CardContent>
|
|
176
|
+
</Card>
|
|
177
|
+
</template>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { resetLicenseRuntime } from "@/composables/useLicenseRuntime";
|
|
4
|
+
import LicensePairingCard from "./LicensePairingCard.vue";
|
|
5
|
+
|
|
6
|
+
const api = vi.hoisted(() => ({
|
|
7
|
+
create: vi.fn(),
|
|
8
|
+
load: vi.fn(),
|
|
9
|
+
confirm: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("qrcode", () => ({
|
|
13
|
+
default: { toDataURL: vi.fn().mockResolvedValue("data:image/png;base64,qr") },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@/services/license-api", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("@/services/license-api")>()),
|
|
18
|
+
createPlatformLicensePairingSession: api.create,
|
|
19
|
+
loadPlatformLicensePairingSession: api.load,
|
|
20
|
+
confirmPlatformLicensePairing: api.confirm,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe("License 手机配对卡片", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
resetLicenseRuntime();
|
|
27
|
+
api.create.mockReset().mockResolvedValue({
|
|
28
|
+
id: "session_1234567890",
|
|
29
|
+
token: "token_12345678901234567890",
|
|
30
|
+
nonce: "nonce_12345678901234567890",
|
|
31
|
+
productCode: "windy-platform",
|
|
32
|
+
machineCode: "machine_1",
|
|
33
|
+
expiresAt: "2030-08-21T00:05:00.000Z",
|
|
34
|
+
qrPayload: '{"type":"windy-license-pairing"}',
|
|
35
|
+
status: "waiting",
|
|
36
|
+
});
|
|
37
|
+
api.load.mockReset().mockResolvedValue({
|
|
38
|
+
id: "session_1234567890",
|
|
39
|
+
status: "delivered",
|
|
40
|
+
expiresAt: "2030-08-21T00:05:00.000Z",
|
|
41
|
+
license: {
|
|
42
|
+
licenseVersion: { key: "advanced", name: "高级付费版" },
|
|
43
|
+
expiresAt: "2031-08-21T00:00:00.000Z",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
api.confirm.mockReset().mockResolvedValue({
|
|
47
|
+
installStatus: "installed",
|
|
48
|
+
snapshot: {
|
|
49
|
+
status: "valid",
|
|
50
|
+
restricted: false,
|
|
51
|
+
reminder: { level: "none" },
|
|
52
|
+
canViewDetails: false,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
vi.setSystemTime(new Date("2030-08-21T00:00:00.000Z"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("扫码回传后只需输入 8 位配对码完成安装", async () => {
|
|
63
|
+
const wrapper = mount(LicensePairingCard);
|
|
64
|
+
await wrapper
|
|
65
|
+
.get('[data-testid="create-license-pairing"]')
|
|
66
|
+
.trigger("click");
|
|
67
|
+
await flushPromises();
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
wrapper.get('[data-testid="license-pairing-qr"]').attributes("src"),
|
|
71
|
+
).toBe("data:image/png;base64,qr");
|
|
72
|
+
expect(api.create).toHaveBeenCalledWith(window.location.origin);
|
|
73
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
74
|
+
await flushPromises();
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
wrapper.get('[data-testid="pairing-license-summary"]').text(),
|
|
78
|
+
).toContain("高级付费版");
|
|
79
|
+
|
|
80
|
+
await wrapper
|
|
81
|
+
.get('[data-testid="license-pairing-code"]')
|
|
82
|
+
.setValue("abcd1234");
|
|
83
|
+
await wrapper.get('[data-testid="license-pairing-form"]').trigger("submit");
|
|
84
|
+
await flushPromises();
|
|
85
|
+
|
|
86
|
+
expect(api.confirm).toHaveBeenCalledWith(
|
|
87
|
+
"session_1234567890",
|
|
88
|
+
expect.stringMatching(/^[0-9A-Z]{8}$/),
|
|
89
|
+
);
|
|
90
|
+
expect(wrapper.emitted("installed")).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("手机已回传但未及时确认时仍按会话期限失效", async () => {
|
|
94
|
+
const wrapper = mount(LicensePairingCard);
|
|
95
|
+
await wrapper
|
|
96
|
+
.get('[data-testid="create-license-pairing"]')
|
|
97
|
+
.trigger("click");
|
|
98
|
+
await flushPromises();
|
|
99
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
100
|
+
await flushPromises();
|
|
101
|
+
expect(wrapper.find('[data-testid="license-pairing-form"]').exists()).toBe(
|
|
102
|
+
true,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await vi.advanceTimersByTimeAsync(5 * 60_000);
|
|
106
|
+
await flushPromises();
|
|
107
|
+
|
|
108
|
+
expect(wrapper.find('[data-testid="license-pairing-form"]').exists()).toBe(
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
expect(wrapper.text()).toContain("本次激活会话已过期");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onBeforeUnmount,
|
|
4
|
+
readonly,
|
|
5
|
+
shallowReadonly,
|
|
6
|
+
shallowRef,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import { normalizeLicensePairingCode } from "@southwind-ai/license/pairing";
|
|
9
|
+
import {
|
|
10
|
+
createPlatformLicensePairingSession,
|
|
11
|
+
loadPlatformLicensePairingSession,
|
|
12
|
+
type LicensePairingSession,
|
|
13
|
+
type LicensePairingSessionSnapshot,
|
|
14
|
+
} from "@/services/license-api";
|
|
15
|
+
|
|
16
|
+
interface UseLicensePairingOptions {
|
|
17
|
+
confirm(sessionId: string, pairingCode: string): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useLicensePairing(options: UseLicensePairingOptions) {
|
|
21
|
+
const session = shallowRef<LicensePairingSession>();
|
|
22
|
+
const snapshot = shallowRef<LicensePairingSessionSnapshot>();
|
|
23
|
+
const qrDataUrl = shallowRef("");
|
|
24
|
+
const pairingCode = shallowRef("");
|
|
25
|
+
const error = shallowRef("");
|
|
26
|
+
const creating = shallowRef(false);
|
|
27
|
+
const confirming = shallowRef(false);
|
|
28
|
+
const now = shallowRef(Date.now());
|
|
29
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
30
|
+
let clockTimer: ReturnType<typeof setInterval> | undefined;
|
|
31
|
+
|
|
32
|
+
const secondsRemaining = computed(() => {
|
|
33
|
+
const expiresAt = session.value?.expiresAt;
|
|
34
|
+
return expiresAt
|
|
35
|
+
? Math.max(0, Math.ceil((Date.parse(expiresAt) - now.value) / 1_000))
|
|
36
|
+
: 0;
|
|
37
|
+
});
|
|
38
|
+
const ready = computed(() => snapshot.value?.status === "delivered");
|
|
39
|
+
|
|
40
|
+
async function create(): Promise<void> {
|
|
41
|
+
creating.value = true;
|
|
42
|
+
error.value = "";
|
|
43
|
+
stopTimers();
|
|
44
|
+
try {
|
|
45
|
+
const created = await createPlatformLicensePairingSession(
|
|
46
|
+
window.location.origin,
|
|
47
|
+
);
|
|
48
|
+
session.value = created;
|
|
49
|
+
snapshot.value = {
|
|
50
|
+
id: created.id,
|
|
51
|
+
status: "waiting",
|
|
52
|
+
expiresAt: created.expiresAt,
|
|
53
|
+
};
|
|
54
|
+
const { default: QRCode } = await import("qrcode");
|
|
55
|
+
qrDataUrl.value = await QRCode.toDataURL(created.qrPayload, {
|
|
56
|
+
errorCorrectionLevel: "M",
|
|
57
|
+
margin: 2,
|
|
58
|
+
width: 320,
|
|
59
|
+
});
|
|
60
|
+
startTimers();
|
|
61
|
+
} catch (current) {
|
|
62
|
+
error.value = messageOf(current);
|
|
63
|
+
session.value = undefined;
|
|
64
|
+
snapshot.value = undefined;
|
|
65
|
+
qrDataUrl.value = "";
|
|
66
|
+
} finally {
|
|
67
|
+
creating.value = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function refresh(): Promise<void> {
|
|
72
|
+
const current = session.value;
|
|
73
|
+
if (!current) return;
|
|
74
|
+
try {
|
|
75
|
+
snapshot.value = await loadPlatformLicensePairingSession(current.id);
|
|
76
|
+
if (snapshot.value.status !== "waiting") stopPolling();
|
|
77
|
+
} catch (currentError) {
|
|
78
|
+
error.value = messageOf(currentError);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function confirm(): Promise<boolean> {
|
|
83
|
+
const current = session.value;
|
|
84
|
+
if (!current) return false;
|
|
85
|
+
error.value = "";
|
|
86
|
+
confirming.value = true;
|
|
87
|
+
try {
|
|
88
|
+
const normalized = normalizeLicensePairingCode(pairingCode.value);
|
|
89
|
+
pairingCode.value = normalized;
|
|
90
|
+
await options.confirm(current.id, normalized);
|
|
91
|
+
snapshot.value = {
|
|
92
|
+
id: current.id,
|
|
93
|
+
status: "consumed",
|
|
94
|
+
expiresAt: current.expiresAt,
|
|
95
|
+
license: snapshot.value?.license,
|
|
96
|
+
};
|
|
97
|
+
stopTimers();
|
|
98
|
+
return true;
|
|
99
|
+
} catch (currentError) {
|
|
100
|
+
error.value = messageOf(currentError);
|
|
101
|
+
await refresh();
|
|
102
|
+
return false;
|
|
103
|
+
} finally {
|
|
104
|
+
confirming.value = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function updateCode(value: string): void {
|
|
109
|
+
pairingCode.value = value
|
|
110
|
+
.toUpperCase()
|
|
111
|
+
.replace(/[^0-9A-Z]/g, "")
|
|
112
|
+
.slice(0, 8);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function startTimers(): void {
|
|
116
|
+
stopTimers();
|
|
117
|
+
now.value = Date.now();
|
|
118
|
+
pollTimer = setInterval(() => void refresh(), 2_000);
|
|
119
|
+
clockTimer = setInterval(() => {
|
|
120
|
+
now.value = Date.now();
|
|
121
|
+
if (secondsRemaining.value === 0) {
|
|
122
|
+
stopTimers();
|
|
123
|
+
if (
|
|
124
|
+
snapshot.value?.status === "waiting" ||
|
|
125
|
+
snapshot.value?.status === "delivered"
|
|
126
|
+
) {
|
|
127
|
+
snapshot.value = {
|
|
128
|
+
id: snapshot.value.id,
|
|
129
|
+
status: "expired",
|
|
130
|
+
expiresAt: snapshot.value.expiresAt,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, 1_000);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stopPolling(): void {
|
|
138
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
139
|
+
pollTimer = undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stopTimers(): void {
|
|
143
|
+
stopPolling();
|
|
144
|
+
if (clockTimer) clearInterval(clockTimer);
|
|
145
|
+
clockTimer = undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
onBeforeUnmount(stopTimers);
|
|
149
|
+
return {
|
|
150
|
+
session: shallowReadonly(session),
|
|
151
|
+
snapshot: shallowReadonly(snapshot),
|
|
152
|
+
qrDataUrl: readonly(qrDataUrl),
|
|
153
|
+
pairingCode: readonly(pairingCode),
|
|
154
|
+
error: readonly(error),
|
|
155
|
+
creating: readonly(creating),
|
|
156
|
+
confirming: readonly(confirming),
|
|
157
|
+
secondsRemaining,
|
|
158
|
+
ready,
|
|
159
|
+
create,
|
|
160
|
+
refresh,
|
|
161
|
+
confirm,
|
|
162
|
+
updateCode,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function messageOf(value: unknown): string {
|
|
167
|
+
return value instanceof Error ? value.message : String(value);
|
|
168
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readonly, shallowReadonly, shallowRef } from "vue";
|
|
2
2
|
import {
|
|
3
|
+
confirmPlatformLicensePairing,
|
|
3
4
|
installPlatformActivationCode,
|
|
4
5
|
installPlatformLicense,
|
|
5
6
|
loadPlatformLicense,
|
|
@@ -55,6 +56,15 @@ async function installActivationCode(
|
|
|
55
56
|
return performInstall(() => installPlatformActivationCode(activationCode));
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
async function confirmPairing(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
pairingCode: string,
|
|
62
|
+
): Promise<PlatformLicenseSnapshot> {
|
|
63
|
+
return performInstall(() =>
|
|
64
|
+
confirmPlatformLicensePairing(sessionId, pairingCode),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
async function performInstall(
|
|
59
69
|
operation: () => ReturnType<typeof installPlatformLicense>,
|
|
60
70
|
): Promise<PlatformLicenseSnapshot> {
|
|
@@ -82,6 +92,7 @@ export function useLicenseRuntime() {
|
|
|
82
92
|
refresh: () => load(true),
|
|
83
93
|
install,
|
|
84
94
|
installActivationCode,
|
|
95
|
+
confirmPairing,
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
98
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { onMounted } from "vue";
|
|
3
3
|
import LicenseActivationCodeForm from "@/components/license/LicenseActivationCodeForm.vue";
|
|
4
|
+
import LicensePairingCard from "@/components/license/LicensePairingCard.vue";
|
|
4
5
|
import LicenseStatusCard from "@/components/license/LicenseStatusCard.vue";
|
|
5
6
|
import LicenseUploadForm from "@/components/license/LicenseUploadForm.vue";
|
|
6
7
|
import PageLoading from "@/components/common/PageLoading.vue";
|
|
@@ -33,6 +34,12 @@ async function activate(activationCode: string) {
|
|
|
33
34
|
toast.error("激活码安装失败", { description: runtime.error.value });
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
function pairingInstalled() {
|
|
39
|
+
toast.success("手机配对激活完成", {
|
|
40
|
+
description: "配对码已确认,新授权立即生效。",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
36
43
|
</script>
|
|
37
44
|
|
|
38
45
|
<template>
|
|
@@ -57,6 +64,7 @@ async function activate(activationCode: string) {
|
|
|
57
64
|
:snapshot="runtime.snapshot.value"
|
|
58
65
|
/>
|
|
59
66
|
<LicenseUploadForm :loading="runtime.installing.value" @upload="upload" />
|
|
67
|
+
<LicensePairingCard @installed="pairingInstalled" />
|
|
60
68
|
<LicenseActivationCodeForm
|
|
61
69
|
:loading="runtime.installing.value"
|
|
62
70
|
@activate="activate"
|