create-windy 0.6.9 → 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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +860 -305
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +3 -3
  5. package/template/README.md +4 -0
  6. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +2 -0
  7. package/template/apps/agent-server/tests/test_multimodal.py +30 -0
  8. package/template/apps/agent-server/tests/test_postgres_store.py +1 -0
  9. package/template/apps/agent-server/tests/test_sqlite_store.py +1 -0
  10. package/template/apps/server/src/agent/remote-runtime.test.ts +1 -0
  11. package/template/apps/server/src/agent/run-audit.ts +1 -0
  12. package/template/apps/server/src/agent/run-contracts.ts +1 -0
  13. package/template/apps/server/src/agent/run-facade.ts +2 -2
  14. package/template/apps/server/src/agent/run-operation-registry.ts +7 -0
  15. package/template/apps/server/src/agent/run-output-schema.ts +15 -0
  16. package/template/apps/server/src/agent/run-release.ts +11 -0
  17. package/template/apps/server/src/agent/supervisor-output-schema.test.ts +95 -0
  18. package/template/apps/server/src/agent/vision-input.ts +37 -2
  19. package/template/apps/server/src/license/pairing-http.ts +138 -0
  20. package/template/apps/server/src/license/pairing-routes.test.ts +282 -0
  21. package/template/apps/server/src/license/pairing-routes.ts +257 -0
  22. package/template/apps/server/src/license/pairing-service.test.ts +320 -0
  23. package/template/apps/server/src/license/pairing-service.ts +321 -0
  24. package/template/apps/server/src/license/restriction.test.ts +11 -0
  25. package/template/apps/server/src/license/restriction.ts +5 -0
  26. package/template/apps/server/src/license/routes.ts +8 -1
  27. package/template/apps/server/src/license/runtime-service.ts +20 -0
  28. package/template/apps/web/package.json +6 -4
  29. package/template/apps/web/src/components/license/LicensePairingCard.vue +177 -0
  30. package/template/apps/web/src/components/license/LicensePairingCard.webtest.ts +113 -0
  31. package/template/apps/web/src/composables/useLicensePairing.ts +168 -0
  32. package/template/apps/web/src/composables/useLicenseRuntime.ts +11 -0
  33. package/template/apps/web/src/pages/license/LicensePage.vue +8 -0
  34. package/template/apps/web/src/services/license-api.ts +60 -0
  35. package/template/apps/web/src/style.webtest.ts +5 -6
  36. package/template/docs/architecture/ai-runtime.md +5 -0
  37. package/template/docs/license/license-pairing-activation.md +67 -0
  38. package/template/docs/operations/incremental-delivery.md +6 -6
  39. package/template/docs/platform/agent-supervisor-output-contract.md +80 -0
  40. package/template/packages/ai-client/package.json +1 -1
  41. package/template/packages/ai-client/src/agent/contracts/final-answer.test.ts +67 -0
  42. package/template/packages/ai-client/src/agent/contracts/final-answer.ts +53 -0
  43. package/template/packages/ai-client/src/agent/contracts/index.ts +1 -0
  44. package/template/packages/modules/index.ts +1 -0
  45. package/template/packages/modules/package.json +1 -1
  46. package/template/packages/modules/src/system-admin-ui-catalog.ts +5 -1
  47. package/template/packages/modules/src/system-agent-supervisor.test.ts +48 -0
  48. package/template/packages/modules/src/system-agent-supervisor.ts +101 -0
  49. package/template/packages/modules/src/system-api-control-plane.ts +37 -0
  50. package/template/packages/modules/src/system-license-pairing.test.ts +44 -0
  51. package/template/packages/modules/src/system-module-catalog.ts +8 -18
  52. package/template/packages/modules/src/system-module-menu-selection.ts +15 -0
  53. package/template/packages/modules/src/system-module-schema-catalog.ts +14 -0
  54. package/template/packages/modules/src/system-modules.ts +2 -0
  55. package/template/packages/server-sdk/index.ts +5 -0
  56. package/template/packages/server-sdk/package.json +3 -1
  57. package/template/packages/server-sdk/src/ai-operation-registration.ts +4 -0
  58. package/template/packages/server-sdk/src/system-agent-supervisor.test.ts +87 -0
  59. 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: Pick<RouteApp, "get" | "post">,
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"
@@ -30,6 +30,34 @@ interface LicenseInstallResponse {
30
30
  snapshot: PlatformLicenseSnapshot;
31
31
  }
32
32
 
33
+ export type LicensePairingSessionStatus =
34
+ | "waiting"
35
+ | "delivered"
36
+ | "expired"
37
+ | "locked"
38
+ | "consumed";
39
+
40
+ export interface LicensePairingSession {
41
+ id: string;
42
+ token: string;
43
+ nonce: string;
44
+ productCode: string;
45
+ machineCode: string;
46
+ expiresAt: string;
47
+ qrPayload: string;
48
+ status: "waiting";
49
+ }
50
+
51
+ export interface LicensePairingSessionSnapshot {
52
+ id: string;
53
+ status: LicensePairingSessionStatus;
54
+ expiresAt: string;
55
+ license?: {
56
+ licenseVersion: { key: string; name: string };
57
+ expiresAt: string;
58
+ };
59
+ }
60
+
33
61
  export function loadPlatformLicense(): Promise<PlatformLicenseSnapshot> {
34
62
  return apiRequest<PlatformLicenseSnapshot>("/license/runtime");
35
63
  }
@@ -51,3 +79,35 @@ export function installPlatformActivationCode(
51
79
  body: JSON.stringify({ activationCode }),
52
80
  });
53
81
  }
82
+
83
+ export function createPlatformLicensePairingSession(
84
+ callbackOrigin: string,
85
+ ): Promise<LicensePairingSession> {
86
+ return apiRequest<LicensePairingSession>(
87
+ "/license/runtime/pairing/sessions",
88
+ { method: "POST", body: JSON.stringify({ callbackOrigin }) },
89
+ );
90
+ }
91
+
92
+ export function loadPlatformLicensePairingSession(
93
+ sessionId: string,
94
+ ): Promise<LicensePairingSessionSnapshot> {
95
+ return apiRequest<LicensePairingSessionSnapshot>(
96
+ `/license/runtime/pairing/sessions/${encodeURIComponent(sessionId)}`,
97
+ undefined,
98
+ { suppressFailureHandler: true },
99
+ );
100
+ }
101
+
102
+ export function confirmPlatformLicensePairing(
103
+ sessionId: string,
104
+ pairingCode: string,
105
+ ): Promise<LicenseInstallResponse> {
106
+ return apiRequest<LicenseInstallResponse>(
107
+ `/license/runtime/pairing/sessions/${encodeURIComponent(sessionId)}/confirm`,
108
+ {
109
+ method: "POST",
110
+ body: JSON.stringify({ pairingCode }),
111
+ },
112
+ );
113
+ }