Compare commits

...
Author SHA1 Message Date
suyx edbefcc738 feat(POSTV1-10): 增加统一交互反馈
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 19:19:33 +08:00
suyx 1713607572 fix(POSTV1-09): 展示项目生成图片
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 19:00:07 +08:00
suyx e4aec01ea6 fix(POSTV1-08): 修复文字拖动闪烁与自动保存
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 18:47:11 +08:00
suyx 468bb5579d fix(POSTV1-07): 自动关闭编辑器操作提示
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 18:01:03 +08:00
suyx 9d2aa879e9 fix(POSTV1-07): 固定画布布局与文字贴纸选择
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 17:59:53 +08:00
suyx d9e39702e0 fix(POSTV1-06): 恢复贴纸与字体运行时资源链路
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 16:58:45 +08:00
suyx 7de633d4c9 fix(POSTV1-05): 修复生成请求与 Worker 重入
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 16:04:11 +08:00
32 changed files with 12936 additions and 106 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
/https?:\/\//i, /https?:\/\//i,
]; ];
const safePauseReasons = new Set([ const safePauseReasons = new Set([
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked", "asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed", "contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown", "model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
"worker_degraded", "worker_state_missing", "worker_stopped", "worker_degraded", "worker_state_missing", "worker_stopped",
+15 -6
View File
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof import("better-sqlite3"); const Database = require("better-sqlite3") as typeof import("better-sqlite3");
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const fixedDirectories = [ const fixedDirectories = [
"db", "db",
"content/references", "content/references",
@@ -82,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
return resolve(candidate); return resolve(candidate);
} }
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
return resolve(configuration.asset_root);
}
export interface ValidatedReadOnlyAssetRoot { export interface ValidatedReadOnlyAssetRoot {
absolute_root: string; absolute_root: string;
ok: true; ok: true;
root_ref: string; root_ref: string;
} }
interface PublicAssetEntry { export interface PublicAssetEntry {
assetId: string; assetId: string;
mimeType: string; mimeType: string;
relativePath: string; relativePath: string;
@@ -317,18 +325,19 @@ export function createPublicAssetResolver(input: {
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root])); const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
const entries = new Map<string, PublicAssetEntry>(); const entries = new Map<string, PublicAssetEntry>();
for (const entry of input.entries) { for (const entry of input.entries) {
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid"); const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated"); if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid"); if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid"); if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
entries.set(entry.assetId, { ...entry }); entries.set(key, { ...entry });
} }
return { return {
read(resourceVersion, assetId) { read(resourceVersion, assetId) {
if (!assetIdPattern.test(assetId)) return undefined; if (!assetIdPattern.test(assetId)) return undefined;
const entry = entries.get(assetId); const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
if (!entry || entry.resourceVersion !== resourceVersion) return undefined; if (!entry) return undefined;
const root = roots.get(entry.rootRef); const root = roots.get(entry.rootRef);
if (!root) return undefined; if (!root) return undefined;
let path: string; let path: string;
+13 -1
View File
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
import { createApp } from "./app.js"; import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js"; import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js"; import { ManagedStorage } from "./managed-storage.js";
import { LatestExportService } from "./latest-exports.js"; import { LatestExportService } from "./latest-exports.js";
import { CreditService } from "./credits.js"; import { CreditService } from "./credits.js";
@@ -25,6 +25,7 @@ import {
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js"; import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
import { StickerReleaseService } from "./sticker-releases.js"; import { StickerReleaseService } from "./sticker-releases.js";
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined; let registration: RegistrationService | undefined;
@@ -36,6 +37,8 @@ let models: ModelConfigurationService | undefined;
let generations: GenerationSubmissionService | undefined; let generations: GenerationSubmissionService | undefined;
let recentAssets: RecentAssetService | undefined; let recentAssets: RecentAssetService | undefined;
let stickers: StickerReleaseService | undefined; let stickers: StickerReleaseService | undefined;
let publicAssets: PublicAssetResolver | undefined;
let assetRootState: RuntimeAssetState | undefined;
let amap: AmapAdapter = new MockAmapAdapter(); let amap: AmapAdapter = new MockAmapAdapter();
let localTestAuth = false; let localTestAuth = false;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
@@ -49,6 +52,13 @@ if (credentialChannelEnabled) {
.digest(); .digest();
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
ensureLocalDataRuntimeDirectories(dataRoot); ensureLocalDataRuntimeDirectories(dataRoot);
const runtimeAssets = loadConfiguredRuntimeAssets({
configFile: instanceConfigPath,
dataRoot,
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
});
publicAssets = runtimeAssets.publicAssets;
assetRootState = runtimeAssets.state;
const databasePath = join(dataRoot, "db", "dada.sqlite3"); const databasePath = join(dataRoot, "db", "dada.sqlite3");
registration = new RegistrationService({ registration = new RegistrationService({
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
@@ -101,6 +111,7 @@ const adminServicesStorage = registration
database: registration.database, database: registration.database,
...(models ? { models } : {}), ...(models ? { models } : {}),
...(storage ? { storage } : {}), ...(storage ? { storage } : {}),
...(assetRootState ? { assetRoot: assetRootState } : {}),
}) })
: undefined; : undefined;
const adminDiagnostics = adminServicesStorage const adminDiagnostics = adminServicesStorage
@@ -120,6 +131,7 @@ const app = await createApp({
...(registration && localTestAuth ? { localTestAuth: true } : {}), ...(registration && localTestAuth ? { localTestAuth: true } : {}),
...(models ? { models } : {}), ...(models ? { models } : {}),
...(projects ? { projects } : {}), ...(projects ? { projects } : {}),
...(publicAssets ? { publicAssets } : {}),
...(registration ? { registration } : {}), ...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}), ...(recentAssets ? { recentAssets } : {}),
...(stickers ? { stickers } : {}), ...(stickers ? { stickers } : {}),
+93
View File
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import {
createPublicAssetResolver,
readConfiguredAssetRoot,
validateReadOnlyAssetRoot,
type PublicAssetEntry,
type PublicAssetResolver,
} from "./local-data-root.js";
const rootRef = "p0a_runtime_assets";
const schemaVersion = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
export interface RuntimeAssetState {
checked_at: string;
configured: boolean;
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
status: "active" | "unavailable";
}
export interface LoadedRuntimeAssets {
publicAssets?: PublicAssetResolver;
state: RuntimeAssetState;
}
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
throw new Error("runtime_asset_manifest_invalid");
}
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
return value.entries.map((candidate) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
const entry = candidate as Record<string, unknown>;
if (
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|| entry.rootRef !== rootRef
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
) throw new Error("runtime_asset_manifest_invalid");
return entry as unknown as PublicAssetEntry;
});
}
function unavailable(
configured: boolean,
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
checkedAt: string,
): LoadedRuntimeAssets {
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
}
export function loadConfiguredRuntimeAssets(input: {
configFile: string;
dataRoot: string;
trustedManifestPath: string;
clock?: () => number;
}): LoadedRuntimeAssets {
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
let assetRoot: string;
try {
assetRoot = readConfiguredAssetRoot(input.configFile);
} catch {
return unavailable(false, "asset_root_state_missing", checkedAt);
}
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
try {
const trustedBytes = readFileSync(input.trustedManifestPath);
const entries = parseRuntimeManifest(trustedBytes);
const validatedRoot = validateReadOnlyAssetRoot({
dataRoot: input.dataRoot,
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
manifestRelativePath: "manifest.json",
root: assetRoot,
rootRef,
});
if (!validatedRoot.ok) {
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
}
return {
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
};
} catch {
return unavailable(true, "asset_manifest_invalid", checkedAt);
}
}
+7 -3
View File
@@ -1,9 +1,12 @@
.editor-page-shell { .editor-page-shell {
min-height: 100vh; height: 100vh;
height: 100dvh;
min-height: 0;
display: grid; display: grid;
grid-template-rows: 56px minmax(0, 1fr) 32px; grid-template-rows: 56px minmax(0, 1fr) 32px;
background: #e8e8e5; background: #e8e8e5;
color: #111111; color: #111111;
overflow: hidden;
} }
.editor-page-shell :focus-visible { .editor-page-shell :focus-visible {
@@ -124,6 +127,7 @@
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px; grid-template-columns: 280px minmax(0, 1fr) 320px;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.editor-assets-panel, .editor-assets-panel,
@@ -542,13 +546,13 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; } .editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; } .editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); } .editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
.editor-history-actions { order: 3; } .editor-history-actions { order: 3; }
.editor-save-status { order: 4; flex: 1 1 128px; } .editor-save-status { order: 4; flex: 1 1 128px; }
.editor-toolbar-controls > button { display: block; order: 5; } .editor-toolbar-controls > button { display: block; order: 5; }
.editor-layout { grid-template-columns: 1fr; } .editor-layout { grid-template-columns: 1fr; overflow: visible; }
.editor-assets-panel, .editor-inspector { border: 0; } .editor-assets-panel, .editor-inspector { border: 0; }
.editor-assets-panel { order: 2; } .editor-assets-panel { order: 2; }
.editor-inspector { order: 3; } .editor-inspector { order: 3; }
+128 -40
View File
@@ -86,6 +86,14 @@ interface EditorExportResult {
status: ExportFlowStatus; status: ExportFlowStatus;
} }
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
if (!textEdit) return canvasState;
return {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
};
}
interface EditorProject { interface EditorProject {
canvas_state?: CanvasState; canvas_state?: CanvasState;
created_at: string; created_at: string;
@@ -155,11 +163,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined); const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined); const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined); const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]); const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined); const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard()); const conflictExportGuardRef = useRef(new ConflictExportGuard());
const candidateMenuRef = useRef<HTMLDivElement | null>(null); const candidateMenuRef = useRef<HTMLDivElement | null>(null);
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null); const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
function showNotice(message: string) {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
setNotice(message);
noticeTimerRef.current = setTimeout(() => {
setNotice("");
noticeTimerRef.current = undefined;
}, 3_000);
}
useEffect(() => () => {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
}, []);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -175,7 +198,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setDraftAdjustments(initial.background.adjustments); setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial); historyRef.current = new CanvasEditHistory(initial);
elementControllerRef.current = new CanvasElementController(initial); elementControllerRef.current = new CanvasElementController(initial);
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); }); }).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
return () => { active = false; }; return () => { active = false; };
}, [projectId]); }, [projectId]);
@@ -261,6 +284,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
}); });
}, [canvasState, selectedIds.join("|")]); }, [canvasState, selectedIds.join("|")]);
useEffect(() => {
if (textEdit) commitTextDraftAutomatically(textEdit);
}, [textEdit?.draft]);
async function ensureFont(fontId: string, url: string, retry = false) { async function ensureFont(fontId: string, url: string, retry = false) {
const current = fontStatuses[fontId]; const current = fontStatuses[fontId];
if (current === "ready" || (current === "unavailable" && !retry)) return current; if (current === "ready" || (current === "unavailable" && !retry)) return current;
@@ -277,24 +304,34 @@ export function EditorPage({ projectId }: { projectId: string }) {
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true))); await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
} }
function commitCanvas(next: CanvasState) { function finalizeTextHistory() {
const pending = textHistoryRef.current;
if (!pending) return undefined;
textHistoryRef.current = undefined;
historyRef.current?.commit(pending.last);
return pending.last;
}
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
if (!project || saveStatus === "conflicted") return; if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next); const finalizedText = finalizeTextHistory();
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next); elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []); setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next); setCanvasState(next);
setDraftAdjustments(next.background.adjustments); setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name }); queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined); if (!options.preserveTextEdit) setTextEdit(undefined);
} }
function applyPreview() { function applyPreview() {
if (!canvasState) return; if (!canvasState) return;
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments)); commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
setNotice("底图调整已提交"); showNotice("底图调整已提交");
} }
function undo() { function undo() {
finalizeTextHistory();
const previous = historyRef.current?.undo(); const previous = historyRef.current?.undo();
if (previous) { if (previous) {
elementControllerRef.current?.replaceState(previous); elementControllerRef.current?.replaceState(previous);
@@ -307,6 +344,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
function redo() { function redo() {
finalizeTextHistory();
const next = historyRef.current?.redo(); const next = historyRef.current?.redo();
if (next) { if (next) {
elementControllerRef.current?.replaceState(next); elementControllerRef.current?.replaceState(next);
@@ -334,9 +372,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
const palette = await paletteForAsset(pendingBackground); const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette)); commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined); setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置"); showNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch { } catch {
setNotice("新底图无法读取,未更换底图或刷新色卡"); showNotice("新底图无法读取,未更换底图或刷新色卡");
} }
} }
@@ -353,7 +391,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitCanvas(next); commitCanvas(next);
setGuides([]); setGuides([]);
setCandidateMenu(undefined); setCandidateMenu(undefined);
setNotice(message); showNotice(message);
} }
function addSticker(sticker: StaticStickerCatalogItem) { function addSticker(sticker: StaticStickerCatalogItem) {
@@ -369,8 +407,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
})); }));
commitElementOperation(controller, "贴纸已加入画布"); commitElementOperation(controller, "贴纸已加入画布");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("贴纸未能加入画布"); else showNotice("贴纸未能加入画布");
} }
} }
@@ -383,8 +421,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length)); controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布"); commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布"); else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
} }
} }
@@ -394,7 +432,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
if (templateId === "DYN012") { if (templateId === "DYN012") {
const font = fontOption("FONT081"); const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") { if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。"); showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return; return;
} }
} }
@@ -407,8 +445,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "动态值已确认并加入画布"); commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined); setLocationDialog(undefined);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("动态贴纸未能加入画布"); else showNotice("动态贴纸未能加入画布");
} }
} }
@@ -456,7 +494,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.replaceElement(overrideDynamicStickerValue(element, value)); controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新"); commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch { } catch {
setNotice("动态贴纸显示文字不能为空"); showNotice("动态贴纸显示文字不能为空");
} }
} }
@@ -480,7 +518,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
if (!template.fontUrl || !canvasState) return; if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl); const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") { if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。"); showNotice("素材暂不可用,未使用系统字体替代。");
return; return;
} }
const controller = controllerForCurrent(); const controller = controllerForCurrent();
@@ -490,8 +528,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "文字模板已加入画布"); commitElementOperation(controller, "文字模板已加入画布");
void recordRecentTextTemplate(template.templateId, template.resourceVersion); void recordRecentTextTemplate(template.templateId, template.resourceVersion);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("文字模板未能加入画布"); else showNotice("文字模板未能加入画布");
} }
} }
@@ -503,18 +541,49 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(edit); action(edit);
return { ...current, draft: edit.value }; return { ...current, draft: edit.value };
} catch { } catch {
setNotice("文字参数不在允许范围内"); showNotice("文字参数不在允许范围内");
return current; return current;
} }
}); });
} }
function commitTextDraftAutomatically(editState: TextEditState) {
if (!canvasState || !project || saveStatus === "conflicted") return;
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
try {
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
const history = textHistoryRef.current;
if (!history || history.elementId !== editState.elementId) {
if (history) finalizeTextHistory();
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
} else {
history.last = next;
}
elementControllerRef.current?.replaceState(next);
setCanvasState(next);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (complete.template_or_asset_id !== editState.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
setTextEdit((current) => current?.elementId === editState.elementId ? {
...current,
draft: complete,
originalTemplateId: complete.template_or_asset_id,
} : current);
} catch (error) {
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
}
}
async function changeTextTemplate(templateId: string) { async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId); const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return; if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl); const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") { if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。"); showNotice("素材暂不可用,未使用系统字体替代。");
return; return;
} }
updateTextDraft((edit) => edit.switchTemplate(templateId)); updateTextDraft((edit) => edit.switchTemplate(templateId));
@@ -527,7 +596,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
const option = fontOption(fontId); const option = fontOption(fontId);
if (!option || await ensureFont(option.fontId, option.url) !== "ready") { if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
setNotice("字体素材暂不可用,未使用系统字体替代。"); showNotice("字体素材暂不可用,未使用系统字体替代。");
return; return;
} }
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId })); updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
@@ -535,6 +604,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
function completeTextEdit() { function completeTextEdit() {
if (!textEdit) return; if (!textEdit) return;
if (!pendingTextDraft()) {
finalizeTextHistory();
showNotice("文字修改已进入自动保存");
return;
}
try { try {
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES); const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
const complete = edit.complete(); const complete = edit.complete();
@@ -546,17 +620,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version); void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。"); if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
else setNotice("文字编辑未能完成"); else showNotice("文字编辑未能完成");
} }
} }
function cancelTextEdit() { function cancelTextEdit() {
const pendingHistory = textHistoryRef.current;
if (pendingHistory && project) {
textHistoryRef.current = undefined;
elementControllerRef.current?.replaceState(pendingHistory.base);
setCanvasState(pendingHistory.base);
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
}
if (canvasState && textEdit) { if (canvasState && textEdit) {
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId); const source = pendingHistory?.base ?? canvasState;
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id }); if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
} }
setNotice("已取消未提交的文字修改"); showNotice("已取消未提交的文字修改");
} }
function pendingTextDraft() { function pendingTextDraft() {
@@ -624,7 +706,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
return; return;
} }
const ran = await conflictExportGuardRef.current.run(() => executeExport(options)); const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次"); if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
} }
async function retryExportDownload() { async function retryExportDownload() {
@@ -639,8 +721,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(controller); action(controller);
commitElementOperation(controller, message); commitElementOperation(controller, message);
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("对象操作未完成"); else showNotice("对象操作未完成");
} }
} }
@@ -668,7 +750,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
opacityGestureRef.current = undefined; opacityGestureRef.current = undefined;
if (gesture.last === gesture.base) return; if (gesture.last === gesture.base) return;
commitCanvas(gesture.last); commitCanvas(gesture.last);
setNotice("贴纸透明度已提交"); showNotice("贴纸透明度已提交");
} }
function duplicateSelection() { function duplicateSelection() {
@@ -688,7 +770,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const controller = controllerForCurrent(); const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return; if (!controller || selectedIds.length === 0) return;
clipboardRef.current = controller.copySelected(); clipboardRef.current = controller.copySelected();
setNotice("已复制到画布剪贴板"); showNotice("已复制到画布剪贴板");
} }
function pasteSelection() { function pasteSelection() {
@@ -698,18 +780,20 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.pasteElements(clipboardRef.current, () => newElementIdentity()); controller.pasteElements(clipboardRef.current, () => newElementIdentity());
commitElementOperation(controller, "已粘贴画布对象"); commitElementOperation(controller, "已粘贴画布对象");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。"); if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
} }
} }
function selectAt(point: CanvasPoint, append: boolean) { function selectAt(point: CanvasPoint, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent(); const controller = controllerForCurrent();
if (!controller || !canvasState) return false; if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point); const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode }); const selection = controller.selectAt(point, { append: append || multiMode });
setSelectedIds(selection); setSelectedIds(selection);
setCandidateMenu(undefined); setCandidateMenu(undefined);
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection }; const dragBase = withTextDraft(canvasState, textEdit);
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
return candidates.length > 0; return candidates.length > 0;
} }
@@ -721,19 +805,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
const preview = previewController.moveSelected(delta); const preview = previewController.moveSelected(delta);
drag.last = preview.state; drag.last = preview.state;
setCanvasState(preview.state); setCanvasState(preview.state);
setTextEdit((current) => {
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
return movedDraft ? { ...current, draft: movedDraft } : current;
});
setGuides(preview.guides); setGuides(preview.guides);
} }
function commitMove() { function commitMove() {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag) return; if (!drag) return;
commitCanvas(drag.last); commitCanvas(drag.last, { preserveTextEdit: true });
setNotice("对象位置已提交"); showNotice("对象位置已提交");
setGuides([]); setGuides([]);
dragRef.current = undefined; dragRef.current = undefined;
} }
function marqueeSelect(rectangle: CanvasRect, append: boolean) { function marqueeSelect(rectangle: CanvasRect, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent(); const controller = controllerForCurrent();
if (!controller) return; if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode)); setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
@@ -782,6 +872,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
function clearSelection() { function clearSelection() {
finalizeTextHistory();
elementControllerRef.current?.clearSelection(); elementControllerRef.current?.clearSelection();
setSelectedIds([]); setSelectedIds([]);
setCandidateMenu(undefined); setCandidateMenu(undefined);
@@ -789,10 +880,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>; if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = textEdit ? { const renderedCanvasState = withTextDraft(canvasState, textEdit);
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
} : canvasState;
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`; const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted"; const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id)); const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
@@ -869,7 +957,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`, maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
}}> }}>
<EditorStage <EditorStage
assetId={canvasState.background.asset_id}
canvasState={renderedCanvasState} canvasState={renderedCanvasState}
fontStatuses={fontStatuses} fontStatuses={fontStatuses}
guides={guides} guides={guides}
@@ -877,6 +964,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
onClearSelection={clearSelection} onClearSelection={clearSelection}
onCopy={copySelection} onCopy={copySelection}
onDelete={deleteSelection} onDelete={deleteSelection}
onDragStart={() => setCandidateMenu(undefined)}
onMarquee={marqueeSelect} onMarquee={marqueeSelect}
onMoveCommit={commitMove} onMoveCommit={commitMove}
onMovePreview={previewMove} onMovePreview={previewMove}
+83 -23
View File
@@ -12,14 +12,18 @@ import { drawColorCard } from "./palette-provider.js";
interface Gesture { interface Gesture {
append: boolean; append: boolean;
bounds: DOMRect;
hit: boolean; hit: boolean;
longPressOpened: boolean; longPressOpened: boolean;
moved: boolean;
pointerId: number; pointerId: number;
start: CanvasPoint; start: CanvasPoint;
startClient: CanvasPoint;
} }
const DRAG_THRESHOLD_PX = 4;
interface EditorStageProps { interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState; canvasState: CanvasState;
guides: readonly string[]; guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
@@ -27,6 +31,7 @@ interface EditorStageProps {
onClearSelection: () => void; onClearSelection: () => void;
onCopy: () => void; onCopy: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart: () => void;
onMarquee: (rectangle: CanvasRect, append: boolean) => void; onMarquee: (rectangle: CanvasRect, append: boolean) => void;
onMoveCommit: () => void; onMoveCommit: () => void;
onMovePreview: (delta: CanvasPoint) => void; onMovePreview: (delta: CanvasPoint) => void;
@@ -38,11 +43,10 @@ interface EditorStageProps {
selectedIds: readonly string[]; selectedIds: readonly string[];
} }
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint { function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
const bounds = event.currentTarget.getBoundingClientRect();
return { return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)), x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)), y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
}; };
} }
@@ -240,6 +244,27 @@ function loadCanvasImage(url: string) {
}); });
} }
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
interface SceneResources {
background: HTMLImageElement | undefined;
resourceImages: Readonly<Record<string, HTMLImageElement>>;
}
function createCachedCanvasImageLoader(): CanvasImageLoader {
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
return (url) => {
const cached = cache.get(url);
if (cached) return cached;
const pending = loadCanvasImage(url).then((image) => {
if (!image) cache.delete(url);
return image;
});
cache.set(url, pending);
return pending;
};
}
function resourceUrlsForCanvas(canvasState: CanvasState) { function resourceUrlsForCanvas(canvasState: CanvasState) {
const imageReferences = new Map<string, string>(); const imageReferences = new Map<string, string>();
for (const element of canvasState.elements) { for (const element of canvasState.elements) {
@@ -252,11 +277,19 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
return imageReferences; return imageReferences;
} }
async function loadSceneResources(canvasState: CanvasState, projectId: string) { function sceneResourceKey(canvasState: CanvasState, projectId: string) {
const background = canvasState.background.asset_id const background = canvasState.background.asset_id
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`) ? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
: null;
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
return JSON.stringify({ background, projectId, resources });
}
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
const background = canvasState.background.asset_id
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
: Promise.resolve(undefined); : Promise.resolve(undefined);
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const)); const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
const [image, loaded] = await Promise.all([background, resources]); const [image, loaded] = await Promise.all([background, resources]);
return { return {
background: image, background: image,
@@ -314,16 +347,29 @@ export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined); const gestureRef = useRef<Gesture | undefined>(undefined);
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
const [marquee, setMarquee] = useState<CanvasRect>(); const [marquee, setMarquee] = useState<CanvasRect>();
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
useEffect(() => { useEffect(() => {
let active = true; let active = true;
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
if (active) setSceneResources({ key: resourceKey, resources });
});
return () => { active = false; };
}, [props.projectId, resourceKey]);
useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return undefined; if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width; if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height; if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
if (!context) return undefined; if (!context) return undefined;
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => { const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages); renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
context.lineWidth = 4; context.lineWidth = 4;
@@ -345,21 +391,22 @@ export function EditorStage(props: EditorStageProps) {
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height); if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore(); context.restore();
}; };
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => { render(sceneResources.resources.background, sceneResources.resources.resourceImages);
if (!active) return; return undefined;
render(background, resourceImages); }, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
});
return () => { active = false; };
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []); useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) { function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
if (event.button !== 0) return; if (event.button !== 0) return;
const start = pointFromEvent(event); const bounds = event.currentTarget.getBoundingClientRect();
const start = pointFromClient(event.clientX, event.clientY, bounds);
const append = event.shiftKey; const append = event.shiftKey;
const hit = props.onSelect(start, append); const hit = props.onSelect(start, append);
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start }; gestureRef.current = {
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
startClient: { x: event.clientX, y: event.clientY },
};
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
longPressRef.current = setTimeout(() => { longPressRef.current = setTimeout(() => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
@@ -375,9 +422,15 @@ export function EditorStage(props: EditorStageProps) {
props.onPointerMoved(); props.onPointerMoved();
return; return;
} }
const point = pointFromEvent(event); const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
if (!gesture.moved) {
if (clientDistance < DRAG_THRESHOLD_PX) return;
gesture.moved = true;
gesture.longPressOpened = false;
props.onDragStart();
}
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
if (longPressRef.current) clearTimeout(longPressRef.current); if (longPressRef.current) clearTimeout(longPressRef.current);
props.onPointerMoved(); props.onPointerMoved();
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta); if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
@@ -388,11 +441,18 @@ export function EditorStage(props: EditorStageProps) {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return; if (!gesture || gesture.pointerId !== event.pointerId) return;
if (longPressRef.current) clearTimeout(longPressRef.current); if (longPressRef.current) clearTimeout(longPressRef.current);
const point = pointFromEvent(event); const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
if (moved && !gesture.moved && !gesture.longPressOpened) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003; if (gesture.hit) props.onMovePreview(delta);
}
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit(); if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append); else if (!gesture.hit && moved) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
}
setMarquee(undefined); setMarquee(undefined);
gestureRef.current = undefined; gestureRef.current = undefined;
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId);
+152
View File
@@ -0,0 +1,152 @@
:root {
--dada-interaction-duration: 120ms;
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
cursor: pointer;
}
:where(button:not(:disabled), a[href]) {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease,
color var(--dada-interaction-duration) ease,
opacity var(--dada-interaction-duration) ease;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
transition:
border-color var(--dada-interaction-duration) ease,
box-shadow var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
:where(.product-page, .product-loading) :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 3px;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
}
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
@media (hover: hover) and (pointer: fine) {
:where(button:not(:disabled)):hover {
border-color: #111111;
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
}
:where(a[href]):hover {
color: #005fcc;
opacity: 0.78;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.product-header nav a:hover {
color: #111111;
background: #e9e9e5;
box-shadow: inset 0 -3px #111111;
opacity: 1;
}
.product-header nav a[aria-current="page"]:hover {
background: #f2f500;
}
.ratio-control label:hover span,
.reference-input:hover {
border-color: #111111;
background: #ffffd6;
box-shadow: inset 0 -3px #111111;
}
.project-card:hover {
border-color: #111111;
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
}
.editor-asset-tabs button:not(:disabled):hover,
.editor-source:hover,
.editor-sticker-grid button:not(:disabled):hover,
.editor-template-categories button:not(:disabled):hover,
.editor-template-grid button:not(:disabled):hover,
.editor-provider-grid button:not(:disabled):hover,
.editor-candidates button:not(:disabled):hover {
border-color: #111111;
background: #ffffd6;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
border-color: #111111;
}
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):hover {
transform: translateY(-1px);
}
.project-card:hover .project-preview img,
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
transform: scale(1.02);
}
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
.editor-provider-grid button:not(:disabled):hover > :first-child {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):active {
transform: translateY(1px);
transition-duration: 45ms;
}
}
:where(button:not(:disabled)):active {
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
}
:where(a[href]):active {
opacity: 0.58;
}
@media (prefers-reduced-motion: reduce) {
:where(button, a[href], input, select, textarea),
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
+2
View File
@@ -16,6 +16,8 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
import { EditorPage } from "./editor-page.js"; import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js"; import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
import "./interaction-feedback.css";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (!root) { if (!root) {
+25 -8
View File
@@ -592,15 +592,26 @@
height: 18px; height: 18px;
} }
.project-placeholder { .project-placeholder,
.project-preview {
display: grid; display: grid;
height: 154px; height: 154px;
grid-template-columns: repeat(4, 1fr);
overflow: hidden; overflow: hidden;
border-bottom: 1px solid #a5a59f; border-bottom: 1px solid #a5a59f;
background: #d8d8d3; background: #d8d8d3;
} }
.project-placeholder {
grid-template-columns: repeat(4, 1fr);
}
.project-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-placeholder span { .project-placeholder span {
display: grid; display: grid;
place-items: end center; place-items: end center;
@@ -935,9 +946,9 @@
font-size: 19px; font-size: 19px;
} }
.project-current > .project-placeholder { .project-current > .project-placeholder,
height: auto; .project-current > .project-preview {
min-height: 480px; height: 480px;
border: 1px solid #73736d; border: 1px solid #73736d;
} }
@@ -945,6 +956,10 @@
font-size: 80px; font-size: 80px;
} }
.project-current > .project-preview img {
object-fit: contain;
}
.project-actions { .project-actions {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1013,7 +1028,8 @@
padding: 6px; padding: 6px;
} }
.project-history li .project-placeholder { .project-history li .project-placeholder,
.project-history li .project-preview {
height: 88px; height: 88px;
border: 0; border: 0;
} }
@@ -1439,8 +1455,9 @@
flex-direction: column; flex-direction: column;
} }
.project-current > .project-placeholder { .project-current > .project-placeholder,
min-height: 360px; .project-current > .project-preview {
height: 360px;
} }
.local-only-footer { .local-only-footer {
+49 -3
View File
@@ -231,6 +231,33 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
); );
} }
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
alt: string;
imageId: string | null;
loading?: "eager" | "lazy";
projectId: string;
ratio: Ratio;
status: ProjectStatus;
}) {
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => setLoadFailed(false), [imageId, projectId]);
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
return (
<div className="project-preview" data-ratio={ratio} data-status={status}>
<img
alt={alt}
decoding="async"
loading={loading}
onError={() => setLoadFailed(true)}
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
/>
</div>
);
}
export function WorkspacePage() { export function WorkspacePage() {
const promptId = useId(); const promptId = useId();
const [session, setSession] = useState<SessionPayload>(); const [session, setSession] = useState<SessionPayload>();
@@ -568,7 +595,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
/> />
</label> </label>
) : null} ) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} /> <ProjectPreview
alt={`${project.name}预览图`}
imageId={project.current_image_id}
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-card-body"> <div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div> <div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p> <p>{project.successful_image_count} · {project.ratio}</p>
@@ -953,7 +986,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<div className="project-detail-grid"> <div className="project-detail-grid">
<section className="project-current" aria-labelledby="current-image-title"> <section className="project-current" aria-labelledby="current-image-title">
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header> <header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} /> <ProjectPreview
alt={`${project.name}当前底图`}
imageId={project.current_image_id}
loading="eager"
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-actions"> <div className="project-actions">
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button> <button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
{conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/app/projects/${project.project_id}/editor`}></a>} {conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/app/projects/${project.project_id}/editor`}></a>}
@@ -970,7 +1010,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<ol> <ol>
{project.images.toReversed().map((image, index) => ( {project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}> <li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" /> <ProjectPreview
alt={`生成结果 ${project.images.length - index}`}
imageId={image.image_id}
projectId={project.project_id}
ratio={project.ratio}
status="active"
/>
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}></a></div> <div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}></a></div>
</li> </li>
))} ))}
@@ -0,0 +1,35 @@
export interface GenerationPollingProcessor {
processNext(): Promise<unknown>;
}
export class GenerationPollingLoop {
private closed = false;
private inFlight = false;
private readonly timer: ReturnType<typeof setInterval>;
constructor(
private readonly processor: GenerationPollingProcessor,
intervalMilliseconds = 250,
) {
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
throw new Error("generation_polling_interval_invalid");
}
this.timer = setInterval(() => this.run(), intervalMilliseconds);
}
close() {
if (this.closed) return;
this.closed = true;
clearInterval(this.timer);
}
private run() {
if (this.closed || this.inFlight) return;
this.inFlight = true;
void this.processor.processNext()
.catch(() => undefined)
.finally(() => {
this.inFlight = false;
});
}
}
+5 -1
View File
@@ -16,6 +16,7 @@ const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits"; const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
const maximumResponseBytes = 32 * 1024 * 1024; const maximumResponseBytes = 32 * 1024 * 1024;
const requestTimeoutMilliseconds = 180_000; const requestTimeoutMilliseconds = 180_000;
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
type FetchLike = typeof fetch; type FetchLike = typeof fetch;
@@ -141,7 +142,10 @@ function buildRequest(request: GenerationAdapterRequest) {
return { return {
body: JSON.stringify({ body: JSON.stringify({
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } }, extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
messages: [{ content, role: "user" }], messages: [
{ content: geminiImageSystemInstruction, role: "system" },
{ content, role: "user" },
],
model: geminiProviderModelId, model: geminiProviderModelId,
stream: false, stream: false,
}), }),
+4 -3
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js"; import { WorkerAiCallGate } from "./ai-call-gate.js";
import { runAiRuntimeProbe } from "./ai-runtime-probe.js"; import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
import { GenerationPollingLoop } from "./generation-polling-loop.js";
import { GenerationProcessor } from "./generation-processor.js"; import { GenerationProcessor } from "./generation-processor.js";
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js"; import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js"; import { readConfiguredLocalDataRoot } from "./runtime-config.js";
@@ -52,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
let projectCleanup: ProjectPurgeCleanup | undefined; let projectCleanup: ProjectPurgeCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined; let retentionTimer: ReturnType<typeof setInterval> | undefined;
let processor: GenerationProcessor | undefined; let processor: GenerationProcessor | undefined;
let generationTimer: ReturnType<typeof setInterval> | undefined; let generationLoop: GenerationPollingLoop | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => { const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive); clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer); if (retentionTimer) clearInterval(retentionTimer);
retention?.close(); retention?.close();
projectCleanup?.close(); projectCleanup?.close();
if (generationTimer) clearInterval(generationTimer); generationLoop?.close();
processor?.close(); processor?.close();
storage?.close(); storage?.close();
}); });
@@ -102,7 +103,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
}); });
logger.write({ error_category: "none", status_category: "ready" }); logger.write({ error_category: "none", status_category: "ready" });
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger }); new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250); generationLoop = new GenerationPollingLoop(processor);
} catch { } catch {
storageStatus = "unavailable"; storageStatus = "unavailable";
control.reportStatus("storage_unavailable"); control.reportStatus("storage_unavailable");
File diff suppressed because it is too large Load Diff
+2
View File
@@ -21,6 +21,8 @@
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", "test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
"package:portable": "node scripts/build-portable.mjs", "package:portable": "node scripts/build-portable.mjs",
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
"generate:openapi": "node scripts/generate-openapi.mjs", "generate:openapi": "node scripts/generate-openapi.mjs",
"check:openapi": "node scripts/check-openapi.mjs", "check:openapi": "node scripts/check-openapi.mjs",
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs", "validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
+21
View File
@@ -23,6 +23,27 @@ export const P0A_DYNAMIC_STICKER_IDS = [
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
] as const; ] as const;
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
] as const;
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
] as const;
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template"; export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
export interface RegisteredComplexAsset extends Record<string, unknown> { export interface RegisteredComplexAsset extends Record<string, unknown> {
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
deployRuntimeAssetPlan,
readRuntimeAssetManifest,
serializeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
throw new Error("asset_root_configuration_invalid");
}
const assetRoot = resolve(assetRootCandidate);
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
const plan = await buildP0aRuntimeAssetPlan({
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
});
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
}
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
@@ -0,0 +1,23 @@
import { resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
serializeRuntimeAssetManifest,
writeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
writeRuntimeAssetManifest(outputPath, plan.manifest);
process.stdout.write(`${JSON.stringify({
counts: plan.manifest.counts,
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
status: "generated",
})}\n`);
+5 -1
View File
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { frozenRuntime } from "../frozen-versions.mjs"; import { frozenRuntime } from "../frozen-versions.mjs";
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
const repositoryRoot = resolve(import.meta.dirname, "..", ".."); const repositoryRoot = resolve(import.meta.dirname, "..", "..");
const fixedPort = 43121; const fixedPort = 43121;
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web")); copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate")); copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" }); writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" }); writeJson(
join(packageDirectory, "asset-metadata", "manifest.json"),
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
);
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies }); writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
const commit = run("git", ["rev-parse", "HEAD"]); const commit = run("git", ["rev-parse", "HEAD"]);
+317
View File
@@ -0,0 +1,317 @@
import { createHash } from "node:crypto";
import {
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
const fontMimeTypes = new Map([
[".otf", "font/otf"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function fileSha256(path) {
return sha256(readFileSync(path));
}
function stableEntries(entries) {
return entries.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
if (
typeof entry.relativePath !== "string"
|| isAbsolute(entry.relativePath)
|| entry.relativePath.includes("\\")
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
) throw new Error("runtime_asset_relative_path_invalid");
return { ...entry, sha256: entry.sha256.toLowerCase() };
}).sort((left, right) => {
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
return byVersion || left.assetId.localeCompare(right.assetId);
});
}
function derivedCounts(entries) {
return {
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
};
}
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
const normalizedEntries = stableEntries(entries);
const keys = new Set();
const paths = new Set();
for (const entry of normalizedEntries) {
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
keys.add(key);
paths.add(entry.relativePath);
}
const actualCounts = derivedCounts(normalizedEntries);
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
throw new Error("runtime_asset_source_manifest_sha256_invalid");
}
return {
counts: actualCounts,
entries: normalizedEntries,
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
source: "external_read_only",
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
};
}
export function readRuntimeAssetManifest(path) {
const value = JSON.parse(readFileSync(path, "utf8"));
if (
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|| value?.source !== "external_read_only"
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|| !Array.isArray(value.entries)
) throw new Error("runtime_asset_manifest_invalid");
return createRuntimeAssetManifest({
counts: value.counts,
entries: value.entries,
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
});
}
export function serializeRuntimeAssetManifest(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export function writeRuntimeAssetManifest(path, manifest) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
}
function targetWithinRoot(root, relativePath) {
const absoluteRoot = resolve(root);
const target = resolve(absoluteRoot, ...relativePath.split("/"));
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
return target;
}
function sameFile(left, right) {
const leftStat = statSync(left);
const rightStat = statSync(right);
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
const normalizedManifest = createRuntimeAssetManifest({
counts: manifest.counts,
entries: manifest.entries,
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
});
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
mkdirSync(assetRoot, { recursive: true });
for (const resource of resources) {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const entry = entries.get(key);
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
throw new Error("asset_resource_plan_mismatch");
}
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
throw new Error("asset_source_invalid");
}
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
mkdirSync(dirname(targetPath), { recursive: true });
if (existsSync(targetPath)) {
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
continue;
}
try {
linkSync(resource.sourcePath, targetPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("asset_hardlink_volume_mismatch");
}
throw error;
}
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
}
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
}
function oneDirectoryWithPrefix(root, prefix) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
return join(root, matches[0].name);
}
function oneSupportedFont(root) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
return join(root, matches[0].name);
}
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: fileSha256(sourcePath),
};
}
function dynamicMetadata(templateRoot, descriptor, field) {
const templateDirectory = join(templateRoot, descriptor.templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
}
return templateDirectory;
}
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
import("../../packages/asset-compiler/dist/index.js"),
import("../../packages/template-registry/dist/index.js"),
]);
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
try {
const staticSourceRoot = join(replicationRoot, "sticker_normal");
const staticResult = compileStaticStickerCatalog({
outputDirectory: compilerOutput,
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
sourceRoot: staticSourceRoot,
});
const resources = staticResult.catalog.items.map((item) => {
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
const entry = entryFor(
sourcePath,
item.stable_id,
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
"image/png",
);
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
return { entry, sourcePath };
});
const fontPackagesRoot = join(
replicationRoot,
"sticker_text",
"字体",
"面板全量采集",
"font_panel_full_20260722",
"resources",
"font_packages",
);
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
const extension = extname(sourcePath).toLowerCase();
resources.push({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
const extension = extname(sourcePath).toLowerCase();
resources.push({
entry: entryFor(
sourcePath,
descriptor.assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
}
resources.push({
entry: entryFor(
sourcePath,
descriptor.assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
"image/png",
),
sourcePath,
});
}
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
const manifest = createRuntimeAssetManifest({
counts: {
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
static_stickers: staticResult.catalog.count,
},
entries: resources.map((resource) => resource.entry),
sourceManifestSha256: fileSha256(manifestPath),
});
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
return {
manifest,
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
};
} finally {
rmSync(compilerOutput, { force: true, recursive: true });
}
}
export function defaultReplicationRoot(environment = process.env) {
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
}
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
}
});
function fixture() {
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
temporaryDirectories.push(base);
const assetRoot = join(base, "assets");
const dataRoot = join(base, "data");
const configFile = join(base, "instance.json");
const trustedManifestPath = join(base, "trusted-manifest.json");
const bytes = Buffer.from("synthetic sticker bytes");
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const manifest = {
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
entries: [entry],
root_ref: "p0a_runtime_assets",
schema_version: "DadaRuntimeAssets/v1",
source: "external_read_only",
};
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
writeFileSync(join(assetRoot, entry.relativePath), bytes);
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
return { assetRoot, configFile, dataRoot, trustedManifestPath };
}
describe("POSTV1-06 portable runtime assets", () => {
it("activates a validated external asset root without exposing its path", () => {
const input = fixture();
const loaded = loadConfiguredRuntimeAssets(input);
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
});
it("rejects a changed external manifest and leaves unrelated API features available", () => {
const input = fixture();
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
const loaded = loadConfiguredRuntimeAssets(input);
expect(loaded.publicAssets).toBeUndefined();
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
const assetRoot = join(base, "read-only-assets"); const assetRoot = join(base, "read-only-assets");
const relativePath = "images/source.png"; const relativePath = "images/source.png";
const bytes = Buffer.from("synthetic png fixture"); const bytes = Buffer.from("synthetic png fixture");
const assetId = randomUUID(); const assetId = "STK001";
mkdirSync(join(assetRoot, "images"), { recursive: true }); mkdirSync(join(assetRoot, "images"), { recursive: true });
writeFileSync(join(assetRoot, relativePath), bytes); writeFileSync(join(assetRoot, relativePath), bytes);
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] }); const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
+45 -2
View File
@@ -33,6 +33,10 @@ function routeSession(page: Page) {
})); }));
} }
function generatedImageSvg(label: string) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
}
async function captureEvidence(page: Page, caseId: string, name: string) { async function captureEvidence(page: Page, caseId: string, name: string) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS; const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return; if (!root) return;
@@ -75,6 +79,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
], ],
}), contentType: "application/json", status: 200, }), contentType: "application/json", status: 200,
})); }));
await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
body: generatedImageSvg("城市工作室"),
contentType: "image/svg+xml",
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
status: 200,
}));
let batchPayload: unknown; let batchPayload: unknown;
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => { await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
batchPayload = route.request().postDataJSON(); batchPayload = route.request().postDataJSON();
@@ -86,12 +96,30 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible(); await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
await expect(page.getByText("2 / 20 active")).toBeVisible(); await expect(page.getByText("2 / 20 active")).toBeVisible();
const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
await expect(projectPreview).toHaveAttribute(
"src",
`/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
);
await expect(projectPreview).toHaveCSS("object-fit", "cover");
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible(); await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0); await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
await page.getByLabel("选择失败草稿:失败草稿").check(); await page.getByLabel("选择失败草稿:失败草稿").check();
await page.getByRole("button", { name: "批量移入回收站" }).click(); const batchTrashButton = page.getByRole("button", { name: "批量移入回收站" });
await batchTrashButton.hover();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -1)");
const buttonBounds = await batchTrashButton.boundingBox();
if (!buttonBounds) throw new Error("Batch trash button geometry is unavailable.");
await page.mouse.move(buttonBounds.x + buttonBounds.width / 2, buttonBounds.y + buttonBounds.height / 2);
await page.mouse.down();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 1)");
await page.mouse.move(0, 0);
await page.mouse.up();
await batchTrashButton.click();
expect(batchPayload).toEqual({ project_ids: [failedId] }); expect(batchPayload).toEqual({ project_ids: [failedId] });
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible(); await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(page.getByRole("link", { name: "打开项目:城市工作室" })).toHaveCSS("transition-duration", "0s");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png"); await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
}); });
@@ -99,9 +127,10 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => { test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
await routeSession(page); await routeSession(page);
const projectId = "00000000-0000-4000-8000-000000000221"; const projectId = "00000000-0000-4000-8000-000000000221";
const currentImageId = "00000000-0000-4000-8000-000000000222";
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({ body: JSON.stringify({
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222", created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({ draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`, created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`, generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
@@ -111,11 +140,25 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z", status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
}), contentType: "application/json", status: 200, }), contentType: "application/json", status: 200,
})); }));
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
body: generatedImageSvg("生成结果"),
contentType: "image/svg+xml",
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
status: 200,
}));
await page.goto(`${webUrl}/app/projects/${projectId}`); await page.goto(`${webUrl}/app/projects/${projectId}`);
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible(); await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
await expect(page.getByText("固定比例 3:4")).toBeVisible(); await expect(page.getByText("固定比例 3:4")).toBeVisible();
await expect(page.getByText("10 / 10 张成功图")).toBeVisible(); await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
await expect(currentImage).toHaveAttribute(
"src",
`/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
);
await expect(currentImage).toHaveAttribute("loading", "eager");
await expect(currentImage).toHaveCSS("object-fit", "contain");
await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled(); await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible(); await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(0); await expect(page.getByRole("radio")).toHaveCount(0);
+88
View File
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { CanvasState } from "@dada/shared-contracts"; import type { CanvasState } from "@dada/shared-contracts";
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
let vite: ViteDevServer; let vite: ViteDevServer;
let webUrl: string; let webUrl: string;
@@ -196,3 +198,89 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true }); writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") }); if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
}); });
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
await page.addInitScript(() => {
const counters = { height: 0, width: 0 };
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
for (const key of ["height", "width"] as const) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
Object.defineProperty(HTMLCanvasElement.prototype, key, {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
get: descriptor.get,
set(value: number) {
if (this.classList.contains("editor-canvas")) counters[key] += 1;
descriptor.set!.call(this, value);
},
});
}
});
const projectId = uuid(530);
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
createdAt: "2026-08-03T08:00:00.000Z",
elementId: uuid(630),
}, 0, { position: { x: 0.5, y: 0.5 } });
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
await page.mouse.click(center.x, center.y);
await expect(page.getByLabel("文字内容")).toBeVisible();
await page.getByLabel("文字内容").fill("拖动中的文字");
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
await page.getByLabel("文字填充色").fill("#FA5751");
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
expect(backend.canvas.elements[0]).toMatchObject({
content: "拖动中的文字",
scale: { x: 64 / 48, y: 64 / 48 },
style_parameters: { fill_color: "#FA5751" },
});
const savesBeforeDrag = backend.saves;
const before = await page.evaluate(() => {
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
});
await page.mouse.move(center.x, center.y);
await page.mouse.down();
await page.waitForTimeout(650);
await expect(page.getByRole("menu")).toBeVisible();
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
await expect(page.getByRole("menu")).toBeHidden();
await expect(page.getByLabel("文字内容")).toBeVisible();
const preview = await stage.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
let totalX = 0;
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const offset = (y * canvas.width + x) * 4;
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
count += 1;
totalX += x;
}
}
}
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
});
const during = await page.evaluate(() => {
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
});
expect(during).toEqual(before);
expect(preview.blue_pixel_count).toBeGreaterThan(100);
expect(preview.blue_x).toBeGreaterThan(0.60);
await page.mouse.up();
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
await expect(page.getByLabel("文字内容")).toBeVisible();
});
+83 -1
View File
@@ -98,7 +98,11 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
await expect(page.locator(".editor-template-grid button")).toHaveCount(0); await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
await search.fill(""); await search.fill("");
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0); expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); const flowerTemplate = page.getByRole("button", { name: /FLOWER001 春日计划/ });
await flowerTemplate.hover();
await expect(flowerTemplate).toHaveCSS("background-color", "rgb(255, 255, 214)");
await expect(flowerTemplate.locator(".editor-template-mark")).toHaveCSS("transform", "matrix(1.03, 0, 0, 1.03, 0, 0)");
await flowerTemplate.click();
await expect(page.getByText("对象 1 / 50")).toBeVisible(); await expect(page.getByText("对象 1 / 50")).toBeVisible();
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]); await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
await page.reload(); await page.reload();
@@ -212,3 +216,81 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 }); writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") }); if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
}); });
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
const projectId = uuid(760);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const before = await stage.boundingBox();
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
const after = await stage.boundingBox();
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
clientHeight: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
scrollHeight: panel.scrollHeight,
}));
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
expect(assetsPanelScroll.overflowY).toBe("auto");
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
});
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
const projectId = uuid(770);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(1);
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
await page.reload();
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
const element = backend.canvas.elements[0];
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
const positionBefore = structuredClone(element.position);
const savesBefore = backend.saves;
const clientX = bounds.x + bounds.width * element.position.x;
const clientY = bounds.y + bounds.height * element.position.y;
await page.mouse.move(clientX, clientY);
await page.mouse.down();
await expect(page.getByLabel("字体覆盖")).toBeVisible();
const selectedBounds = await stage.boundingBox();
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
clientHeight: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
scrollHeight: panel.scrollHeight,
}));
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
expect(inspectorScroll.overflowY).toBe("auto");
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
await page.mouse.move(clientX + 1, clientY);
await page.mouse.up();
await expect(page.getByLabel("字体覆盖")).toBeVisible();
await page.waitForTimeout(800);
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
expect(backend.saves).toBe(savesBefore);
await page.mouse.move(clientX, clientY);
await page.mouse.down();
await page.mouse.move(clientX + 12, clientY);
await page.mouse.up();
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
});
+14 -11
View File
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" })); await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
} }
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => { test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
const projectId = "00000000-0000-4000-8000-000000000920"; const projectId = "00000000-0000-4000-8000-000000000920";
const assetId = "00000000-0000-4000-8000-000000000921"; const assetId = "00000000-0000-4000-8000-000000000921";
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 }; const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
@@ -87,27 +87,29 @@ test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and expor
await page.getByRole("button", { name: "文字模板", exact: true }).click(); await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
await page.getByLabel("文字内容").fill("尚未提交的导出文字"); await page.getByLabel("文字内容").fill("自动保存的导出文字");
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
const downloads: string[] = []; const downloads: string[] = [];
page.on("download", (download) => downloads.push(download.suggestedFilename())); page.on("download", (download) => downloads.push(download.suggestedFilename()));
await page.getByRole("button", { name: "导出", exact: true }).click(); await page.getByRole("button", { name: "导出", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出成品" }); const dialog = page.getByRole("dialog", { name: "导出成品" });
await expect(dialog).toContainText("导出前需要提交当前修改"); await expect(dialog).not.toContainText("导出前需要提交当前修改");
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible(); await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
await dialog.getByRole("button", { name: "取消" }).click(); await dialog.getByRole("button", { name: "取消" }).click();
await expect(dialog).toHaveCount(0); await expect(dialog).toHaveCount(0);
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字"); await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
expect(backend.saves).toBe(1); expect(backend.saves).toBe(2);
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
expect(backend.latestBodies).toHaveLength(0); expect(backend.latestBodies).toHaveLength(0);
expect(downloads).toHaveLength(0); expect(downloads).toHaveLength(0);
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version }; const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
await page.getByRole("button", { name: "撤销" }).click(); await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByLabel("文字内容")).toHaveCount(0); await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 }); evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false }); evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
}); });
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => { test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
const projectId = "00000000-0000-4000-8000-000000000930"; const projectId = "00000000-0000-4000-8000-000000000930";
const assetId = "00000000-0000-4000-8000-000000000931"; const assetId = "00000000-0000-4000-8000-000000000931";
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 }; const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
@@ -117,6 +119,7 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click(); await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1); await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
await page.getByLabel("文字内容").fill("确认后进入导出"); await page.getByLabel("文字内容").fill("确认后进入导出");
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
await page.getByRole("button", { name: "导出", exact: true }).click(); await page.getByRole("button", { name: "导出", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出成品" }); const dialog = page.getByRole("dialog", { name: "导出成品" });
if (process.env.DADA_EVIDENCE_DIR_EXPORT) { if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
@@ -124,14 +127,14 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
mkdirSync(dirname(screenshot), { recursive: true }); mkdirSync(dirname(screenshot), { recursive: true });
await page.screenshot({ fullPage: true, path: screenshot }); await page.screenshot({ fullPage: true, path: screenshot });
} }
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check(); await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
const downloadPromise = page.waitForEvent("download"); const downloadPromise = page.waitForEvent("download");
await dialog.getByRole("button", { name: "导出并下载" }).click(); await dialog.getByRole("button", { name: "导出并下载" }).click();
const download = await downloadPromise; const download = await downloadPromise;
const downloadPath = await download.path(); const downloadPath = await download.path();
if (!downloadPath) throw new Error("Browser download did not expose a local path."); if (!downloadPath) throw new Error("Browser download did not expose a local path.");
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品"); await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
await expect.poll(() => backend.saves).toBe(2); expect(backend.saves).toBe(2);
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出"); expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
expect(backend.latestBodies).toHaveLength(1); expect(backend.latestBodies).toHaveLength(1);
const downloaded = readFileSync(downloadPath); const downloaded = readFileSync(downloadPath);
@@ -22,6 +22,7 @@ import {
defaultLocalDataRoot, defaultLocalDataRoot,
initializeLocalDataRoot, initializeLocalDataRoot,
inspectInitializedLocalDataRoot, inspectInitializedLocalDataRoot,
readConfiguredAssetRoot,
resolvePathWithinRoot, resolvePathWithinRoot,
validateLocalDataRoot, validateLocalDataRoot,
validateReadOnlyAssetRoot, validateReadOnlyAssetRoot,
@@ -66,6 +67,17 @@ afterEach(() => {
}); });
describe("TDD-WP0-DATA-001-root-validation", () => { describe("TDD-WP0-DATA-001-root-validation", () => {
it("reads only an absolute configured read-only asset root", () => {
const base = temporaryDirectory();
const configFile = join(base, "instance.json");
const assetRoot = join(base, "runtime-assets");
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
});
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => { it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
const localAppData = join(temporaryDirectory(), "LocalAppData"); const localAppData = join(temporaryDirectory(), "LocalAppData");
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data")); expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
createRuntimeAssetManifest,
deployRuntimeAssetPlan,
readRuntimeAssetManifest,
serializeRuntimeAssetManifest,
} from "../../scripts/lib/runtime-assets.mjs";
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
assert.deepEqual(manifest.counts, {
dynamic_fonts: 7,
dynamic_images: 8,
font_panel_items: 11,
static_stickers: 1407,
});
assert.equal(manifest.entries.length, 1433);
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
});
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
t.after(() => rm(root, { force: true, recursive: true }));
const sourceRoot = join(root, "source");
const assetRoot = join(root, "assets");
const sourcePath = join(sourceRoot, "sticker.png");
const bytes = Buffer.from("runtime asset fixture");
await mkdir(sourceRoot);
await writeFile(sourcePath, bytes);
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
entries: [entry],
});
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
const targetPath = join(assetRoot, entry.relativePath);
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
assert.equal(sourceStat.ino, targetStat.ino);
assert.deepEqual(await readFile(targetPath), bytes);
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
assert.deepEqual(JSON.parse(writtenManifest), manifest);
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
});
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
t.after(() => rm(root, { force: true, recursive: true }));
const sourcePath = join(root, "source.png");
const assetRoot = join(root, "assets");
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
await writeFile(sourcePath, "expected");
await writeFile(targetPath, "unexpected");
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update("expected").digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
entries: [entry],
});
assert.throws(
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
/asset_target_conflict/,
);
});
+8
View File
@@ -112,6 +112,14 @@ async function verifyWorkerStartup(configPath) {
test("portable package serves the product and keeps SQLite data across API restart", async () => { test("portable package serves the product and keeps SQLite data across API restart", async () => {
assert.ok(existsSync(join(packageRoot, "Dada.exe"))); assert.ok(existsSync(join(packageRoot, "Dada.exe")));
assert.ok(existsSync(join(packageRoot, "web", "index.html"))); assert.ok(existsSync(join(packageRoot, "web", "index.html")));
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
assert.deepEqual(runtimeAssetManifest.counts, {
dynamic_fonts: 7,
dynamic_images: 8,
font_panel_items: 11,
static_stickers: 1407,
});
assert.equal(runtimeAssetManifest.entries.length, 1433);
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"); const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8"); const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
assert.match(packagedWorker, /GenerationProcessor/); assert.match(packagedWorker, /GenerationProcessor/);
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
describe("POSTV1-05 generation polling loop", () => {
afterEach(() => {
vi.useRealTimers();
});
it("does not start another processor call while the current call is unresolved", async () => {
vi.useFakeTimers();
let finishCurrentCall: (() => void) | undefined;
const processNext = vi.fn(() => new Promise<void>((resolve) => {
finishCurrentCall = resolve;
}));
const loop = new GenerationPollingLoop({ processNext }, 250);
await vi.advanceTimersByTimeAsync(1_000);
expect(processNext).toHaveBeenCalledTimes(1);
finishCurrentCall?.();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(250);
expect(processNext).toHaveBeenCalledTimes(2);
loop.close();
});
});
@@ -26,6 +26,14 @@ describe("POSTV1-02 OneAPI runtime adapter", () => {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token"); expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
expect(init?.redirect).toBe("error"); expect(init?.redirect).toBe("error");
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
expect(payload.messages).toEqual([
{
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
role: "system",
},
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
]);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
choices: [{ message: { content: `![result](data:image/png;base64,${source.toString("base64")})` } }], choices: [{ message: { content: `![result](data:image/png;base64,${source.toString("base64")})` } }],
}), { headers: { "content-type": "application/json" }, status: 200 }); }), { headers: { "content-type": "application/json" }, status: 200 });