create-windy 0.3.4 → 0.3.6
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 +20 -3
- package/dist/cli.js +585 -111
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +4 -0
- package/template/README.md +18 -11
- package/template/apps/server/src/application-services.ts +16 -0
- package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
- package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
- package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
- package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
- package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
- package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
- package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
- package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
- package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
- package/template/apps/server/src/data-governance/export/routes.ts +11 -1
- package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
- package/template/apps/server/src/data-governance/export/service.ts +8 -2
- package/template/apps/server/src/data-governance/export/types.ts +27 -0
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
- package/template/apps/server/src/jobs/artifact-service.ts +10 -0
- package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
- package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
- package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
- package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
- package/template/apps/server/src/jobs/execution-repository.ts +17 -0
- package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
- package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
- package/template/apps/server/src/jobs/worker.ts +27 -3
- package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
- package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
- package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
- package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
- package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
- package/template/apps/server/src/module-host/governed-resources.ts +56 -0
- package/template/apps/server/src/module-host/http-runtime.ts +3 -0
- package/template/apps/server/src/module-host/job-port.test.ts +262 -0
- package/template/apps/server/src/module-host/job-port.ts +334 -0
- package/template/apps/server/src/module-host/request-context.ts +14 -26
- package/template/apps/server/src/module-host/route-installer.ts +11 -0
- package/template/apps/web/src/app/business-routes.ts +22 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
- package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
- package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
- package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
- package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
- package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
- package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
- package/template/apps/web/src/layout/AppShell.vue +28 -25
- package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
- package/template/apps/web/src/router/business-routes.ts +68 -0
- package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
- package/template/apps/web/src/router/index.ts +7 -0
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
- package/template/apps/web/src/services/access-api.ts +5 -1
- package/template/apps/web/src/services/access-api.webtest.ts +35 -0
- package/template/docs/architecture/ai-egress.md +12 -9
- package/template/docs/architecture/durable-jobs.md +7 -1
- package/template/docs/architecture/foundation-infrastructure.md +163 -0
- package/template/docs/architecture/module-host.md +115 -0
- package/template/docs/architecture/server-module-interface.md +39 -0
- package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
- package/template/docs/development/business-web-routes.md +66 -0
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/development/module-lifecycle.md +71 -0
- package/template/docs/operations/observability.md +5 -1
- package/template/docs/platform/agent-runtime.md +6 -6
- package/template/docs/platform/agent-tool-sandbox.md +58 -0
- package/template/docs/platform/document-ai-runtime.md +204 -0
- package/template/docs/platform/governed-export-renderers.md +132 -0
- package/template/docs/platform/navigation-registry.md +5 -0
- package/template/docs/platform/organization-membership.md +2 -1
- package/template/docs/platform/speech-ai-runtime.md +156 -0
- package/template/docs/platform/system-crud-api.md +7 -1
- package/template/docs/platform/vision-ai-runtime.md +97 -0
- package/template/docs/platform/web-system-crud.md +6 -0
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
- package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/schema/durable-jobs.ts +1 -0
- package/template/packages/database/src/schema/governed-exports.ts +15 -0
- package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/handlers.ts +1 -0
- package/template/packages/jobs/src/types.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
- package/template/packages/server-sdk/src/jobs-port.ts +69 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -2
- package/template/packages/shared/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
CircleAlertIcon,
|
|
4
|
+
LoaderCircleIcon,
|
|
5
|
+
RefreshCwIcon,
|
|
6
|
+
RotateCwIcon,
|
|
7
|
+
} from "@lucide/vue";
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import type { AccessSnapshotChangeKind } from "@/composables/access-snapshot-change";
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
blocking: boolean;
|
|
15
|
+
reason?:
|
|
16
|
+
| "route-revoked"
|
|
17
|
+
| "admin-access-revoked"
|
|
18
|
+
| "snapshot-unavailable";
|
|
19
|
+
changeKind?: AccessSnapshotChangeKind;
|
|
20
|
+
loading: boolean;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const emit = defineEmits<{
|
|
25
|
+
acknowledge: [];
|
|
26
|
+
reload: [];
|
|
27
|
+
retry: [];
|
|
28
|
+
returnHome: [];
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const title = computed(() => {
|
|
32
|
+
if (props.reason === "route-revoked") return "当前页面权限已被撤销";
|
|
33
|
+
if (props.reason === "admin-access-revoked") {
|
|
34
|
+
return "管理后台访问权限已被撤销";
|
|
35
|
+
}
|
|
36
|
+
if (props.reason === "snapshot-unavailable") return "暂时无法确认最新权限";
|
|
37
|
+
if (props.changeKind === "reduced") return "访问权限已收紧";
|
|
38
|
+
if (props.changeKind === "expanded") return "访问权限已增加";
|
|
39
|
+
return "访问权限已更新";
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const description = computed(() => {
|
|
43
|
+
if (props.reason === "route-revoked") {
|
|
44
|
+
return "页面内容和操作已停止展示。未保存编辑仍保留在当前页面实例中;返回首页时会继续执行原有的未保存确认流程。";
|
|
45
|
+
}
|
|
46
|
+
if (props.reason === "admin-access-revoked") {
|
|
47
|
+
return "管理端内容和操作已停止展示。未保存编辑仍保留在当前页面实例中,可以重新检查权限或返回业务首页。";
|
|
48
|
+
}
|
|
49
|
+
if (props.reason === "snapshot-unavailable") {
|
|
50
|
+
return "为避免继续使用可能已撤销的能力,页面内容已临时隐藏。网络恢复后可重新检查。";
|
|
51
|
+
}
|
|
52
|
+
if (props.changeKind === "reduced") {
|
|
53
|
+
return "已撤销的入口和操作已立即隐藏。当前页面仍符合最新访问策略。";
|
|
54
|
+
}
|
|
55
|
+
if (props.changeKind === "expanded") {
|
|
56
|
+
return "入口和操作已按最新权限更新;如需重新加载页面数据,可以安全刷新。";
|
|
57
|
+
}
|
|
58
|
+
return "角色、功能或菜单策略已更新,当前界面已同步最新访问状态。";
|
|
59
|
+
});
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<template>
|
|
63
|
+
<Alert
|
|
64
|
+
:variant="blocking ? 'destructive' : 'default'"
|
|
65
|
+
:data-testid="
|
|
66
|
+
blocking ? 'access-snapshot-blocker' : 'access-snapshot-notice'
|
|
67
|
+
"
|
|
68
|
+
>
|
|
69
|
+
<CircleAlertIcon aria-hidden="true" />
|
|
70
|
+
<AlertTitle>{{ title }}</AlertTitle>
|
|
71
|
+
<AlertDescription class="space-y-3">
|
|
72
|
+
<p>{{ description }}</p>
|
|
73
|
+
<p v-if="fingerprint" class="font-mono text-[11px]">
|
|
74
|
+
快照:{{ fingerprint }}
|
|
75
|
+
</p>
|
|
76
|
+
<div class="flex flex-wrap gap-2">
|
|
77
|
+
<Button
|
|
78
|
+
v-if="blocking"
|
|
79
|
+
size="sm"
|
|
80
|
+
:disabled="loading"
|
|
81
|
+
@click="emit('retry')"
|
|
82
|
+
>
|
|
83
|
+
<LoaderCircleIcon v-if="loading" class="animate-spin" />
|
|
84
|
+
<RefreshCwIcon v-else />
|
|
85
|
+
{{ loading ? "正在检查…" : "重新检查" }}
|
|
86
|
+
</Button>
|
|
87
|
+
<Button
|
|
88
|
+
v-if="
|
|
89
|
+
reason === 'route-revoked' || reason === 'admin-access-revoked'
|
|
90
|
+
"
|
|
91
|
+
size="sm"
|
|
92
|
+
variant="outline"
|
|
93
|
+
@click="emit('returnHome')"
|
|
94
|
+
>
|
|
95
|
+
返回首页
|
|
96
|
+
</Button>
|
|
97
|
+
<Button
|
|
98
|
+
v-if="!blocking"
|
|
99
|
+
size="sm"
|
|
100
|
+
variant="outline"
|
|
101
|
+
@click="emit('reload')"
|
|
102
|
+
>
|
|
103
|
+
<RotateCwIcon />
|
|
104
|
+
安全刷新
|
|
105
|
+
</Button>
|
|
106
|
+
<Button
|
|
107
|
+
v-if="!blocking"
|
|
108
|
+
size="sm"
|
|
109
|
+
variant="ghost"
|
|
110
|
+
@click="emit('acknowledge')"
|
|
111
|
+
>
|
|
112
|
+
知道了
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</AlertDescription>
|
|
116
|
+
</Alert>
|
|
117
|
+
</template>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { MenuNode } from "@southwind-ai/shared";
|
|
2
|
+
import type { WebAccessSnapshot } from "@/layout/navigation";
|
|
3
|
+
|
|
4
|
+
export type AccessSnapshotChangeKind = "reduced" | "expanded" | "changed";
|
|
5
|
+
|
|
6
|
+
export interface AccessSnapshotChange {
|
|
7
|
+
readonly kind: AccessSnapshotChangeKind;
|
|
8
|
+
readonly previousFingerprint: string;
|
|
9
|
+
readonly currentFingerprint: string;
|
|
10
|
+
readonly removedCapabilities: readonly string[];
|
|
11
|
+
readonly addedCapabilities: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AccessSnapshotIdentity {
|
|
15
|
+
readonly signature: string;
|
|
16
|
+
readonly fingerprint: string;
|
|
17
|
+
readonly capabilities: ReadonlySet<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function accessSnapshotIdentity(
|
|
21
|
+
snapshot: WebAccessSnapshot,
|
|
22
|
+
): AccessSnapshotIdentity {
|
|
23
|
+
const capabilities = collectCapabilities(snapshot);
|
|
24
|
+
const signature = JSON.stringify({
|
|
25
|
+
actor: {
|
|
26
|
+
id: snapshot.actor.id,
|
|
27
|
+
type: snapshot.actor.type,
|
|
28
|
+
roles: sorted(snapshot.actor.roleCodes),
|
|
29
|
+
permissions: sorted(snapshot.actor.permissionKeys),
|
|
30
|
+
},
|
|
31
|
+
features: [...snapshot.features]
|
|
32
|
+
.map(({ key, enabled, visible, allowed, reason }) => ({
|
|
33
|
+
key,
|
|
34
|
+
enabled,
|
|
35
|
+
visible,
|
|
36
|
+
allowed,
|
|
37
|
+
reason,
|
|
38
|
+
}))
|
|
39
|
+
.sort(byKey),
|
|
40
|
+
menus: normalizeMenus(snapshot.menus),
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
signature,
|
|
44
|
+
fingerprint: `access-v1-${fnv1a(signature)}`,
|
|
45
|
+
capabilities,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function describeAccessSnapshotChange(
|
|
50
|
+
previous: AccessSnapshotIdentity,
|
|
51
|
+
current: AccessSnapshotIdentity,
|
|
52
|
+
): AccessSnapshotChange | undefined {
|
|
53
|
+
if (previous.signature === current.signature) return undefined;
|
|
54
|
+
const removedCapabilities = difference(
|
|
55
|
+
previous.capabilities,
|
|
56
|
+
current.capabilities,
|
|
57
|
+
);
|
|
58
|
+
const addedCapabilities = difference(
|
|
59
|
+
current.capabilities,
|
|
60
|
+
previous.capabilities,
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
kind: removedCapabilities.length
|
|
64
|
+
? "reduced"
|
|
65
|
+
: addedCapabilities.length
|
|
66
|
+
? "expanded"
|
|
67
|
+
: "changed",
|
|
68
|
+
previousFingerprint: previous.fingerprint,
|
|
69
|
+
currentFingerprint: current.fingerprint,
|
|
70
|
+
removedCapabilities,
|
|
71
|
+
addedCapabilities,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectCapabilities(snapshot: WebAccessSnapshot): ReadonlySet<string> {
|
|
76
|
+
const capabilities = new Set(
|
|
77
|
+
snapshot.actor.permissionKeys.map((key) => `permission:${key}`),
|
|
78
|
+
);
|
|
79
|
+
for (const feature of snapshot.features) {
|
|
80
|
+
if (feature.allowed) capabilities.add(`feature:${feature.key}`);
|
|
81
|
+
}
|
|
82
|
+
collectMenuCapabilities(snapshot.menus, capabilities);
|
|
83
|
+
return capabilities;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectMenuCapabilities(
|
|
87
|
+
menus: readonly MenuNode[],
|
|
88
|
+
capabilities: Set<string>,
|
|
89
|
+
): void {
|
|
90
|
+
for (const menu of menus) {
|
|
91
|
+
if (menu.visible) capabilities.add(`menu:${menu.key}`);
|
|
92
|
+
collectMenuCapabilities(menu.children || [], capabilities);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeMenus(menus: readonly MenuNode[]): object[] {
|
|
97
|
+
return [...menus]
|
|
98
|
+
.map((menu) => ({
|
|
99
|
+
key: menu.key,
|
|
100
|
+
path: menu.path || "",
|
|
101
|
+
visible: menu.visible,
|
|
102
|
+
permissionKey: menu.permissionKey || "",
|
|
103
|
+
featureKey: menu.featureKey || "",
|
|
104
|
+
children: normalizeMenus(menu.children || []),
|
|
105
|
+
}))
|
|
106
|
+
.sort(byKey);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function difference(
|
|
110
|
+
left: ReadonlySet<string>,
|
|
111
|
+
right: ReadonlySet<string>,
|
|
112
|
+
): string[] {
|
|
113
|
+
return [...left].filter((item) => !right.has(item)).sort();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sorted(values: readonly string[]): string[] {
|
|
117
|
+
return [...new Set(values)].sort();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function byKey(left: { key: string }, right: { key: string }): number {
|
|
121
|
+
return left.key.localeCompare(right.key);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fnv1a(value: string): string {
|
|
125
|
+
let hash = 0x811c9dc5;
|
|
126
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
127
|
+
hash ^= value.charCodeAt(index);
|
|
128
|
+
hash = Math.imul(hash, 0x01000193);
|
|
129
|
+
}
|
|
130
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
131
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { WebAccessSnapshot } from "@/layout/navigation";
|
|
3
|
+
import {
|
|
4
|
+
accessSnapshotIdentity,
|
|
5
|
+
describeAccessSnapshotChange,
|
|
6
|
+
} from "./access-snapshot-change";
|
|
7
|
+
|
|
8
|
+
describe("权限快照变化", () => {
|
|
9
|
+
test("不同返回顺序生成相同稳定指纹", () => {
|
|
10
|
+
const first = snapshot();
|
|
11
|
+
const second = snapshot();
|
|
12
|
+
second.actor.permissionKeys.reverse();
|
|
13
|
+
second.actor.roleCodes.reverse();
|
|
14
|
+
second.features.reverse();
|
|
15
|
+
second.menus.reverse();
|
|
16
|
+
|
|
17
|
+
expect(accessSnapshotIdentity(first).fingerprint).toBe(
|
|
18
|
+
accessSnapshotIdentity(second).fingerprint,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("权限或已授权 Feature 被移除时归类为收紧", () => {
|
|
23
|
+
const previous = accessSnapshotIdentity(snapshot());
|
|
24
|
+
const currentSnapshot = snapshot();
|
|
25
|
+
currentSnapshot.actor.permissionKeys =
|
|
26
|
+
currentSnapshot.actor.permissionKeys.filter(
|
|
27
|
+
(key) => key !== "system.user.manage",
|
|
28
|
+
);
|
|
29
|
+
currentSnapshot.features[0]!.allowed = false;
|
|
30
|
+
const change = describeAccessSnapshotChange(
|
|
31
|
+
previous,
|
|
32
|
+
accessSnapshotIdentity(currentSnapshot),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(change?.kind).toBe("reduced");
|
|
36
|
+
expect(change?.removedCapabilities).toEqual([
|
|
37
|
+
"feature:system.user",
|
|
38
|
+
"permission:system.user.manage",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("只有新增能力时归类为扩展", () => {
|
|
43
|
+
const previousSnapshot = snapshot();
|
|
44
|
+
previousSnapshot.actor.permissionKeys = ["system.user.read"];
|
|
45
|
+
const currentSnapshot = snapshot();
|
|
46
|
+
const change = describeAccessSnapshotChange(
|
|
47
|
+
accessSnapshotIdentity(previousSnapshot),
|
|
48
|
+
accessSnapshotIdentity(currentSnapshot),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(change?.kind).toBe("expanded");
|
|
52
|
+
expect(change?.addedCapabilities).toContain(
|
|
53
|
+
"permission:system.user.manage",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function snapshot(): WebAccessSnapshot {
|
|
59
|
+
return {
|
|
60
|
+
actor: {
|
|
61
|
+
id: "user_1",
|
|
62
|
+
type: "user",
|
|
63
|
+
name: "平台管理员",
|
|
64
|
+
roleCodes: ["operator", "admin"],
|
|
65
|
+
permissionKeys: ["system.user.read", "system.user.manage"],
|
|
66
|
+
},
|
|
67
|
+
menus: [
|
|
68
|
+
{
|
|
69
|
+
key: "system.role",
|
|
70
|
+
title: "角色",
|
|
71
|
+
type: "menu",
|
|
72
|
+
order: 2,
|
|
73
|
+
visible: true,
|
|
74
|
+
path: "/admin/system/roles",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "system.user",
|
|
78
|
+
title: "用户",
|
|
79
|
+
type: "menu",
|
|
80
|
+
order: 1,
|
|
81
|
+
visible: true,
|
|
82
|
+
path: "/admin/system/users",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
permissions: [],
|
|
86
|
+
features: [
|
|
87
|
+
{
|
|
88
|
+
key: "system.user",
|
|
89
|
+
enabled: true,
|
|
90
|
+
visible: "visible",
|
|
91
|
+
allowed: true,
|
|
92
|
+
reason: "allowed",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "system.role",
|
|
96
|
+
enabled: true,
|
|
97
|
+
visible: "visible",
|
|
98
|
+
allowed: true,
|
|
99
|
+
reason: "allowed",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { readonly, shallowReadonly, shallowRef } from "vue";
|
|
2
2
|
import type { WebAccessSnapshot } from "@/layout/navigation";
|
|
3
3
|
import { loadAccessSnapshot } from "@/services/access-api";
|
|
4
|
+
import { isApiErrorStatus } from "@/services/http";
|
|
5
|
+
import {
|
|
6
|
+
accessSnapshotIdentity,
|
|
7
|
+
describeAccessSnapshotChange,
|
|
8
|
+
type AccessSnapshotChange,
|
|
9
|
+
} from "./access-snapshot-change";
|
|
4
10
|
|
|
5
11
|
const snapshot = shallowRef<WebAccessSnapshot>();
|
|
6
12
|
const loading = shallowRef(false);
|
|
7
13
|
const error = shallowRef("");
|
|
14
|
+
const failureReason = shallowRef<
|
|
15
|
+
"admin-access-revoked" | "snapshot-unavailable" | undefined
|
|
16
|
+
>();
|
|
17
|
+
const fingerprint = shallowRef("");
|
|
18
|
+
const version = shallowRef(0);
|
|
19
|
+
const change = shallowRef<AccessSnapshotChange>();
|
|
8
20
|
let pending: Promise<WebAccessSnapshot> | undefined;
|
|
9
21
|
let generation = 0;
|
|
22
|
+
let identity: ReturnType<typeof accessSnapshotIdentity> | undefined;
|
|
10
23
|
|
|
11
24
|
async function load(force = false): Promise<WebAccessSnapshot> {
|
|
12
25
|
if (pending) return pending;
|
|
13
26
|
if (snapshot.value && !force) return snapshot.value;
|
|
14
27
|
loading.value = true;
|
|
15
|
-
error.value = "";
|
|
28
|
+
if (!snapshot.value) error.value = "";
|
|
16
29
|
const requestGeneration = generation;
|
|
17
30
|
const operation = loadAccessSnapshot()
|
|
18
31
|
.then((result) => {
|
|
19
|
-
if (requestGeneration === generation)
|
|
32
|
+
if (requestGeneration === generation) acceptSnapshot(result);
|
|
20
33
|
return result;
|
|
21
34
|
})
|
|
22
35
|
.catch((currentError: unknown) => {
|
|
@@ -25,6 +38,9 @@ async function load(force = false): Promise<WebAccessSnapshot> {
|
|
|
25
38
|
currentError instanceof Error
|
|
26
39
|
? currentError.message
|
|
27
40
|
: String(currentError);
|
|
41
|
+
failureReason.value = isApiErrorStatus(currentError, 403)
|
|
42
|
+
? "admin-access-revoked"
|
|
43
|
+
: "snapshot-unavailable";
|
|
28
44
|
}
|
|
29
45
|
throw currentError;
|
|
30
46
|
})
|
|
@@ -36,11 +52,34 @@ async function load(force = false): Promise<WebAccessSnapshot> {
|
|
|
36
52
|
return operation;
|
|
37
53
|
}
|
|
38
54
|
|
|
55
|
+
function acceptSnapshot(result: WebAccessSnapshot): void {
|
|
56
|
+
const nextIdentity = accessSnapshotIdentity(result);
|
|
57
|
+
const nextChange = identity
|
|
58
|
+
? describeAccessSnapshotChange(identity, nextIdentity)
|
|
59
|
+
: undefined;
|
|
60
|
+
snapshot.value = result;
|
|
61
|
+
error.value = "";
|
|
62
|
+
failureReason.value = undefined;
|
|
63
|
+
fingerprint.value = nextIdentity.fingerprint;
|
|
64
|
+
if (nextChange) {
|
|
65
|
+
version.value += 1;
|
|
66
|
+
change.value = nextChange;
|
|
67
|
+
} else if (!identity) {
|
|
68
|
+
version.value = 1;
|
|
69
|
+
}
|
|
70
|
+
identity = nextIdentity;
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
export function resetAccessSnapshot(): void {
|
|
40
74
|
generation += 1;
|
|
41
75
|
snapshot.value = undefined;
|
|
42
76
|
loading.value = false;
|
|
43
77
|
error.value = "";
|
|
78
|
+
failureReason.value = undefined;
|
|
79
|
+
fingerprint.value = "";
|
|
80
|
+
version.value = 0;
|
|
81
|
+
change.value = undefined;
|
|
82
|
+
identity = undefined;
|
|
44
83
|
pending = undefined;
|
|
45
84
|
}
|
|
46
85
|
|
|
@@ -49,7 +88,14 @@ export function useAccessSnapshot() {
|
|
|
49
88
|
snapshot: shallowReadonly(snapshot),
|
|
50
89
|
loading: readonly(loading),
|
|
51
90
|
error: readonly(error),
|
|
91
|
+
failureReason: readonly(failureReason),
|
|
92
|
+
fingerprint: readonly(fingerprint),
|
|
93
|
+
version: readonly(version),
|
|
94
|
+
change: shallowReadonly(change),
|
|
52
95
|
ensureLoaded: () => load(false),
|
|
53
96
|
refresh: () => load(true),
|
|
97
|
+
acknowledgeChange: () => {
|
|
98
|
+
change.value = undefined;
|
|
99
|
+
},
|
|
54
100
|
};
|
|
55
101
|
}
|
|
@@ -36,4 +36,58 @@ describe("权限快照 single-flight", () => {
|
|
|
36
36
|
expect(first).toEqual(snapshot);
|
|
37
37
|
expect(second).toEqual(snapshot);
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
test("运行中快照变化保留版本、指纹和能力收紧事实", async () => {
|
|
41
|
+
const initial = accessSnapshot(["system.user.read"]);
|
|
42
|
+
const reduced = accessSnapshot([]);
|
|
43
|
+
mocks.loadAccessSnapshot
|
|
44
|
+
.mockResolvedValueOnce(initial)
|
|
45
|
+
.mockResolvedValueOnce(reduced);
|
|
46
|
+
const access = useAccessSnapshot();
|
|
47
|
+
|
|
48
|
+
await access.ensureLoaded();
|
|
49
|
+
const initialFingerprint = access.fingerprint.value;
|
|
50
|
+
await access.refresh();
|
|
51
|
+
|
|
52
|
+
expect(access.version.value).toBe(2);
|
|
53
|
+
expect(access.fingerprint.value).not.toBe(initialFingerprint);
|
|
54
|
+
expect(access.change.value).toMatchObject({
|
|
55
|
+
kind: "reduced",
|
|
56
|
+
previousFingerprint: initialFingerprint,
|
|
57
|
+
currentFingerprint: access.fingerprint.value,
|
|
58
|
+
removedCapabilities: ["permission:system.user.read"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
access.acknowledgeChange();
|
|
62
|
+
expect(access.change.value).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("刷新失败时保留旧快照并进入显式安全降级状态", async () => {
|
|
66
|
+
const initial = accessSnapshot(["system.user.read"]);
|
|
67
|
+
mocks.loadAccessSnapshot
|
|
68
|
+
.mockResolvedValueOnce(initial)
|
|
69
|
+
.mockRejectedValueOnce(new Error("network unavailable"));
|
|
70
|
+
const access = useAccessSnapshot();
|
|
71
|
+
await access.ensureLoaded();
|
|
72
|
+
|
|
73
|
+
await expect(access.refresh()).rejects.toThrow("network unavailable");
|
|
74
|
+
|
|
75
|
+
expect(access.snapshot.value).toEqual(initial);
|
|
76
|
+
expect(access.error.value).toBe("network unavailable");
|
|
77
|
+
});
|
|
39
78
|
});
|
|
79
|
+
|
|
80
|
+
function accessSnapshot(permissionKeys: string[]) {
|
|
81
|
+
return {
|
|
82
|
+
actor: {
|
|
83
|
+
id: "user_1",
|
|
84
|
+
type: "user" as const,
|
|
85
|
+
name: "测试员",
|
|
86
|
+
roleCodes: ["member"],
|
|
87
|
+
permissionKeys,
|
|
88
|
+
},
|
|
89
|
+
menus: [],
|
|
90
|
+
permissions: [],
|
|
91
|
+
features: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted } from "vue";
|
|
2
|
+
import { useAccessSnapshot } from "./useAccessSnapshot";
|
|
3
|
+
|
|
4
|
+
const refreshIntervalMilliseconds = 60_000;
|
|
5
|
+
const eventRefreshCooldownMilliseconds = 5_000;
|
|
6
|
+
|
|
7
|
+
export function useAccessSnapshotMonitor(): void {
|
|
8
|
+
const access = useAccessSnapshot();
|
|
9
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
10
|
+
let lastEventRefresh = 0;
|
|
11
|
+
|
|
12
|
+
function refresh(): void {
|
|
13
|
+
void access.refresh().catch(() => {
|
|
14
|
+
// 失败状态由权限快照统一边界展示,事件处理器不产生未处理拒绝。
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function refreshAfterBrowserEvent(): void {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (now - lastEventRefresh < eventRefreshCooldownMilliseconds) return;
|
|
21
|
+
lastEventRefresh = now;
|
|
22
|
+
refresh();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function refreshWhenVisible(): void {
|
|
26
|
+
if (document.visibilityState === "visible") refreshAfterBrowserEvent();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onMounted(() => {
|
|
30
|
+
interval = setInterval(refresh, refreshIntervalMilliseconds);
|
|
31
|
+
window.addEventListener("focus", refreshAfterBrowserEvent);
|
|
32
|
+
window.addEventListener("online", refreshAfterBrowserEvent);
|
|
33
|
+
document.addEventListener("visibilitychange", refreshWhenVisible);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
onBeforeUnmount(() => {
|
|
37
|
+
if (interval) clearInterval(interval);
|
|
38
|
+
window.removeEventListener("focus", refreshAfterBrowserEvent);
|
|
39
|
+
window.removeEventListener("online", refreshAfterBrowserEvent);
|
|
40
|
+
document.removeEventListener("visibilitychange", refreshWhenVisible);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|
|
3
3
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
|
4
4
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
5
5
|
import DevelopmentAuthWarning from "@/components/auth/DevelopmentAuthWarning.vue";
|
|
6
|
+
import AccessSnapshotBoundary from "@/components/common/AccessSnapshotBoundary.vue";
|
|
6
7
|
// @windy-module system.license begin
|
|
7
8
|
import LicenseExpiryBanner from "@/components/license/LicenseExpiryBanner.vue";
|
|
8
9
|
import { useLicenseReminderRefresh } from "@/composables/useLicenseReminderRefresh";
|
|
@@ -24,32 +25,34 @@ useLicenseReminderRefresh(licenseRuntime.refresh);
|
|
|
24
25
|
|
|
25
26
|
<template>
|
|
26
27
|
<TooltipProvider :delay-duration="0">
|
|
27
|
-
<
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
28
|
+
<AccessSnapshotBoundary>
|
|
29
|
+
<SidebarProvider class="h-svh min-h-0 overflow-hidden">
|
|
30
|
+
<AppSidebar />
|
|
31
|
+
<SidebarInset class="h-svh min-w-0 overflow-hidden">
|
|
32
|
+
<AppHeader />
|
|
33
|
+
<div
|
|
34
|
+
data-testid="app-content-scroll"
|
|
35
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
36
|
+
>
|
|
37
|
+
<div v-if="developmentAuth.configured" class="px-6 pt-4">
|
|
38
|
+
<DevelopmentAuthWarning />
|
|
39
|
+
</div>
|
|
40
|
+
<!-- @windy-module system.license begin -->
|
|
41
|
+
<LicenseExpiryBanner
|
|
42
|
+
v-if="licenseRuntime.snapshot.value"
|
|
43
|
+
:snapshot="licenseRuntime.snapshot.value"
|
|
44
|
+
/>
|
|
45
|
+
<!-- @windy-module system.license end -->
|
|
46
|
+
<main class="p-6">
|
|
47
|
+
<slot />
|
|
48
|
+
</main>
|
|
37
49
|
</div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<main class="p-6">
|
|
45
|
-
<slot />
|
|
46
|
-
</main>
|
|
47
|
-
</div>
|
|
48
|
-
</SidebarInset>
|
|
49
|
-
</SidebarProvider>
|
|
50
|
-
<!-- @windy-module system.settings begin -->
|
|
51
|
-
<GlobalWatermark />
|
|
52
|
-
<!-- @windy-module system.settings end -->
|
|
50
|
+
</SidebarInset>
|
|
51
|
+
</SidebarProvider>
|
|
52
|
+
<!-- @windy-module system.settings begin -->
|
|
53
|
+
<GlobalWatermark />
|
|
54
|
+
<!-- @windy-module system.settings end -->
|
|
55
|
+
</AccessSnapshotBoundary>
|
|
53
56
|
<Toaster />
|
|
54
57
|
</TooltipProvider>
|
|
55
58
|
</template>
|
|
@@ -35,6 +35,7 @@ describe("AppShell", () => {
|
|
|
35
35
|
stubs: {
|
|
36
36
|
AppHeader: { template: '<header data-testid="app-header" />' },
|
|
37
37
|
AppSidebar: { template: '<aside data-testid="app-sidebar" />' },
|
|
38
|
+
AccessSnapshotBoundary: passthroughStub("access-snapshot-boundary"),
|
|
38
39
|
DevelopmentAuthWarning: true,
|
|
39
40
|
GlobalWatermark: true,
|
|
40
41
|
// @windy-module system.license begin
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RouteRecordRaw } from "vue-router";
|
|
2
|
+
import type { BusinessAdminRouteRegistration } from "@/app/business-routes";
|
|
3
|
+
|
|
4
|
+
export function materializeBusinessAdminRoutes(
|
|
5
|
+
registrations: readonly BusinessAdminRouteRegistration[],
|
|
6
|
+
): RouteRecordRaw[] {
|
|
7
|
+
assertBusinessAdminRouteRegistrations(registrations);
|
|
8
|
+
return registrations.map((registration) => ({
|
|
9
|
+
path: registration.path,
|
|
10
|
+
name: registration.name,
|
|
11
|
+
component: registration.component,
|
|
12
|
+
props: registration.props,
|
|
13
|
+
meta: {
|
|
14
|
+
title: registration.title,
|
|
15
|
+
layout: "admin",
|
|
16
|
+
businessRouteKey: registration.key,
|
|
17
|
+
featureKey: registration.featureKey,
|
|
18
|
+
permissionKey: registration.permissionKey,
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertNoRouteCollisions(
|
|
24
|
+
routes: readonly RouteRecordRaw[],
|
|
25
|
+
): void {
|
|
26
|
+
const paths = new Set<string>();
|
|
27
|
+
const names = new Set<string>();
|
|
28
|
+
for (const route of routes) {
|
|
29
|
+
if (paths.has(route.path)) {
|
|
30
|
+
throw new Error(`Web Route 路径重复:${route.path}`);
|
|
31
|
+
}
|
|
32
|
+
paths.add(route.path);
|
|
33
|
+
if (typeof route.name !== "string") continue;
|
|
34
|
+
if (names.has(route.name)) {
|
|
35
|
+
throw new Error(`Web Route 名称重复:${route.name}`);
|
|
36
|
+
}
|
|
37
|
+
names.add(route.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertBusinessAdminRouteRegistrations(
|
|
42
|
+
registrations: readonly BusinessAdminRouteRegistration[],
|
|
43
|
+
): void {
|
|
44
|
+
const keys = new Set<string>();
|
|
45
|
+
for (const registration of registrations) {
|
|
46
|
+
for (const [field, value] of [
|
|
47
|
+
["key", registration.key],
|
|
48
|
+
["path", registration.path],
|
|
49
|
+
["title", registration.title],
|
|
50
|
+
["featureKey", registration.featureKey],
|
|
51
|
+
["permissionKey", registration.permissionKey],
|
|
52
|
+
] as const) {
|
|
53
|
+
if (!value.trim()) {
|
|
54
|
+
throw new Error(`业务 Web Route 的 ${field} 不能为空`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!registration.path.startsWith("/")) {
|
|
58
|
+
throw new Error(`业务 Web Route 必须使用绝对路径:${registration.path}`);
|
|
59
|
+
}
|
|
60
|
+
if (registration.path.includes(":pathMatch")) {
|
|
61
|
+
throw new Error(`业务 Web Route 不能注册兜底路径:${registration.path}`);
|
|
62
|
+
}
|
|
63
|
+
if (keys.has(registration.key)) {
|
|
64
|
+
throw new Error(`业务 Web Route Key 重复:${registration.key}`);
|
|
65
|
+
}
|
|
66
|
+
keys.add(registration.key);
|
|
67
|
+
}
|
|
68
|
+
}
|