@stackonward/cli 1.2.1 → 1.3.1
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/dist/index.js +17 -10
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/app.vue +9 -6
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +268 -33
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationHeader.vue +7 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationShell.vue +1 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ApplicationSidebar.vue +4 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/BackToTop.vue +4 -12
- package/dist/resources/components/nuxt/site-source/app/components/site/ConsentSettingsButton.vue +24 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentArticlePage.vue +18 -6
- package/dist/resources/components/nuxt/site-source/app/components/site/ContentListPage.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/CookieConsent.vue +44 -4
- package/dist/resources/components/nuxt/site-source/app/components/site/LocaleSwitcher.vue +57 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingFooter.vue +11 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingHeader.vue +31 -19
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +9 -9
- package/dist/resources/components/nuxt/site-source/app/components/site/SiteBrand.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/StaticPage.vue +2 -1
- package/dist/resources/components/nuxt/site-source/app/components/site/ThemeSwitcher.vue +63 -0
- package/dist/resources/components/nuxt/site-source/app/components/site/ToastViewport.vue +3 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +299 -51
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +49 -6
- package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +4 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +29 -11
- package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +43 -0
- package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +146 -59
- package/dist/resources/components/registry.json +30 -0
- package/dist/resources/registry.json +3 -3
- package/dist/resources/templates/nuxt/app/app.config.ts +83 -38
- package/dist/resources/templates/nuxt/app/i18n/locales/en.json +124 -0
- package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +68 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +16 -0
- package/dist/resources/templates/nuxt/package.json +2 -2
- package/dist/resources/templates/nuxt/test/unit/consent.test.ts +289 -0
- package/dist/resources/templates/nuxt/vitest.config.ts +6 -0
- package/package.json +3 -3
|
@@ -1,71 +1,319 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
import { computed, readonly } from "vue";
|
|
2
|
+
import type { ComputedRef, Ref } from "vue";
|
|
3
|
+
import type {
|
|
4
|
+
ConsentInitializationResult,
|
|
5
|
+
ConsentMutationResult,
|
|
6
|
+
ConsentState,
|
|
7
|
+
ConsentValue,
|
|
8
|
+
SiteConsentController,
|
|
9
|
+
SiteShellConsentConfig,
|
|
10
|
+
} from "~/types/site-shell";
|
|
11
|
+
|
|
12
|
+
const CONSENT_RECORD_VERSION = 1;
|
|
13
|
+
const MAX_TIMER_DELAY = 2_147_483_647;
|
|
14
|
+
|
|
15
|
+
interface ConsentRecord {
|
|
16
|
+
readonly version: typeof CONSENT_RECORD_VERSION;
|
|
17
|
+
readonly policyRevision: string;
|
|
18
|
+
readonly value: ConsentValue;
|
|
19
|
+
readonly expiresAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ConsentAnalyticsBridge {
|
|
23
|
+
readonly available: () => boolean;
|
|
24
|
+
readonly grant: () => void;
|
|
25
|
+
readonly deny: () => void;
|
|
26
|
+
readonly reset: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CreateSiteConsentControllerOptions {
|
|
30
|
+
readonly state: Ref<ConsentState>;
|
|
31
|
+
readonly config: Readonly<ComputedRef<SiteShellConsentConfig>>;
|
|
32
|
+
readonly analytics: ConsentAnalyticsBridge;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 在应用生命周期内协调同意状态、持久化和分析开关。 */
|
|
36
|
+
export function createSiteConsentController(
|
|
37
|
+
options: CreateSiteConsentControllerOptions,
|
|
38
|
+
): SiteConsentController {
|
|
39
|
+
let initialized = false;
|
|
40
|
+
let disposed = false;
|
|
41
|
+
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
let initializationResult: ConsentInitializationResult = {
|
|
43
|
+
status: "undecided",
|
|
44
|
+
cleanup: "not-required",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const visible = computed(
|
|
48
|
+
() =>
|
|
49
|
+
options.config.value.enabled &&
|
|
50
|
+
options.analytics.available() &&
|
|
51
|
+
options.state.value.ready &&
|
|
52
|
+
(options.state.value.decision === null || options.state.value.settingsOpen),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
function assertAvailable(
|
|
56
|
+
operation: ConsentLifecycleError["operation"],
|
|
57
|
+
requiresReady = true,
|
|
58
|
+
): void {
|
|
59
|
+
if (disposed || (requiresReady && !options.state.value.ready)) {
|
|
60
|
+
throw new ConsentLifecycleError(operation);
|
|
19
61
|
}
|
|
20
62
|
}
|
|
21
63
|
|
|
22
|
-
function
|
|
23
|
-
if (
|
|
24
|
-
|
|
64
|
+
function cancelExpiry(): void {
|
|
65
|
+
if (expiryTimer !== undefined) {
|
|
66
|
+
clearTimeout(expiryTimer);
|
|
67
|
+
expiryTimer = undefined;
|
|
25
68
|
}
|
|
26
69
|
}
|
|
27
70
|
|
|
28
|
-
|
|
71
|
+
function scheduleExpiry(expiresAt: number): void {
|
|
72
|
+
cancelExpiry();
|
|
73
|
+
const remaining = expiresAt - Date.now();
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
expireCurrentDecision();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
expiryTimer = setTimeout(() => restoreStoredDecision(), Math.min(remaining, MAX_TIMER_DELAY));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseConsentRecord(raw: string): ConsentRecord | null {
|
|
82
|
+
let candidate: unknown;
|
|
29
83
|
try {
|
|
30
|
-
|
|
84
|
+
candidate = JSON.parse(raw);
|
|
31
85
|
} catch {
|
|
32
|
-
|
|
33
|
-
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
89
|
+
return null;
|
|
34
90
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
91
|
+
const record = candidate as Record<string, unknown>;
|
|
92
|
+
if (
|
|
93
|
+
record.version !== CONSENT_RECORD_VERSION ||
|
|
94
|
+
record.policyRevision !== options.config.value.policyRevision ||
|
|
95
|
+
(record.value !== "accepted" && record.value !== "rejected") ||
|
|
96
|
+
typeof record.expiresAt !== "string"
|
|
97
|
+
) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const expiresAt = Date.parse(record.expiresAt);
|
|
101
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
version: CONSENT_RECORD_VERSION,
|
|
106
|
+
policyRevision: record.policyRevision,
|
|
107
|
+
value: record.value,
|
|
108
|
+
expiresAt: record.expiresAt,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
38
111
|
|
|
39
|
-
|
|
112
|
+
function updateUndecided(
|
|
113
|
+
persistence: ConsentState["persistence"],
|
|
114
|
+
error: ConsentState["error"],
|
|
115
|
+
): void {
|
|
116
|
+
cancelExpiry();
|
|
117
|
+
options.analytics.reset();
|
|
118
|
+
options.state.value = {
|
|
119
|
+
decision: null,
|
|
120
|
+
ready: true,
|
|
121
|
+
settingsOpen: false,
|
|
122
|
+
persistence,
|
|
123
|
+
error,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
40
126
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
127
|
+
function clearInvalidRecord(): ConsentInitializationResult {
|
|
128
|
+
try {
|
|
129
|
+
window.localStorage.removeItem(options.config.value.storageKey);
|
|
130
|
+
updateUndecided("cleared", null);
|
|
131
|
+
return { status: "undecided", cleanup: "cleared" };
|
|
132
|
+
} catch {
|
|
133
|
+
updateUndecided("cleanup-failed", "clear-failed");
|
|
134
|
+
return { status: "undecided", cleanup: "failed", error: "clear-failed" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
48
137
|
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
138
|
+
function applyRecord(record: ConsentRecord): ConsentInitializationResult {
|
|
139
|
+
const expiresAt = Date.parse(record.expiresAt);
|
|
140
|
+
if (record.value === "accepted") {
|
|
141
|
+
options.analytics.grant();
|
|
142
|
+
} else {
|
|
143
|
+
options.analytics.deny();
|
|
144
|
+
}
|
|
145
|
+
options.state.value = {
|
|
146
|
+
decision: record.value,
|
|
147
|
+
ready: true,
|
|
148
|
+
settingsOpen: false,
|
|
149
|
+
persistence: "persisted",
|
|
150
|
+
error: null,
|
|
151
|
+
};
|
|
152
|
+
scheduleExpiry(expiresAt);
|
|
153
|
+
return { status: "restored", value: record.value };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function restoreStoredDecision(): ConsentInitializationResult {
|
|
157
|
+
let raw: string | null;
|
|
158
|
+
try {
|
|
159
|
+
raw = window.localStorage.getItem(options.config.value.storageKey);
|
|
160
|
+
} catch {
|
|
161
|
+
updateUndecided("none", "read-failed");
|
|
162
|
+
return { status: "storage-unavailable", error: "read-failed" };
|
|
163
|
+
}
|
|
164
|
+
if (raw === null) {
|
|
165
|
+
updateUndecided("none", null);
|
|
166
|
+
return { status: "undecided", cleanup: "not-required" };
|
|
167
|
+
}
|
|
168
|
+
const record = parseConsentRecord(raw);
|
|
169
|
+
return record ? applyRecord(record) : clearInvalidRecord();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function expireCurrentDecision(): void {
|
|
173
|
+
initializationResult = restoreStoredDecision();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onStorage(event: StorageEvent): void {
|
|
177
|
+
if (event.key === options.config.value.storageKey) {
|
|
178
|
+
initializationResult = restoreStoredDecision();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onVisibilityChange(): void {
|
|
183
|
+
if (document.visibilityState === "visible") {
|
|
184
|
+
initializationResult = restoreStoredDecision();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function initialize(): ConsentInitializationResult {
|
|
189
|
+
assertAvailable("initialize", false);
|
|
190
|
+
if (initialized) {
|
|
191
|
+
return initializationResult;
|
|
192
|
+
}
|
|
193
|
+
initialized = true;
|
|
194
|
+
initializationResult = restoreStoredDecision();
|
|
195
|
+
window.addEventListener("storage", onStorage);
|
|
196
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
197
|
+
return initializationResult;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persist(next: ConsentValue): ConsentMutationResult {
|
|
201
|
+
assertAvailable(next === "accepted" ? "accept" : "reject");
|
|
202
|
+
cancelExpiry();
|
|
203
|
+
if (next === "accepted") {
|
|
204
|
+
options.analytics.grant();
|
|
205
|
+
} else {
|
|
206
|
+
options.analytics.deny();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const expiresAt = new Date(
|
|
210
|
+
Date.now() + options.config.value.retentionDays * 24 * 60 * 60 * 1000,
|
|
211
|
+
);
|
|
212
|
+
const record: ConsentRecord = {
|
|
213
|
+
version: CONSENT_RECORD_VERSION,
|
|
214
|
+
policyRevision: options.config.value.policyRevision,
|
|
215
|
+
value: next,
|
|
216
|
+
expiresAt: expiresAt.toISOString(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
window.localStorage.setItem(options.config.value.storageKey, JSON.stringify(record));
|
|
221
|
+
options.state.value = {
|
|
222
|
+
decision: next,
|
|
223
|
+
ready: true,
|
|
224
|
+
settingsOpen: false,
|
|
225
|
+
persistence: "persisted",
|
|
226
|
+
error: null,
|
|
227
|
+
};
|
|
228
|
+
scheduleExpiry(expiresAt.getTime());
|
|
229
|
+
return { status: "persisted", value: next };
|
|
230
|
+
} catch {
|
|
231
|
+
if (next === "rejected") {
|
|
232
|
+
try {
|
|
233
|
+
window.localStorage.removeItem(options.config.value.storageKey);
|
|
234
|
+
options.state.value = {
|
|
235
|
+
decision: next,
|
|
236
|
+
ready: true,
|
|
237
|
+
settingsOpen: true,
|
|
238
|
+
persistence: "cleared",
|
|
239
|
+
error: "write-failed",
|
|
240
|
+
};
|
|
241
|
+
return { status: "cleared", value: next, error: "write-failed" };
|
|
242
|
+
} catch {
|
|
243
|
+
options.state.value = {
|
|
244
|
+
decision: next,
|
|
245
|
+
ready: true,
|
|
246
|
+
settingsOpen: true,
|
|
247
|
+
persistence: "session-only",
|
|
248
|
+
error: "write-and-clear-failed",
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
status: "session-only",
|
|
252
|
+
value: next,
|
|
253
|
+
error: "write-and-clear-failed",
|
|
254
|
+
};
|
|
255
|
+
}
|
|
61
256
|
}
|
|
257
|
+
options.state.value = {
|
|
258
|
+
decision: next,
|
|
259
|
+
ready: true,
|
|
260
|
+
settingsOpen: true,
|
|
261
|
+
persistence: "session-only",
|
|
262
|
+
error: "write-failed",
|
|
263
|
+
};
|
|
264
|
+
return { status: "session-only", value: next, error: "write-failed" };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function openSettings(): void {
|
|
269
|
+
assertAvailable("open-settings");
|
|
270
|
+
options.state.value.settingsOpen = true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function closeSettings(): void {
|
|
274
|
+
assertAvailable("close-settings");
|
|
275
|
+
if (options.state.value.decision === null) {
|
|
276
|
+
throw new ConsentLifecycleError("close-settings");
|
|
277
|
+
}
|
|
278
|
+
options.state.value.settingsOpen = false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function dispose(): void {
|
|
282
|
+
if (disposed) return;
|
|
283
|
+
disposed = true;
|
|
284
|
+
cancelExpiry();
|
|
285
|
+
if (initialized) {
|
|
286
|
+
window.removeEventListener("storage", onStorage);
|
|
287
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
62
288
|
}
|
|
63
289
|
}
|
|
64
290
|
|
|
65
291
|
return {
|
|
66
|
-
|
|
292
|
+
state: readonly(options.state),
|
|
67
293
|
visible,
|
|
68
|
-
|
|
69
|
-
|
|
294
|
+
initialize,
|
|
295
|
+
accept: () => persist("accepted"),
|
|
296
|
+
reject: () => persist("rejected"),
|
|
297
|
+
openSettings,
|
|
298
|
+
closeSettings,
|
|
299
|
+
dispose,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export class ConsentLifecycleError extends Error {
|
|
304
|
+
readonly operation: "initialize" | "accept" | "reject" | "open-settings" | "close-settings";
|
|
305
|
+
|
|
306
|
+
constructor(operation: ConsentLifecycleError["operation"]) {
|
|
307
|
+
super(`Consent operation is unavailable: ${operation}`);
|
|
308
|
+
this.name = "ConsentLifecycleError";
|
|
309
|
+
this.operation = operation;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 返回 Nuxt 应用提供的唯一同意控制器。 */
|
|
314
|
+
export function useConsent(): SiteConsentController {
|
|
315
|
+
const nuxtApp = useNuxtApp() as ReturnType<typeof useNuxtApp> & {
|
|
316
|
+
readonly $siteConsent: SiteConsentController;
|
|
70
317
|
};
|
|
318
|
+
return nuxtApp.$siteConsent;
|
|
71
319
|
}
|
|
@@ -1,11 +1,54 @@
|
|
|
1
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AppNavigationItem,
|
|
3
|
+
LocalizedNavigationItemConfig,
|
|
4
|
+
MarketingNavigationItem,
|
|
5
|
+
ResolvedSiteNavigation,
|
|
6
|
+
} from "~/types/site-shell";
|
|
7
|
+
|
|
8
|
+
/** 将产品配置中的资源键投影为当前语言可渲染的导航。 */
|
|
9
|
+
export function useNavigation(): ResolvedSiteNavigation {
|
|
2
10
|
const site = useSiteShell();
|
|
11
|
+
const { t } = useI18n();
|
|
12
|
+
|
|
13
|
+
function resolveMarketingItem(item: LocalizedNavigationItemConfig): MarketingNavigationItem {
|
|
14
|
+
return {
|
|
15
|
+
label: t(item.labelKey),
|
|
16
|
+
...(item.to === undefined ? {} : { to: item.to }),
|
|
17
|
+
...(item.external === undefined ? {} : { external: item.external }),
|
|
18
|
+
...(item.descriptionKey === undefined ? {} : { description: t(item.descriptionKey) }),
|
|
19
|
+
...(item.children === undefined ? {} : { children: item.children.map(resolveMarketingItem) }),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
3
22
|
|
|
4
23
|
return {
|
|
5
|
-
marketingNav: computed(() => site.value.navigation.marketing),
|
|
6
|
-
appNavGroups: computed(() =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
24
|
+
marketingNav: computed(() => site.value.navigation.marketing.map(resolveMarketingItem)),
|
|
25
|
+
appNavGroups: computed(() =>
|
|
26
|
+
site.value.navigation.app.map((group) => ({
|
|
27
|
+
...(group.labelKey === undefined ? {} : { label: t(group.labelKey) }),
|
|
28
|
+
items: group.items.map(
|
|
29
|
+
(item): AppNavigationItem => ({
|
|
30
|
+
label: t(item.labelKey),
|
|
31
|
+
to: item.to,
|
|
32
|
+
...(item.icon === undefined ? {} : { icon: item.icon }),
|
|
33
|
+
...(item.badge === undefined ? {} : { badge: item.badge }),
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
})),
|
|
37
|
+
),
|
|
38
|
+
footerGroups: computed(() =>
|
|
39
|
+
site.value.navigation.footer.map((group) => ({
|
|
40
|
+
title: t(group.titleKey),
|
|
41
|
+
links: group.links.map(resolveMarketingItem),
|
|
42
|
+
})),
|
|
43
|
+
),
|
|
44
|
+
legalLinks: computed(() => site.value.navigation.legal.map(resolveMarketingItem)),
|
|
45
|
+
socialLinks: computed(() =>
|
|
46
|
+
site.value.navigation.social.map((link) => ({
|
|
47
|
+
label: t(link.labelKey),
|
|
48
|
+
href: link.href,
|
|
49
|
+
})),
|
|
50
|
+
),
|
|
51
|
+
primaryActionLabel: computed(() => t(site.value.primaryActionLabelKey)),
|
|
52
|
+
secondaryActionLabel: computed(() => t(site.value.secondaryActionLabelKey)),
|
|
10
53
|
};
|
|
11
54
|
}
|
|
@@ -15,14 +15,16 @@ function joinUrl(baseUrl: string, path: string): string {
|
|
|
15
15
|
|
|
16
16
|
export function useSeo(input: SeoInput): void {
|
|
17
17
|
const site = useSiteShell();
|
|
18
|
+
const { t } = useI18n();
|
|
18
19
|
const route = useRoute();
|
|
19
20
|
const canonical = computed(() => joinUrl(site.value.url, input.canonicalPath ?? route.path));
|
|
21
|
+
const description = computed(() => input.description ?? t(site.value.descriptionKey));
|
|
20
22
|
|
|
21
23
|
useSeoMeta({
|
|
22
24
|
title: input.title,
|
|
23
|
-
description
|
|
25
|
+
description,
|
|
24
26
|
ogTitle: input.title,
|
|
25
|
-
ogDescription:
|
|
27
|
+
ogDescription: description,
|
|
26
28
|
ogImage: input.image,
|
|
27
29
|
robots: input.robots,
|
|
28
30
|
});
|
|
@@ -1,23 +1,39 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ComputedRef } from "vue";
|
|
2
|
+
import type {
|
|
3
|
+
SiteShellConfig,
|
|
4
|
+
SiteShellConsentConfig,
|
|
5
|
+
StorageDisclosureConfig,
|
|
6
|
+
} from "~/types/site-shell";
|
|
7
|
+
|
|
8
|
+
type SiteShellConfigInput = Omit<
|
|
9
|
+
Partial<SiteShellConfig>,
|
|
10
|
+
"consent" | "storageDisclosures" | "navigation" | "content"
|
|
11
|
+
> & {
|
|
12
|
+
readonly consent?: Partial<SiteShellConsentConfig>;
|
|
13
|
+
readonly storageDisclosures?: readonly StorageDisclosureConfig[];
|
|
14
|
+
readonly navigation?: Partial<SiteShellConfig["navigation"]>;
|
|
15
|
+
readonly content?: Partial<SiteShellConfig["content"]>;
|
|
16
|
+
};
|
|
2
17
|
|
|
3
18
|
const DEFAULT_SITE_SHELL_CONFIG: SiteShellConfig = {
|
|
4
19
|
name: "Product Studio",
|
|
5
|
-
|
|
20
|
+
descriptionKey: "site.description",
|
|
6
21
|
url: "",
|
|
7
22
|
defaultLocale: "en",
|
|
8
23
|
homePath: "/",
|
|
9
24
|
appPath: "/app",
|
|
10
25
|
logoText: "Product Studio",
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
primaryActionLabelKey: "actions.start",
|
|
27
|
+
secondaryActionLabelKey: "actions.signIn",
|
|
13
28
|
secondaryActionPath: "/app",
|
|
14
29
|
consent: {
|
|
15
30
|
enabled: true,
|
|
16
|
-
storageKey: "site-consent",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
storageKey: "site-analytics-consent-v1",
|
|
32
|
+
policyRevision: "1",
|
|
33
|
+
retentionDays: 180,
|
|
34
|
+
policyPath: "/legal/cookies",
|
|
20
35
|
},
|
|
36
|
+
storageDisclosures: [],
|
|
21
37
|
navigation: {
|
|
22
38
|
marketing: [],
|
|
23
39
|
app: [],
|
|
@@ -33,7 +49,7 @@ const DEFAULT_SITE_SHELL_CONFIG: SiteShellConfig = {
|
|
|
33
49
|
},
|
|
34
50
|
};
|
|
35
51
|
|
|
36
|
-
function mergeSiteShellConfig(input:
|
|
52
|
+
function mergeSiteShellConfig(input: SiteShellConfigInput | undefined): SiteShellConfig {
|
|
37
53
|
return {
|
|
38
54
|
...DEFAULT_SITE_SHELL_CONFIG,
|
|
39
55
|
...input,
|
|
@@ -41,6 +57,7 @@ function mergeSiteShellConfig(input: Partial<SiteShellConfig> | undefined): Site
|
|
|
41
57
|
...DEFAULT_SITE_SHELL_CONFIG.consent,
|
|
42
58
|
...input?.consent,
|
|
43
59
|
},
|
|
60
|
+
storageDisclosures: input?.storageDisclosures ?? DEFAULT_SITE_SHELL_CONFIG.storageDisclosures,
|
|
44
61
|
navigation: {
|
|
45
62
|
...DEFAULT_SITE_SHELL_CONFIG.navigation,
|
|
46
63
|
...input?.navigation,
|
|
@@ -52,7 +69,8 @@ function mergeSiteShellConfig(input: Partial<SiteShellConfig> | undefined): Site
|
|
|
52
69
|
};
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
/** 返回当前 Nuxt 应用拥有的站点配置只读视图。 */
|
|
73
|
+
export function useSiteShell(): Readonly<ComputedRef<SiteShellConfig>> {
|
|
74
|
+
const appConfig = useAppConfig() as { readonly siteShell?: SiteShellConfigInput };
|
|
57
75
|
return computed(() => mergeSiteShellConfig(appConfig.siteShell));
|
|
58
76
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ConsentState } from "~/types/site-shell";
|
|
2
|
+
|
|
3
|
+
export default defineNuxtPlugin({
|
|
4
|
+
name: "site:consent",
|
|
5
|
+
dependsOn: ["stackonward:analytics"],
|
|
6
|
+
setup(nuxtApp) {
|
|
7
|
+
const site = useSiteShell();
|
|
8
|
+
const analytics = useAnalytics();
|
|
9
|
+
const analyticsConsent = useAnalyticsConsent();
|
|
10
|
+
const state = useState<ConsentState>("site:consent", () => ({
|
|
11
|
+
decision: null,
|
|
12
|
+
ready: false,
|
|
13
|
+
settingsOpen: false,
|
|
14
|
+
persistence: "none",
|
|
15
|
+
error: null,
|
|
16
|
+
}));
|
|
17
|
+
const controller = createSiteConsentController({
|
|
18
|
+
state,
|
|
19
|
+
config: computed(() => site.value.consent),
|
|
20
|
+
analytics: {
|
|
21
|
+
available: () => analytics.status.value !== "disabled",
|
|
22
|
+
grant: () => {
|
|
23
|
+
analyticsConsent.grant();
|
|
24
|
+
},
|
|
25
|
+
deny: () => {
|
|
26
|
+
analyticsConsent.deny();
|
|
27
|
+
},
|
|
28
|
+
reset: () => {
|
|
29
|
+
analyticsConsent.reset();
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (import.meta.client) {
|
|
35
|
+
onNuxtReady(() => {
|
|
36
|
+
controller.initialize();
|
|
37
|
+
});
|
|
38
|
+
nuxtApp.vueApp.onUnmount(() => controller.dispose());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { provide: { siteConsent: controller } };
|
|
42
|
+
},
|
|
43
|
+
});
|