From 22bdfdc4d596e18b62fb8d76330e59a744e2a2ca Mon Sep 17 00:00:00 2001 From: Zhanglj <15000123@qq.com> Date: Fri, 10 Jul 2026 18:20:06 +0800 Subject: [PATCH] Initial commit: Vue rewrite frontend --- .env.example | 2 + .gitignore | 5 + README.md | 41 + index.html | 12 + package.json | 31 + src/App.vue | 3 + src/api/account.js | 21 + src/api/assets.js | 33 + src/api/assistant.js | 9 + src/api/auth.js | 13 + src/api/characters.js | 25 + src/api/episodes.js | 37 + src/api/freezone.js | 37 + src/api/http.js | 37 + src/api/ingest.js | 13 + src/api/mock/data.js | 2391 +++++++++++++++++ src/api/projects.js | 29 + src/api/styles.js | 23 + src/api/tasks.js | 18 + src/api/watch.js | 11 + src/config/runtime.js | 1 + src/i18n/en-US.js | 36 + src/i18n/index.js | 15 + src/i18n/zh-CN.js | 36 + src/layouts/AppLayout.vue | 708 +++++ src/layouts/ProjectLayout.vue | 3 + src/main.js | 30 + src/queries/account.js | 53 + src/queries/assets.js | 89 + src/queries/assistant.js | 26 + src/queries/characters.js | 66 + src/queries/episodes.js | 115 + src/queries/freezone.js | 113 + src/queries/ingest.js | 48 + src/queries/projects.js | 54 + src/queries/styles.js | 57 + src/queries/tasks.js | 39 + src/queries/watch.js | 23 + src/router/index.js | 125 + src/stores/app.js | 44 + src/stores/auth.js | 54 + src/stores/index.js | 3 + src/styles/index.scss | 392 +++ src/views/LoginView.vue | 381 +++ src/views/WatchView.vue | 416 +++ src/views/account/AccountView.vue | 615 +++++ src/views/assets/AssetsView.vue | 700 +++++ src/views/assistant/AssistantView.vue | 484 ++++ src/views/characters/CharactersView.vue | 720 +++++ src/views/episodes/EpisodeStageView.vue | 1378 ++++++++++ src/views/episodes/EpisodesView.vue | 438 +++ src/views/freezone/FreezoneView.vue | 1245 +++++++++ .../components/FreezoneCanvasPanel.vue | 154 ++ .../freezone/components/FreezoneDialogs.vue | 491 ++++ .../freezone/components/FreezoneInspector.vue | 247 ++ .../freezone/components/FreezoneLeftPanel.vue | 193 ++ .../freezone/components/FreezoneToolbar.vue | 79 + src/views/freezone/freezone.config.js | 427 +++ src/views/freezone/freezone.scss | 1466 ++++++++++ src/views/ingest/IngestView.vue | 307 +++ src/views/projects/ProjectDashboardView.vue | 482 ++++ src/views/projects/ProjectOverviewView.vue | 670 +++++ src/views/styles/StylesView.vue | 720 +++++ src/views/tasks/TasksView.vue | 710 +++++ vite.config.js | 46 + 65 files changed, 17290 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 src/App.vue create mode 100644 src/api/account.js create mode 100644 src/api/assets.js create mode 100644 src/api/assistant.js create mode 100644 src/api/auth.js create mode 100644 src/api/characters.js create mode 100644 src/api/episodes.js create mode 100644 src/api/freezone.js create mode 100644 src/api/http.js create mode 100644 src/api/ingest.js create mode 100644 src/api/mock/data.js create mode 100644 src/api/projects.js create mode 100644 src/api/styles.js create mode 100644 src/api/tasks.js create mode 100644 src/api/watch.js create mode 100644 src/config/runtime.js create mode 100644 src/i18n/en-US.js create mode 100644 src/i18n/index.js create mode 100644 src/i18n/zh-CN.js create mode 100644 src/layouts/AppLayout.vue create mode 100644 src/layouts/ProjectLayout.vue create mode 100644 src/main.js create mode 100644 src/queries/account.js create mode 100644 src/queries/assets.js create mode 100644 src/queries/assistant.js create mode 100644 src/queries/characters.js create mode 100644 src/queries/episodes.js create mode 100644 src/queries/freezone.js create mode 100644 src/queries/ingest.js create mode 100644 src/queries/projects.js create mode 100644 src/queries/styles.js create mode 100644 src/queries/tasks.js create mode 100644 src/queries/watch.js create mode 100644 src/router/index.js create mode 100644 src/stores/app.js create mode 100644 src/stores/auth.js create mode 100644 src/stores/index.js create mode 100644 src/styles/index.scss create mode 100644 src/views/LoginView.vue create mode 100644 src/views/WatchView.vue create mode 100644 src/views/account/AccountView.vue create mode 100644 src/views/assets/AssetsView.vue create mode 100644 src/views/assistant/AssistantView.vue create mode 100644 src/views/characters/CharactersView.vue create mode 100644 src/views/episodes/EpisodeStageView.vue create mode 100644 src/views/episodes/EpisodesView.vue create mode 100644 src/views/freezone/FreezoneView.vue create mode 100644 src/views/freezone/components/FreezoneCanvasPanel.vue create mode 100644 src/views/freezone/components/FreezoneDialogs.vue create mode 100644 src/views/freezone/components/FreezoneInspector.vue create mode 100644 src/views/freezone/components/FreezoneLeftPanel.vue create mode 100644 src/views/freezone/components/FreezoneToolbar.vue create mode 100644 src/views/freezone/freezone.config.js create mode 100644 src/views/freezone/freezone.scss create mode 100644 src/views/ingest/IngestView.vue create mode 100644 src/views/projects/ProjectDashboardView.vue create mode 100644 src/views/projects/ProjectOverviewView.vue create mode 100644 src/views/styles/StylesView.vue create mode 100644 src/views/tasks/TasksView.vue create mode 100644 vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7b4ae0 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_USE_MOCK=true +VITE_API_URL=http://127.0.0.1:8780 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3aad93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +*.log +.env.local +.env.*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9d2f8d --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# SuperTale Vue Rewrite + +Vue 3 + JavaScript rewrite workspace for the existing SuperTale creator frontend. + +## Stack + +- Vite +- Vue 3 +- JavaScript +- Vue Router +- Pinia +- TanStack Vue Query +- Element Plus +- SCSS +- Vue I18n +- Vue Flow + +## Explicitly Not Migrated + +- `piko-mini-game` +- `rewards` +- `companion` + +## Commands + +```bash +pnpm --filter supertale-vue-rewrite dev +pnpm --filter supertale-vue-rewrite build +``` + +The dev server defaults to `http://localhost:5174` and proxies `/api/v1` and `/static` to `VITE_API_URL` or `http://127.0.0.1:8780`. + +## Mock Mode + +Mock API is enabled by default unless `VITE_USE_MOCK=false` is set. + +```bash +VITE_USE_MOCK=true +``` + +In mock mode, auth is auto-restored as `mock_user`, project and task APIs use local in-memory data, and the app can be navigated without a backend. diff --git a/index.html b/index.html new file mode 100644 index 0000000..e9eb536 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SuperTale Vue Rewrite + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e8dfcc --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "supertale-vue-rewrite", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/vue-query": "^5.97.0", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.47.0", + "element-plus": "^2.11.0", + "ky": "^2.0.0", + "lucide-vue-next": "^0.468.0", + "pinia": "^2.3.0", + "vue": "^3.5.0", + "vue-i18n": "^10.0.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "sass": "^1.83.0", + "vite": "^6.3.2", + "vitest": "^4.1.4" + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/src/api/account.js b/src/api/account.js new file mode 100644 index 0000000..dd64e2e --- /dev/null +++ b/src/api/account.js @@ -0,0 +1,21 @@ +import { getJson, postJson } from "./http"; + +export function getAccountSummary() { + return getJson("account/summary"); +} + +export function listCreditUsage(params = {}) { + return getJson("account/usage", { searchParams: params }); +} + +export function listAccountNotifications() { + return getJson("account/notifications"); +} + +export function topUpCredits(payload) { + return postJson("account/topup", payload); +} + +export function markNotificationRead(notificationId) { + return postJson(`account/notifications/${encodeURIComponent(notificationId)}/read`, {}); +} diff --git a/src/api/assets.js b/src/api/assets.js new file mode 100644 index 0000000..419bc99 --- /dev/null +++ b/src/api/assets.js @@ -0,0 +1,33 @@ +import { getJson, postJson } from "./http"; + +export function listSceneAssets(project) { + return getJson(`projects/${project}/assets/scenes`); +} + +export function createSceneAsset(project, payload) { + return postJson(`projects/${project}/assets/scenes`, payload); +} + +export function saveSceneAsset(project, sceneId, payload) { + return postJson(`projects/${project}/assets/scenes/${encodeURIComponent(sceneId)}/save`, payload); +} + +export function generateSceneAsset(project, sceneId) { + return postJson(`projects/${project}/assets/scenes/${encodeURIComponent(sceneId)}/generate`, {}); +} + +export function listPropAssets(project) { + return getJson(`projects/${project}/assets/props`); +} + +export function createPropAsset(project, payload) { + return postJson(`projects/${project}/assets/props`, payload); +} + +export function savePropAsset(project, propId, payload) { + return postJson(`projects/${project}/assets/props/${encodeURIComponent(propId)}/save`, payload); +} + +export function generatePropAsset(project, propId) { + return postJson(`projects/${project}/assets/props/${encodeURIComponent(propId)}/generate`, {}); +} diff --git a/src/api/assistant.js b/src/api/assistant.js new file mode 100644 index 0000000..b23d4d0 --- /dev/null +++ b/src/api/assistant.js @@ -0,0 +1,9 @@ +import { getJson, postJson } from "./http"; + +export function listAssistantMessages(project) { + return getJson(`projects/${project}/assistant/messages`); +} + +export function sendAssistantMessage(project, text) { + return postJson(`projects/${project}/assistant/messages`, { text }); +} diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..b2a8401 --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,13 @@ +import { getJson, postJson } from "./http"; + +export function login(payload) { + return postJson("auth/login", payload); +} + +export function logout() { + return postJson("auth/logout", {}); +} + +export function fetchMe() { + return getJson("auth/me"); +} diff --git a/src/api/characters.js b/src/api/characters.js new file mode 100644 index 0000000..0e2cf35 --- /dev/null +++ b/src/api/characters.js @@ -0,0 +1,25 @@ +import { getJson, postJson } from "./http"; + +export function listCharacters(project) { + return getJson(`projects/${project}/characters`); +} + +export function buildCharacters(project) { + return postJson(`projects/${project}/characters/build`, {}); +} + +export function listCharacterIdentities(project, character) { + return getJson(`projects/${project}/characters/${encodeURIComponent(character)}/identities`); +} + +export function saveCharacter(project, character, payload) { + return postJson(`projects/${project}/characters/${encodeURIComponent(character)}/save`, payload); +} + +export function generateCharacterAssets(project, character) { + return postJson(`projects/${project}/characters/${encodeURIComponent(character)}/generate`, {}); +} + +export function addCharacterIdentity(project, character, payload) { + return postJson(`projects/${project}/characters/${encodeURIComponent(character)}/identities`, payload); +} diff --git a/src/api/episodes.js b/src/api/episodes.js new file mode 100644 index 0000000..65bf65b --- /dev/null +++ b/src/api/episodes.js @@ -0,0 +1,37 @@ +import { deleteJson, getJson, postJson } from "./http"; + +export function listEpisodes(project) { + return getJson(`projects/${project}/episodes`); +} + +export function getEpisode(project, episode) { + return getJson(`projects/${project}/episodes/${episode}`); +} + +export function listEpisodeBeats(project, episode) { + return getJson(`projects/${project}/episodes/${episode}/beats`); +} + +export function saveEpisodeBeats(project, episode, beats) { + return postJson(`projects/${project}/episodes/${episode}/beats/save`, { beats }); +} + +export function saveEpisodeBeat(project, episode, beatNumber, beat) { + return postJson(`projects/${project}/episodes/${episode}/beats/${beatNumber}`, { beat }); +} + +export function deleteEpisodeBeat(project, episode, beatNumber) { + return deleteJson(`projects/${project}/episodes/${episode}/beats/${beatNumber}`); +} + +export function planEpisodes(project) { + return postJson(`projects/${project}/episodes/plan`, {}); +} + +export function runEpisodeStage(project, episode, stage, payload = {}) { + return postJson(`projects/${project}/episodes/${episode}/${stage}/generate`, payload); +} + +export function publishEpisodePreview(project, episode, payload = {}) { + return postJson(`projects/${project}/episodes/${episode}/publish`, payload); +} diff --git a/src/api/freezone.js b/src/api/freezone.js new file mode 100644 index 0000000..f5d0b99 --- /dev/null +++ b/src/api/freezone.js @@ -0,0 +1,37 @@ +import { getJson, postJson } from "./http"; + +export function listFreezoneAssets(project) { + return getJson(`projects/${project}/freezone/assets`); +} + +export function listFreezoneCanvases(project) { + return getJson(`projects/${project}/freezone/canvases`); +} + +export function getFreezoneCanvas(project, canvasId) { + return getJson(`projects/${project}/freezone/canvases/${encodeURIComponent(canvasId)}`); +} + +export function saveFreezoneCanvas(project, canvasId, payload) { + return postJson(`projects/${project}/freezone/canvases/${encodeURIComponent(canvasId)}/save`, payload); +} + +export function listFreezoneCanvasHistory(project, canvasId) { + return getJson(`projects/${project}/freezone/canvases/${encodeURIComponent(canvasId)}/history`); +} + +export function restoreFreezoneCanvasHistory(project, canvasId, historyId) { + return postJson(`projects/${project}/freezone/canvases/${encodeURIComponent(canvasId)}/restore`, { history_id: historyId }); +} + +export function createFreezoneCanvas(project, payload) { + return postJson(`projects/${project}/freezone/canvases`, payload); +} + +export function runFreezoneNode(project, payload) { + return postJson(`projects/${project}/freezone/generate`, payload); +} + +export function commitFreezoneNode(project, payload) { + return postJson(`projects/${project}/freezone/commit`, payload); +} diff --git a/src/api/http.js b/src/api/http.js new file mode 100644 index 0000000..8334ab5 --- /dev/null +++ b/src/api/http.js @@ -0,0 +1,37 @@ +import ky from "ky"; +import { useMockApi } from "@/config/runtime"; +import { mockDelete, mockGet, mockPost } from "@/api/mock/data"; + +export const api = ky.create({ + prefixUrl: "/api/v1", + credentials: "include", + timeout: 120_000, + hooks: { + afterResponse: [ + async (_request, _options, response) => { + if (response.status === 401) { + window.localStorage.removeItem("supertale-vue-username"); + window.localStorage.removeItem("supertale-vue-role"); + if (!window.location.pathname.startsWith("/login")) { + window.location.assign("/login"); + } + } + }, + ], + }, +}); + +export async function getJson(path, options) { + if (useMockApi) return mockGet(path, options); + return api.get(path, options).json(); +} + +export async function postJson(path, json, options) { + if (useMockApi) return mockPost(path, json, options); + return api.post(path, { json, ...options }).json(); +} + +export async function deleteJson(path, options) { + if (useMockApi) return mockDelete(path, options); + return api.delete(path, options).json(); +} diff --git a/src/api/ingest.js b/src/api/ingest.js new file mode 100644 index 0000000..26971dd --- /dev/null +++ b/src/api/ingest.js @@ -0,0 +1,13 @@ +import { getJson, postJson } from "./http"; + +export function uploadNovel(project, payload) { + return postJson(`projects/${project}/ingest/upload`, payload); +} + +export function getChapters(project) { + return getJson(`projects/${project}/chapters`); +} + +export function startIngest(project, payload) { + return postJson(`projects/${project}/ingest/start`, payload); +} diff --git a/src/api/mock/data.js b/src/api/mock/data.js new file mode 100644 index 0000000..3b3ce60 --- /dev/null +++ b/src/api/mock/data.js @@ -0,0 +1,2391 @@ +let projects = [ + { + id: "demo-xianxia", + name: "demo_xianxia", + displayName: "仙侠短剧 Demo", + status: "active", + episodeCount: 8, + updatedAt: "2026-07-08T15:20:00+08:00", + ownerUsername: "mock_user", + role: "owner", + }, + { + id: "demo-modern", + name: "demo_modern", + displayName: "都市悬疑 Demo", + status: "active", + episodeCount: 5, + updatedAt: "2026-07-07T11:12:00+08:00", + ownerUsername: "mock_user", + role: "owner", + }, +]; + +let tasks = [ + { + id: "task-ingest-1", + task_type: "ingest", + title: "小说章节识别", + project: "demo-xianxia", + episode: 0, + status: "completed", + progress: 1, + current_task: "章节识别完成", + created_at: "2026-07-09T09:20:00+08:00", + logs: ["读取小说文本", "识别章节结构", "生成剧集素材", "导入完成"], + }, + { + id: "task-video-1", + task_type: "video", + title: "第 1 集视频生成", + project: "demo-xianxia", + episode: 1, + status: "running", + progress: 0.42, + current_task: "生成第 1 集镜头视频", + created_at: "2026-07-09T10:08:00+08:00", + logs: ["读取分镜脚本", "准备角色参考", "生成镜头 1", "正在生成镜头 2"], + }, + { + id: "task-style-1", + task_type: "style", + title: "视觉风格分析", + project: "demo-xianxia", + episode: 0, + status: "failed", + progress: 0.68, + current_task: "参考图清晰度不足", + created_at: "2026-07-09T09:42:00+08:00", + error: "参考图过暗,请换一张主体更清晰的图片。", + logs: ["上传参考图", "分析色彩", "分析失败:主体不清晰"], + }, +]; + +const sampleChapters = [ + { + number: 1, + title: "夜雨入城", + content: "少年在雨夜抵达王都,发现旧宅门前悬着一盏不会熄灭的灯。", + word_count: 42, + char_count: 42, + }, + { + number: 2, + title: "镜中故人", + content: "铜镜映出失踪多年的师姐,她留下的第一句话却是不要相信我。", + word_count: 38, + char_count: 38, + }, + { + number: 3, + title: "赤焰试剑", + content: "宗门大比提前开启,主角被迫以残剑迎战新晋天才。", + word_count: 34, + char_count: 34, + }, +]; + +const ingestState = new Map([ + [ + "demo-xianxia", + { + filename: "demo_xianxia.txt", + size: 26800, + chapters: sampleChapters, + total_chars: 114, + imported: true, + }, + ], +]); + +const stylePresets = [ + { + id: "chinese_period_drama", + name: "国风古装剧", + category: "古风", + description: "适合仙侠、权谋、宫廷和江湖故事,画面强调华丽服饰、电影感光影与东方建筑。", + tags: ["电影感", "东方美学", "古装"], + tone: "冷暖对比", + palette: ["#1d3557", "#d4af37", "#b91c1c", "#f8fafc"], + previewGradient: "linear-gradient(135deg, #0f172a, #1d3557 45%, #b91c1c)", + recommended: true, + }, + { + id: "anime", + name: "高燃动漫", + category: "动画", + description: "适合热血成长、奇幻冒险和年轻受众,角色表情更夸张,动作镜头更有冲击力。", + tags: ["高饱和", "动作感", "年轻化"], + tone: "明快高对比", + palette: ["#2563eb", "#ec4899", "#facc15", "#0f172a"], + previewGradient: "linear-gradient(135deg, #2563eb, #ec4899 55%, #facc15)", + recommended: false, + }, + { + id: "realistic", + name: "写实电影", + category: "写实", + description: "适合都市、悬疑和情感题材,突出真实布光、自然肤色和克制镜头语言。", + tags: ["真实光影", "都市感", "沉浸"], + tone: "低饱和", + palette: ["#111827", "#64748b", "#e5e7eb", "#38bdf8"], + previewGradient: "linear-gradient(135deg, #111827, #334155 60%, #38bdf8)", + recommended: false, + }, + { + id: "guoman_fantasy", + name: "国漫玄幻", + category: "玄幻", + description: "适合修仙、异世大陆和强设定世界观,强调能量特效、宏大场景和角色轮廓。", + tags: ["玄幻特效", "大场景", "国漫"], + tone: "奇幻蓝紫", + palette: ["#312e81", "#7c3aed", "#22d3ee", "#f8fafc"], + previewGradient: "linear-gradient(135deg, #111827, #312e81 45%, #22d3ee)", + recommended: true, + }, +]; + +const selectedStyleByProject = new Map([ + ["demo-xianxia", "chinese_period_drama"], + ["demo-modern", "realistic"], +]); + +const customStylesByProject = new Map(); + +const sceneAssetsByProject = new Map([ + [ + "demo-xianxia", + [ + { + scene_id: "old_house", + name: "旧宅内堂", + description: "青灯、铜镜、木门和雨声构成第一集关键场景。", + environment_prompt: "雨夜旧宅,青灯微亮,木门半开,空气里有潮湿尘埃。", + asset_url: "", + director_world_url: "", + status: "draft", + usage_count: 2, + }, + { + scene_id: "royal_city", + name: "王都城门", + description: "黑云压城,灯火穿过雨幕,适合做开场远景和空间参考。", + environment_prompt: "古代王都城门,雨夜,灯火反射在湿润石板路上,压迫感强。", + asset_url: "", + director_world_url: "", + status: "ready", + usage_count: 1, + }, + ], + ], +]); + +const propAssetsByProject = new Map([ + [ + "demo-xianxia", + [ + { + prop_id: "bronze_mirror", + name: "铜镜", + description: "师姐留言的关键道具,适合用于特写镜头。", + visual_prompt: "古旧铜镜,镜面泛起水纹,边缘有细密符文。", + asset_url: "", + status: "ready", + usage_count: 2, + }, + { + prop_id: "broken_sword", + name: "残剑", + description: "主角随身携带的残剑,是后续力量觉醒的核心物件。", + visual_prompt: "断裂古剑,剑身有暗红裂纹,握柄磨损明显。", + asset_url: "", + status: "draft", + usage_count: 1, + }, + ], + ], +]); + +let publicWorks = [ + { + id: "neon-patrol", + title: "鲁班", + description: "机关少年误入未来战场,用一具失控木偶拆开城中最大的秘密。", + likes: 124, + duration: "01:24", + genre: "国风科幻", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/luban/luban-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/luban/luban-ep01.mp4", + gradient: "linear-gradient(135deg, #132674 0%, #e52c3b 48%, #1a0d23 100%)", + }, + { + id: "glass-signal", + title: "归灵司", + description: "全城反光物同时作祟,归灵司收灵人追查镜灵时,发现自己才是灵祸源头。", + likes: 33, + duration: "01:42", + genre: "悬疑志怪", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/guilingsi/guilingsi-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/guilingsi/guilingsi-ep01.mp4", + gradient: "linear-gradient(135deg, #dfe8ec 0%, #61a8b8 42%, #21313e 100%)", + }, + { + id: "silent-arcade", + title: "师兄你怎么不舔了", + description: "重生后的掌门拒绝旧命运,第一集就把所有人的剧本打乱。", + likes: 71, + duration: "01:18", + genre: "反套路修仙", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/shixiong-butianle/shixiong-butianle-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/shixiong-butianle/shixiong-butianle-ep01.mp4", + gradient: "linear-gradient(135deg, #1b1730 0%, #b7477e 44%, #1a8baa 100%)", + }, + { + id: "floating-market", + title: "天命不可欺", + description: "大婚当日内忧外患齐至,一本归乡之法把女主推向命运真相。", + likes: 95, + duration: "01:56", + genre: "古装权谋", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/tianmingbukeqi/tianmingbukeqi-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/tianmingbukeqi/tianmingbukeqi-ep02.mp4", + gradient: "linear-gradient(135deg, #233a2b 0%, #d69d55 48%, #1b1e28 100%)", + }, + { + id: "last-take", + title: "乌龙仙途", + description: "穿越十八年终于激活飞升系统,结果系统第一步就把人带进大麻烦。", + likes: 118, + duration: "01:31", + genre: "轻喜玄幻", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/wulongxiantu/wulongxiantu-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/wulongxiantu/wulongxiantu-ep01.mp4", + gradient: "linear-gradient(135deg, #2e1020 0%, #d65041 44%, #f4c16e 100%)", + }, +]; + +const chatMessagesByProject = new Map([ + [ + "demo-xianxia", + [ + { + id: "assistant-welcome", + role: "assistant", + text: "我已经读取了当前项目:仙侠短剧 Demo。你可以让我帮你检查剧集节奏、优化角色设定,或给某一集生成更适合视频化的分镜建议。", + created_at: "2026-07-09T10:00:00+08:00", + }, + ], + ], +]); + +const freezoneCanvasesByProject = new Map([ + [ + "demo-xianxia", + [ + { + id: "default", + display_name: "主线视图", + canvas_scope: "default", + modified_at: "2026-07-09T10:18:00+08:00", + size: 28_640, + revision: 12, + metadata: { preset: { scope: "default" } }, + }, + { + id: "user_mock_user_personal", + display_name: "mock_user 的自由画布", + canvas_scope: "blank", + modified_at: "2026-07-09T10:45:00+08:00", + size: 18_240, + revision: 4, + metadata: { canvas_origin: "personal", creator_username: "mock_user" }, + }, + { + id: "beat_1_1_render", + display_name: "第 1 集 / Beat 1 分镜", + canvas_scope: "beat", + episode: 1, + beat: 1, + modified_at: "2026-07-09T11:02:00+08:00", + size: 32_800, + revision: 7, + metadata: { + preset: { scope: "beat", episode: 1, beat: 1, primary_slot: "render" }, + default_push_target: { kind: "frame", episode: 1, beat: 1 }, + }, + }, + { + id: "episode_1_overview", + display_name: "第 1 集资产规划", + canvas_scope: "episode", + episode: 1, + modified_at: "2026-07-08T18:33:00+08:00", + size: 24_120, + revision: 3, + metadata: { preset: { scope: "episode", episode: 1 } }, + }, + ], + ], +]); + +const freezoneCanvasPayloads = new Map(); +const freezoneCanvasHistory = new Map(); + +function freezoneCanvasKey(projectId, canvasId) { + return `${projectId}:${canvasId}`; +} + +function ensureFreezoneCanvasPayload(projectId, canvasId) { + const key = freezoneCanvasKey(projectId, canvasId); + if (freezoneCanvasPayloads.has(key)) return freezoneCanvasPayloads.get(key); + const canvas = (freezoneCanvasesByProject.get(projectId) || []).find((item) => item.id === canvasId); + const payload = { + schema_version: 2, + canvas_id: canvasId, + project_id: projectId, + revision: canvas?.revision || 1, + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + metadata: canvas?.metadata || null, + updated_at: canvas?.modified_at || new Date().toISOString(), + }; + freezoneCanvasPayloads.set(key, payload); + freezoneCanvasHistory.set(key, [ + { + id: `${canvasId}-rev-${payload.revision}`, + revision: payload.revision, + save_source: "mock_seed", + modified_at: payload.updated_at, + size: canvas?.size || 0, + updated_by: mockUser.username, + snapshot: payload, + }, + ]); + return payload; +} + +function updateFreezoneCanvasSummary(projectId, canvasId, patch) { + const list = freezoneCanvasesByProject.get(projectId) || []; + freezoneCanvasesByProject.set( + projectId, + list.map((item) => (item.id === canvasId ? { ...item, ...patch } : item)), + ); +} + +function fallbackChatMessages(projectId) { + if (chatMessagesByProject.has(projectId)) return chatMessagesByProject.get(projectId); + const messages = [ + { + id: `assistant-${projectId}-welcome`, + role: "assistant", + text: "我会根据当前项目的小说、角色、风格和剧集进度给你建议。你可以直接问:下一步该做什么?", + created_at: new Date().toISOString(), + }, + ]; + chatMessagesByProject.set(projectId, messages); + return messages; +} + +function assistantReply(projectId, text) { + const lower = text.toLowerCase(); + const selectedStyleId = selectedStyleByProject.get(projectId); + const style = stylePresets.find((item) => item.id === selectedStyleId); + if (text.includes("下一步") || lower.includes("next")) { + return `建议你下一步先确认角色资产和视觉风格。当前风格是「${style?.name || "未选择"}」,如果角色形象还没有确认,后续分镜和视频生成会更容易返工。`; + } + if (text.includes("分镜") || text.includes("镜头")) { + return "这集可以先把每个镜头控制在 4-6 秒:先用远景建立环境,再用中景交代人物动作,最后用特写承接情绪或悬念。这样更适合短视频节奏。"; + } + if (text.includes("角色")) { + return "角色建议优先确认主角、关键对手和推动剧情的隐藏角色。普通配角可以先用默认形象,等主线镜头稳定后再细化。"; + } + return "收到。我建议先把这个需求拆成「故事目标、画面风格、角色状态、生成动作」四部分。你也可以指定某一集,我可以给出更具体的脚本或分镜优化建议。"; +} + +function freezoneAssets(projectId) { + const episodes = episodesByProject.get(projectId) || []; + const characters = fallbackCharacters(projectId); + const scenes = fallbackSceneAssets(projectId); + const props = fallbackPropAssets(projectId); + const selectedStyleId = selectedStyleByProject.get(projectId); + const style = stylePresets.find((item) => item.id === selectedStyleId); + const currentAssets = [ + ...episodes.slice(0, 4).flatMap((episode) => { + const beats = listEpisodeBeats(projectId, episode.number); + return beats.map((beat) => ({ + id: `beat-${episode.number}-${beat.beat_number}`, + tab: "beat", + kind: "beat_context", + role: "beat_context", + label: `EP${episode.number} / Beat ${beat.beat_number}`, + sublabel: beat.visual_description, + url: "", + media_type: "text", + aspect_ratio: "16:9", + pushable: false, + meta: { + episode: episode.number, + beat: beat.beat_number, + narration_segment: beat.narration_segment, + visual_description: beat.visual_description, + }, + })); + }), + ...episodes.slice(0, 3).flatMap((episode) => [ + { + id: `frame-${episode.number}-1`, + tab: "beat", + kind: "frame", + role: "current_frame", + label: `第 ${episode.number} 集 · 当前分镜`, + sublabel: episode.summary, + url: "", + media_type: "image", + aspect_ratio: "16:9", + pushable: true, + slot_target: { kind: "frame", episode: episode.number, beat: 1 }, + meta: { episode: episode.number, beat: 1 }, + }, + { + id: `video-${episode.number}-1`, + tab: "beat", + kind: "video", + role: "current_video", + label: `第 ${episode.number} 集 · 视频片段`, + sublabel: "可拖入画布继续做配音、剪辑或成片合成。", + url: "", + media_type: "video", + aspect_ratio: "16:9", + pushable: true, + slot_target: { kind: "video", episode: episode.number, beat: 1 }, + meta: { episode: episode.number, beat: 1 }, + }, + ]), + ...episodes.slice(0, 4).map((episode) => ({ + id: `episode-${episode.number}`, + tab: "beat", + kind: "episode", + role: "story", + label: `第 ${episode.number} 集 · ${episode.title}`, + sublabel: episode.summary, + media_type: "text", + meta: { episode: episode.number }, + })), + ...characters.map((character) => ({ + id: `character-${character.name}`, + tab: "characters", + kind: "character", + role: character.role || "角色", + label: character.display_name || character.name, + sublabel: character.description, + url: character.portrait_url || "", + aspect_ratio: "1:1", + pushable: true, + slot_target: { kind: "portrait", character: character.name }, + media_type: "image", + meta: { name: character.name, identities: character.identities?.length || 0 }, + })), + ...characters.flatMap((character) => + (character.identities || []).map((identity) => ({ + id: `identity-${identity.identity_id}`, + tab: "characters", + kind: "identity", + role: "identity_portrait", + label: identity.identity_name, + sublabel: identity.appearance_details, + url: identity.image_url || "", + media_type: "image", + aspect_ratio: "1:1", + pushable: true, + slot_target: { + kind: "identity_portrait", + character: character.name, + identity_id: identity.identity_id, + }, + meta: { character: character.name, identity_id: identity.identity_id }, + })), + ), + ...scenes.flatMap((scene) => [ + { + id: `scene-${scene.scene_id}`, + tab: "scenes", + kind: "scene", + role: "scene_master", + label: scene.name, + sublabel: scene.description, + url: scene.asset_url || "", + media_type: "image", + aspect_ratio: "16:9", + pushable: true, + slot_target: { kind: "scene_master", scene_id: scene.scene_id }, + meta: { scene_id: scene.scene_id, usage_count: scene.usage_count }, + }, + { + id: `scene-${scene.scene_id}-director`, + tab: "scenes", + kind: "director", + role: "scene_3gs_master_ply", + label: `${scene.name} 导演世界`, + sublabel: scene.environment_prompt, + url: scene.director_world_url || "", + media_type: "file", + aspect_ratio: "16:9", + pushable: true, + slot_target: { kind: "scene_director_world", scene_id: scene.scene_id }, + meta: { scene_id: scene.scene_id, usage_count: scene.usage_count }, + }, + ]), + ...props.map((prop) => ({ + id: `prop-${prop.prop_id}`, + tab: "props", + kind: "prop", + role: "prop_ref", + label: prop.name, + sublabel: prop.description, + url: prop.asset_url || "", + media_type: "image", + aspect_ratio: "1:1", + pushable: true, + slot_target: { kind: "prop_ref", prop_id: prop.prop_id }, + meta: { prop_id: prop.prop_id, usage_count: prop.usage_count }, + })), + { + id: `style-${style?.id || "default"}`, + tab: "beat", + kind: "style", + role: "visual_style", + label: style?.name || "默认风格", + sublabel: style?.description || "项目视觉风格", + url: "", + aspect_ratio: "16:9", + pushable: false, + media_type: "file", + meta: { palette: style?.palette || [] }, + }, + ]; + const crossProjectAssets = [ + { + id: "cross-demo-modern-rain-street", + tab: "scenes", + kind: "director", + role: "scene_director_world", + label: "都市雨夜街区导演世界", + sublabel: "来自都市悬疑 Demo,可作为雨夜街景、湿地反光和霓虹光线参考。", + url: "mock://cross-project/demo-modern/scene/rain-street/director-world", + media_type: "file", + aspect_ratio: "16:9", + pushable: false, + scope: "cross_project", + source_project: "都市悬疑 Demo", + meta: { scene_id: "rain_street", usage_count: 4 }, + }, + { + id: "cross-demo-modern-detective", + tab: "characters", + kind: "character", + role: "character_reference", + label: "冷面侦探角色参考", + sublabel: "来自都市悬疑 Demo,可桥接为当前项目的配角气质或服装参考。", + url: "mock://cross-project/demo-modern/character/detective", + media_type: "image", + aspect_ratio: "1:1", + pushable: false, + scope: "cross_project", + source_project: "都市悬疑 Demo", + meta: { name: "detective", identities: 2 }, + }, + { + id: "cross-shared-bronze-texture", + tab: "props", + kind: "prop", + role: "prop_ref", + label: "青铜纹理共享素材", + sublabel: "共享资产库素材,适合铜镜、古剑、机关道具的材质参考。", + url: "mock://cross-project/shared/prop/bronze-texture", + media_type: "image", + aspect_ratio: "1:1", + pushable: false, + scope: "cross_project", + source_project: "共享资产库", + meta: { prop_id: "bronze_texture", usage_count: 12 }, + }, + { + id: "cross-shared-pano-palace", + tab: "scenes", + kind: "director", + role: "scene_360_pano", + label: "宫城内庭 360 全景", + sublabel: "共享全景素材,可拖入全景捕获或导演世界节点作为空间参考。", + url: "mock://cross-project/shared/pano/palace-court", + media_type: "file", + aspect_ratio: "2:1", + pushable: false, + scope: "cross_project", + source_project: "共享资产库", + meta: { scene_id: "palace_court", usage_count: 7 }, + }, + ]; + return [ + ...currentAssets.map((asset) => ({ + scope: "current", + source_project: projectId, + ...asset, + })), + ...crossProjectAssets, + ]; +} + +const episodesByProject = new Map([ + [ + "demo-xianxia", + [ + { + number: 1, + title: "夜雨入城", + summary: "少年抵达王都,旧宅里的灯引出失踪师姐的线索。", + identity_ids: ["hero", "senior_sister"], + scene_menu: ["royal_city", "old_house"], + prop_menu: ["bronze_mirror"], + }, + { + number: 2, + title: "镜中故人", + summary: "铜镜中的留言让主角意识到宗门内部另有隐情。", + identity_ids: ["hero", "senior_sister", "elder"], + scene_menu: ["old_house"], + prop_menu: ["bronze_mirror", "sealed_letter"], + }, + { + number: 3, + title: "赤焰试剑", + summary: "宗门大比提前开启,残剑第一次显露真正力量。", + identity_ids: ["hero", "rival"], + scene_menu: ["arena"], + prop_menu: ["broken_sword"], + }, + ], + ], +]); + +const beatsByEpisode = new Map([ + [ + "demo-xianxia:1", + [ + { + beat_number: 1, + narration_segment: "雨夜,少年站在王都城门下,怀中旧信被雨水浸湿。", + visual_description: "远景,黑云压城,城门灯火在雨中拉出长长光影。", + speaker: "旁白", + status: "ready", + }, + { + beat_number: 2, + narration_segment: "他推开旧宅大门,看见一盏不会熄灭的青灯。", + visual_description: "室内低机位,青灯照亮灰尘,门缝外仍有雨声。", + speaker: "旁白", + status: "draft", + }, + { + beat_number: 3, + narration_segment: "铜镜忽然泛起水纹,师姐的声音从镜中传来。", + visual_description: "特写,铜镜表面像湖面一样震动,映出模糊人影。", + speaker: "师姐", + status: "draft", + }, + ], + ], +]); + +const charactersByProject = new Map([ + [ + "demo-xianxia", + [ + { + name: "hero", + display_name: "沈夜", + role: "男主角", + gender: "male", + is_main: true, + description: "雨夜入城的少年,背负师门旧案,性格隐忍但行动果断。", + portrait_url: "", + reference_audio_url: "", + identities: [ + { + identity_id: "hero-young", + identity_name: "少年沈夜", + age_group: "young", + appearance_details: "黑衣、束发、眉眼冷静,常佩一柄残剑。", + image_url: "", + costume_image_url: "", + }, + ], + }, + { + name: "senior_sister", + display_name: "柳听澜", + role: "关键角色", + gender: "female", + is_main: true, + description: "失踪多年的师姐,通过铜镜留下线索,真实立场成谜。", + portrait_url: "", + reference_audio_url: "/static/mock/voice/senior_sister.wav", + identities: [ + { + identity_id: "sister-memory", + identity_name: "镜中柳听澜", + age_group: "adult", + appearance_details: "白衣、银簪、神色疲惫,像被困在镜面之后。", + image_url: "", + costume_image_url: "", + }, + ], + }, + { + name: "rival", + display_name: "陆承锋", + role: "对手", + gender: "male", + is_main: false, + description: "宗门新晋天才,自负锋利,是赤焰试剑中的主要对手。", + portrait_url: "", + reference_audio_url: "", + identities: [ + { + identity_id: "rival-arena", + identity_name: "擂台陆承锋", + age_group: "young", + appearance_details: "赤色劲装,手持长剑,笑容张扬。", + image_url: "", + costume_image_url: "", + }, + ], + }, + ], + ], +]); + +function fallbackCharacters(projectId) { + if (charactersByProject.has(projectId)) return charactersByProject.get(projectId); + const generated = [ + { + name: "hero", + display_name: "主角", + role: "主角", + gender: "unknown", + is_main: true, + description: "AI 从小说中识别出的核心角色。", + portrait_url: "", + reference_audio_url: "", + identities: [ + { + identity_id: "hero-main", + identity_name: "主线形象", + age_group: "adult", + appearance_details: "等待根据项目风格生成具体形象。", + image_url: "", + costume_image_url: "", + }, + ], + }, + ]; + charactersByProject.set(projectId, generated); + return generated; +} + +function updateCharacter(projectId, characterName, updater) { + const list = fallbackCharacters(projectId); + const next = list.map((character) => + character.name === characterName ? updater(character) : character, + ); + charactersByProject.set(projectId, next); + return next.find((character) => character.name === characterName) || null; +} + +function fallbackSceneAssets(projectId) { + if (sceneAssetsByProject.has(projectId)) return sceneAssetsByProject.get(projectId); + const episodes = episodesByProject.get(projectId) || []; + const ids = [...new Set(episodes.flatMap((episode) => episode.scene_menu || []))]; + const scenes = (ids.length ? ids : ["mock_scene"]).map((sceneId) => ({ + scene_id: sceneId, + name: sceneId === "mock_scene" ? "默认场景" : sceneId, + description: "AI 根据剧集场景菜单生成的场景资产草稿。", + environment_prompt: "补充场景空间、时间、天气、光线和关键物件。", + asset_url: "", + director_world_url: "", + status: "draft", + usage_count: episodes.filter((episode) => episode.scene_menu?.includes(sceneId)).length, + })); + sceneAssetsByProject.set(projectId, scenes); + return scenes; +} + +function fallbackPropAssets(projectId) { + if (propAssetsByProject.has(projectId)) return propAssetsByProject.get(projectId); + const episodes = episodesByProject.get(projectId) || []; + const ids = [...new Set(episodes.flatMap((episode) => episode.prop_menu || []))]; + const props = (ids.length ? ids : ["mock_prop"]).map((propId) => ({ + prop_id: propId, + name: propId === "mock_prop" ? "默认道具" : propId, + description: "AI 根据剧集道具菜单生成的道具资产草稿。", + visual_prompt: "补充道具材质、形状、磨损程度和特写重点。", + asset_url: "", + status: "draft", + usage_count: episodes.filter((episode) => episode.prop_menu?.includes(propId)).length, + })); + propAssetsByProject.set(projectId, props); + return props; +} + +function updateSceneAsset(projectId, sceneId, updater) { + const list = fallbackSceneAssets(projectId); + const next = list.map((scene) => (scene.scene_id === sceneId ? updater(scene) : scene)); + sceneAssetsByProject.set(projectId, next); + return next.find((scene) => scene.scene_id === sceneId) || null; +} + +function updatePropAsset(projectId, propId, updater) { + const list = fallbackPropAssets(projectId); + const next = list.map((prop) => (prop.prop_id === propId ? updater(prop) : prop)); + propAssetsByProject.set(projectId, next); + return next.find((prop) => prop.prop_id === propId) || null; +} + +function episodeKey(projectId, episodeNum) { + return `${projectId}:${episodeNum}`; +} + +function listEpisodeBeats(projectId, episodeNum) { + const key = episodeKey(projectId, episodeNum); + if (beatsByEpisode.has(key)) return beatsByEpisode.get(key); + const fallback = [ + { + beat_number: 1, + narration_segment: "AI 已为这一集准备好第一段剧情节奏。", + visual_description: "主角进入关键场景,镜头从环境推向人物表情。", + speaker: "旁白", + status: "draft", + }, + { + beat_number: 2, + narration_segment: "冲突出现,角色做出第一个重要选择。", + visual_description: "中景,角色站在光影交界处,气氛紧张。", + speaker: "主角", + status: "draft", + }, + ]; + beatsByEpisode.set(key, fallback); + return fallback; +} + +function normalizeEpisodeBeats(beats = []) { + return beats.map((beat, index) => ({ + beat_number: index + 1, + narration_segment: beat.narration_segment || "", + visual_description: beat.visual_description || "", + speaker: beat.speaker || "旁白", + status: beat.status || "draft", + estimated_duration: Number(beat.estimated_duration || 5), + sketch_url: beat.sketch_url || "", + sketch_status: beat.sketch_status || "", + sketch_generated_at: beat.sketch_generated_at || "", + audio_url: beat.audio_url || "", + audio_status: beat.audio_status || "", + audio_generated_at: beat.audio_generated_at || "", + video_url: beat.video_url || "", + video_status: beat.video_status || "", + video_generated_at: beat.video_generated_at || "", + compose_status: beat.compose_status || "", + compose_generated_at: beat.compose_generated_at || "", + versions: beat.versions || { + sketches: [], + audio: [], + video: [], + compose: [], + }, + active_versions: beat.active_versions || {}, + })); +} + +function createEpisodeVersion(stage, beat, url, settings = {}, generatedAt = new Date().toISOString()) { + const versionLabel = { + sketches: "草图", + audio: "配音", + video: "视频", + compose: "合成", + }[stage] || "结果"; + return { + id: `${stage}-${beat.beat_number}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + stage, + label: `${versionLabel} V${(beat.versions?.[stage]?.length || 0) + 1}`, + url, + status: stage === "video" || stage === "compose" ? "running" : "completed", + generated_at: generatedAt, + model: settings.videoModel || settings.sketchModel || settings.voice || "Mock Pipeline", + settings: { + aspectRatio: settings.aspectRatio || "16:9", + resolution: settings.resolution || "1280x720", + voice: settings.voice || "", + motionStrength: settings.motionStrength || 50, + cameraMotion: settings.cameraMotion || "推镜", + }, + note: `${versionLabel}由 Mock 生成,可在工作台中设为当前版本。`, + }; +} + +function freezoneCommitUrl(projectId, payload) { + const kind = payload.target?.kind || "asset"; + const node = payload.nodeId || "node"; + return payload.previewUrl || `mock://freezone/${projectId}/${kind}/${node}/${Date.now().toString(36)}`; +} + +function stageForCommitKind(kind) { + return { + frame: "sketches", + video: "video", + beat_audio: "audio", + compose: "compose", + }[kind] || ""; +} + +function createFreezoneVersion(stage, beat, url, payload, generatedAt) { + const version = createEpisodeVersion( + stage, + beat, + url, + { + sketchModel: payload.kind || "Freezone", + videoModel: payload.kind || "Freezone", + voice: payload.kind || "Freezone", + }, + generatedAt, + ); + return { + ...version, + status: "completed", + label: `Freezone ${version.label}`, + note: payload.note || "由 Freezone 画布提交回主线资产。", + }; +} + +function commitFreezoneToEpisodeBeat(projectId, payload, url, generatedAt) { + const target = payload.target || {}; + const stage = stageForCommitKind(target.kind); + const episodeNum = Number(target.episode || 1); + const beatNumber = Number(target.beat || 1); + if (!stage || !episodeNum || !beatNumber) return null; + + const current = normalizeEpisodeBeats(listEpisodeBeats(projectId, episodeNum)); + const hasBeat = current.some((beat) => beat.beat_number === beatNumber); + const baseBeats = hasBeat + ? current + : normalizeEpisodeBeats([ + ...current, + { + beat_number: beatNumber, + narration_segment: payload.note || "Freezone 提交的新镜头产物。", + visual_description: payload.label || "来自 Freezone 的创作结果。", + speaker: "旁白", + status: "ready", + }, + ]); + + const nextBeats = baseBeats.map((beat) => { + if (beat.beat_number !== beatNumber) return beat; + const version = createFreezoneVersion(stage, beat, url, payload, generatedAt); + const versions = { + ...(beat.versions || {}), + [stage]: [version, ...(beat.versions?.[stage] || [])], + }; + const active_versions = { + ...(beat.active_versions || {}), + [stage]: version.id, + }; + const patch = { + status: "ready", + versions, + active_versions, + }; + if (stage === "sketches") { + patch.sketch_url = url; + patch.sketch_status = "completed"; + patch.sketch_generated_at = generatedAt; + } + if (stage === "audio") { + patch.audio_url = url; + patch.audio_status = "completed"; + patch.audio_generated_at = generatedAt; + } + if (stage === "video") { + patch.video_url = url; + patch.video_status = "completed"; + patch.video_generated_at = generatedAt; + } + if (stage === "compose") { + patch.compose_status = "completed"; + patch.compose_generated_at = generatedAt; + } + return { + ...beat, + ...patch, + }; + }); + + beatsByEpisode.set(episodeKey(projectId, episodeNum), nextBeats); + updateEpisodeFromBeats(projectId, episodeNum, nextBeats); + return `episodes/${episodeNum}/beats/${beatNumber}/${stage}`; +} + +function commitFreezoneToCharacter(projectId, payload, url) { + const target = payload.target || {}; + const characterName = target.character; + if (!characterName) return null; + const updated = updateCharacter(projectId, characterName, (character) => { + if (target.kind === "portrait") { + return { + ...character, + portrait_url: url, + }; + } + if (target.kind === "identity_portrait") { + const identityId = target.identity_id || character.identities?.[0]?.identity_id; + const identities = character.identities?.length + ? character.identities.map((identity) => + identity.identity_id === identityId ? { ...identity, image_url: url } : identity, + ) + : [ + { + identity_id: `${characterName}-${Date.now().toString(36)}`, + identity_name: "Freezone 形象", + age_group: "adult", + appearance_details: payload.note || "由 Freezone 提交的身份形象。", + image_url: url, + costume_image_url: "", + }, + ]; + return { + ...character, + identities, + }; + } + return character; + }); + return updated ? `characters/${characterName}/${target.kind}` : null; +} + +function commitFreezoneToScene(projectId, payload, url) { + const target = payload.target || {}; + const sceneId = target.scene_id; + if (!sceneId) return null; + let updated = updateSceneAsset(projectId, sceneId, (scene) => ({ + ...scene, + asset_url: target.kind === "scene_master" ? url : scene.asset_url, + director_world_url: target.kind === "scene_director_world" ? url : scene.director_world_url, + status: "ready", + })); + if (!updated) { + updated = { + scene_id: sceneId, + name: payload.label || sceneId, + description: payload.note || "由 Freezone 提交的场景资产。", + environment_prompt: payload.note || "", + asset_url: target.kind === "scene_master" ? url : "", + director_world_url: target.kind === "scene_director_world" ? url : "", + status: "ready", + usage_count: 0, + }; + sceneAssetsByProject.set(projectId, [updated, ...fallbackSceneAssets(projectId)]); + } + return `assets/scenes/${sceneId}/${target.kind}`; +} + +function commitFreezoneToProp(projectId, payload, url) { + const target = payload.target || {}; + const propId = target.prop_id; + if (!propId) return null; + let updated = updatePropAsset(projectId, propId, (prop) => ({ + ...prop, + asset_url: url, + status: "ready", + })); + if (!updated) { + updated = { + prop_id: propId, + name: payload.label || propId, + description: payload.note || "由 Freezone 提交的道具资产。", + visual_prompt: payload.note || "", + asset_url: url, + status: "ready", + usage_count: 0, + }; + propAssetsByProject.set(projectId, [updated, ...fallbackPropAssets(projectId)]); + } + return `assets/props/${propId}`; +} + +function applyFreezoneCommit(projectId, payload) { + const target = payload.target || {}; + const url = freezoneCommitUrl(projectId, payload); + const generatedAt = new Date().toISOString(); + const targetPath = + commitFreezoneToEpisodeBeat(projectId, payload, url, generatedAt) || + commitFreezoneToCharacter(projectId, payload, url) || + (["scene_master", "scene_director_world"].includes(target.kind) + ? commitFreezoneToScene(projectId, payload, url) + : null) || + (target.kind === "prop_ref" ? commitFreezoneToProp(projectId, payload, url) : null) || + `${projectId}/${target.kind || "asset"}/${payload.nodeId || "node"}`; + + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, updatedAt: generatedAt } + : item, + ); + + return { + targetPath, + targetUrl: url, + generatedAt, + }; +} + +function updateEpisodeFromBeats(projectId, episodeNum, beats) { + const list = episodesByProject.get(projectId) || []; + episodesByProject.set( + projectId, + list.map((episode) => + episode.number === episodeNum + ? { + ...episode, + summary: beats.map((beat) => beat.narration_segment).filter(Boolean).join(" "), + } + : episode, + ), + ); +} + +const mockUser = { + username: "mock_user", + role: "admin", +}; + +let accountSummary = { + username: "mock_user", + display_name: "Mock 创作者", + role: "admin", + plan: "Creator Pro", + plan_status: "active", + avatar_url: "", + credit_balance: 1280, + monthly_included: 3000, + monthly_used: 860, + renewal_at: "2026-08-01T00:00:00+08:00", + forecast_days: 22, + usage_today: 96, + usage_month: 860, +}; + +let creditUsageRecords = [ + { + id: "usage-video-1", + project: "demo-xianxia", + project_name: "仙侠短剧 Demo", + module: "视频生成", + action: "第 1 集镜头视频生成", + amount: -120, + status: "completed", + created_at: "2026-07-09T10:08:00+08:00", + detail: "视频阶段按镜头数量预估扣点", + }, + { + id: "usage-style-1", + project: "demo-xianxia", + project_name: "仙侠短剧 Demo", + module: "风格分析", + action: "AI 分析参考图", + amount: -36, + status: "failed", + created_at: "2026-07-09T09:42:00+08:00", + detail: "失败任务已保留部分分析成本", + }, + { + id: "usage-ingest-1", + project: "demo-xianxia", + project_name: "仙侠短剧 Demo", + module: "小说导入", + action: "章节识别与剧集拆分", + amount: -18, + status: "completed", + created_at: "2026-07-09T09:20:00+08:00", + detail: "按文本长度计算", + }, + { + id: "usage-bonus-1", + project: "", + project_name: "系统", + module: "账户", + action: "新工作台迁移体验额度", + amount: 500, + status: "completed", + created_at: "2026-07-08T14:00:00+08:00", + detail: "Mock 环境初始化赠送", + }, + { + id: "usage-scene-1", + project: "demo-modern", + project_name: "都市悬疑 Demo", + module: "场景资产", + action: "城市雨夜场景生成", + amount: -48, + status: "completed", + created_at: "2026-07-07T16:32:00+08:00", + detail: "场景主图和导演世界引用", + }, +]; + +let accountNotifications = [ + { + id: "notice-credit-low", + title: "点数消耗提醒", + content: "当前项目有视频任务正在运行,预计会继续消耗点数。你可以在任务中心查看进度。", + type: "credit", + read: false, + created_at: "2026-07-09T10:12:00+08:00", + }, + { + id: "notice-version-canvas", + title: "画布工作台已迁移", + content: "新版 Freezone 已支持多画布、历史恢复、批量生成和节点提交。", + type: "update", + read: false, + created_at: "2026-07-09T09:30:00+08:00", + }, + { + id: "notice-mock-mode", + title: "Mock 模式说明", + content: "当前流程使用模拟接口,方便完整验证登录、任务、发布和账户链路。", + type: "system", + read: true, + created_at: "2026-07-08T18:00:00+08:00", + }, +]; + +function wait(ms = 180) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function ok(data, message = "ok") { + return { ok: true, data, message }; +} + +export async function mockGet(path, options = {}) { + await wait(); + + if (path === "auth/me") return ok(mockUser); + if (path === "account/summary") { + const unread_count = accountNotifications.filter((item) => !item.read).length; + return ok({ ...accountSummary, unread_count }); + } + if (path === "account/usage") { + const project = options.searchParams?.project; + const status = options.searchParams?.status; + const records = creditUsageRecords.filter((record) => { + const projectMatch = project && project !== "all" ? record.project === project : true; + const statusMatch = status && status !== "all" ? record.status === status : true; + return projectMatch && statusMatch; + }); + return ok(records); + } + if (path === "account/notifications") return ok(accountNotifications); + if (path === "watch/works") return ok(publicWorks); + if (path.startsWith("watch/works/")) { + const workId = decodeURIComponent(path.split("/")[2]); + return ok(publicWorks.find((item) => item.id === workId) || publicWorks[0]); + } + if (path === "projects") return ok(projects); + if (path === "styles") { + const project = options.searchParams?.project; + const selectedId = selectedStyleByProject.get(project) || "chinese_period_drama"; + const projectStyles = customStylesByProject.get(project) || []; + return ok( + [...projectStyles, ...stylePresets].map((style) => ({ + ...style, + selected: style.id === selectedId, + })), + ); + } + if (path.startsWith("styles/")) { + const styleId = path.split("/")[1]; + const project = options.searchParams?.project; + const projectStyles = customStylesByProject.get(project) || []; + return ok([...projectStyles, ...stylePresets].find((style) => style.id === styleId) || null); + } + if (path.endsWith("/assistant/messages")) { + const projectId = path.split("/")[1]; + return ok(fallbackChatMessages(projectId)); + } + if (path.endsWith("/freezone/canvases")) { + const projectId = path.split("/")[1]; + return ok(freezoneCanvasesByProject.get(projectId) || [ + { + id: "default", + display_name: "主线视图", + canvas_scope: "default", + modified_at: new Date().toISOString(), + size: 0, + revision: 1, + metadata: { preset: { scope: "default" } }, + }, + ]); + } + if (path.includes("/freezone/canvases/") && path.endsWith("/history")) { + const parts = path.split("/"); + const projectId = parts[1]; + const canvasId = decodeURIComponent(parts[4]); + ensureFreezoneCanvasPayload(projectId, canvasId); + return ok(freezoneCanvasHistory.get(freezoneCanvasKey(projectId, canvasId)) || []); + } + if (path.includes("/freezone/canvases/")) { + const parts = path.split("/"); + const projectId = parts[1]; + const canvasId = decodeURIComponent(parts[4]); + return ok(ensureFreezoneCanvasPayload(projectId, canvasId)); + } + if (path.endsWith("/freezone/assets")) { + const projectId = path.split("/")[1]; + return ok(freezoneAssets(projectId)); + } + if (path.endsWith("/assets/scenes")) { + const projectId = path.split("/")[1]; + return ok(fallbackSceneAssets(projectId)); + } + if (path.endsWith("/assets/props")) { + const projectId = path.split("/")[1]; + return ok(fallbackPropAssets(projectId)); + } + if (path.endsWith("/chapters")) { + const projectId = path.split("/")[1]; + const state = ingestState.get(projectId); + return ok({ + chapters: state?.chapters || [], + total_chars: state?.total_chars || 0, + count: state?.chapters?.length || 0, + }); + } + if (path.endsWith("/episodes")) { + const projectId = path.split("/")[1]; + return ok(episodesByProject.get(projectId) || []); + } + if (path.endsWith("/characters")) { + const projectId = path.split("/")[1]; + return ok(fallbackCharacters(projectId)); + } + if (path.includes("/characters/") && path.endsWith("/identities")) { + const parts = path.split("/"); + const projectId = parts[1]; + const characterName = decodeURIComponent(parts[3]); + const character = fallbackCharacters(projectId).find((item) => item.name === characterName); + return ok(character?.identities || []); + } + if (path.includes("/episodes/")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const suffix = parts[4]; + const episode = (episodesByProject.get(projectId) || []).find((item) => item.number === episodeNum); + if (suffix === "beats") return ok(listEpisodeBeats(projectId, episodeNum)); + return ok(episode || null); + } + if (path.startsWith("projects/")) { + const projectId = path.split("/")[1]; + const project = projects.find((item) => item.id === projectId || item.name === projectId); + return ok(project || null); + } + if (path === "tasks") { + const project = options.searchParams?.project; + return ok(project ? tasks.filter((item) => item.project === project) : tasks); + } + + return ok(null); +} + +export async function mockPost(path, json = {}) { + await wait(); + + if (path === "auth/login") return ok(mockUser); + if (path === "auth/logout") return ok(null); + if (path === "account/topup") { + const amount = Number(json.amount || 0); + if (amount <= 0) return ok(accountSummary, "充值金额无效"); + accountSummary = { + ...accountSummary, + credit_balance: accountSummary.credit_balance + amount, + }; + creditUsageRecords = [ + { + id: `usage-topup-${Date.now()}`, + project: "", + project_name: "账户", + module: "点数充值", + action: json.packageName || "Mock 点数包", + amount, + status: "completed", + created_at: new Date().toISOString(), + detail: "Mock 充值即时到账", + }, + ...creditUsageRecords, + ]; + accountNotifications = [ + { + id: `notice-topup-${Date.now()}`, + title: "点数已到账", + content: `本次 Mock 充值 ${amount} 点,已加入账户余额。`, + type: "credit", + read: false, + created_at: new Date().toISOString(), + }, + ...accountNotifications, + ]; + return ok(accountSummary, "Mock 点数已到账"); + } + if (path.startsWith("account/notifications/") && path.endsWith("/read")) { + const notificationId = decodeURIComponent(path.split("/")[2]); + accountNotifications = accountNotifications.map((item) => + item.id === notificationId ? { ...item, read: true } : item, + ); + return ok(null); + } + if (path.startsWith("tasks/") && path.endsWith("/retry")) { + const taskId = decodeURIComponent(path.split("/")[1]); + const source = tasks.find((task) => task.id === taskId); + if (!source) return ok(null); + const retried = { + ...source, + id: `task-retry-${Date.now()}`, + status: source.task_type === "video" || source.task_type === "compose" ? "running" : "completed", + progress: source.task_type === "video" || source.task_type === "compose" ? 0.24 : 1, + current_task: + source.task_type === "video" || source.task_type === "compose" + ? "Mock 重试任务已进入生成队列" + : "Mock 重试任务已完成", + created_at: new Date().toISOString(), + logs: [...(source.logs || []), "用户发起重试", "重新读取上下文", "Mock 重试任务已创建"], + error: "", + }; + tasks = [retried, ...tasks]; + return ok(retried, "Mock 任务已重试"); + } + if (path === "tasks/clear-completed") { + const projectId = json.project; + tasks = tasks.filter((task) => { + const sameProject = projectId ? task.project === projectId : true; + return !(sameProject && ["completed", "cancelled"].includes(task.status)); + }); + return ok({ cleared: true }, "Mock 已清理完成任务"); + } + if (path.endsWith("/episodes/plan")) { + const projectId = path.split("/")[1]; + const state = ingestState.get(projectId); + const chapters = state?.chapters?.length ? state.chapters : sampleChapters; + const episodes = chapters.map((chapter) => ({ + number: chapter.number, + title: chapter.title, + summary: chapter.content, + identity_ids: ["hero", chapter.number === 1 ? "senior_sister" : "supporting_role"], + scene_menu: chapter.number === 1 ? ["royal_city", "old_house"] : ["mock_scene"], + prop_menu: chapter.number === 1 ? ["bronze_mirror"] : ["mock_prop"], + })); + episodesByProject.set(projectId, episodes); + episodes.forEach((episode) => { + beatsByEpisode.set( + episodeKey(projectId, episode.number), + [ + { + beat_number: 1, + narration_segment: episode.summary, + visual_description: "开场镜头先建立环境,再推进到主角状态,方便后续生成关键画面。", + speaker: "旁白", + status: "ready", + }, + { + beat_number: 2, + narration_segment: "冲突被明确提出,角色开始做出本集的关键选择。", + visual_description: "中景,角色站在强对比光影里,背景保留故事线索。", + speaker: "主角", + status: "draft", + }, + ], + ); + }); + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, episodeCount: episodes.length, updatedAt: new Date().toISOString() } + : item, + ); + const task = { + id: `task-plan-${Date.now()}`, + task_type: "episodes", + title: "AI 剧集规划", + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 剧集规划已完成", + created_at: new Date().toISOString(), + logs: ["读取章节", "拆分单集剧情", "规划角色场景道具", "生成分镜草稿"], + }; + tasks = [task, ...tasks]; + return ok({ episodes, task }, "Mock 剧集规划已完成"); + } + if (path.endsWith("/beats/save")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const beats = normalizeEpisodeBeats(json.beats || []); + beatsByEpisode.set(episodeKey(projectId, episodeNum), beats); + updateEpisodeFromBeats(projectId, episodeNum, beats); + return ok(beats, "Mock 分镜已保存"); + } + if (path.includes("/episodes/") && path.includes("/beats/")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const beatNumber = Number(parts[5]); + const current = listEpisodeBeats(projectId, episodeNum); + const incoming = { + ...json.beat, + beat_number: beatNumber, + }; + const next = current.some((beat) => beat.beat_number === beatNumber) + ? current.map((beat) => (beat.beat_number === beatNumber ? { ...beat, ...incoming } : beat)) + : [...current, incoming]; + const beats = normalizeEpisodeBeats(next); + beatsByEpisode.set(episodeKey(projectId, episodeNum), beats); + updateEpisodeFromBeats(projectId, episodeNum, beats); + return ok(beats.find((beat) => beat.beat_number === beatNumber) || beats.at(-1), "Mock 镜头已保存"); + } + if (path.endsWith("/freezone/canvases")) { + const projectId = path.split("/")[1]; + const list = freezoneCanvasesByProject.get(projectId) || []; + const id = json.id || `canvas_${Date.now().toString(36)}`; + const canvas = { + id, + display_name: json.name || "新的自由画布", + canvas_scope: json.scope || "blank", + modified_at: new Date().toISOString(), + size: 0, + revision: 1, + metadata: { + canvas_origin: "user_created", + display_name: json.name || "新的自由画布", + creator_username: mockUser.username, + }, + }; + freezoneCanvasesByProject.set(projectId, [canvas, ...list]); + ensureFreezoneCanvasPayload(projectId, id); + return ok(canvas); + } + if (path.includes("/freezone/canvases/") && path.endsWith("/restore")) { + const parts = path.split("/"); + const projectId = parts[1]; + const canvasId = decodeURIComponent(parts[4]); + const key = freezoneCanvasKey(projectId, canvasId); + const previous = ensureFreezoneCanvasPayload(projectId, canvasId); + const history = freezoneCanvasHistory.get(key) || []; + const entry = history.find((item) => item.id === json.history_id); + if (!entry?.snapshot) return ok({ restored: false, error: "history not found" }); + const nextRevision = (previous.revision || 0) + 1; + const updatedAt = new Date().toISOString(); + const restored = { + ...entry.snapshot, + revision: nextRevision, + updated_at: updatedAt, + save_source: "restore", + }; + freezoneCanvasPayloads.set(key, restored); + updateFreezoneCanvasSummary(projectId, canvasId, { + revision: nextRevision, + modified_at: updatedAt, + size: JSON.stringify(restored).length, + }); + freezoneCanvasHistory.set(key, [ + { + id: `${canvasId}-rev-${nextRevision}`, + revision: nextRevision, + save_source: "restore", + modified_at: updatedAt, + size: JSON.stringify(restored).length, + updated_by: mockUser.username, + snapshot: restored, + }, + ...history, + ].slice(0, 8)); + return ok({ + restored: true, + revision: nextRevision, + updated_at: updatedAt, + }); + } + if (path.includes("/freezone/canvases/") && path.endsWith("/save")) { + const parts = path.split("/"); + const projectId = parts[1]; + const canvasId = decodeURIComponent(parts[4]); + const previous = ensureFreezoneCanvasPayload(projectId, canvasId); + const nextRevision = (previous.revision || 0) + 1; + const updatedAt = new Date().toISOString(); + const payload = { + schema_version: 2, + canvas_id: canvasId, + project_id: projectId, + revision: nextRevision, + nodes: json.nodes || [], + edges: json.edges || [], + viewport: json.viewport || { x: 0, y: 0, zoom: 1 }, + metadata: json.metadata || previous.metadata || null, + updated_at: updatedAt, + save_source: json.save_source || "manual_save", + }; + freezoneCanvasPayloads.set(freezoneCanvasKey(projectId, canvasId), payload); + updateFreezoneCanvasSummary(projectId, canvasId, { + revision: nextRevision, + modified_at: updatedAt, + size: JSON.stringify(payload).length, + }); + const history = freezoneCanvasHistory.get(freezoneCanvasKey(projectId, canvasId)) || []; + freezoneCanvasHistory.set(freezoneCanvasKey(projectId, canvasId), [ + { + id: `${canvasId}-rev-${nextRevision}`, + revision: nextRevision, + save_source: payload.save_source, + modified_at: updatedAt, + size: JSON.stringify(payload).length, + updated_by: mockUser.username, + snapshot: payload, + }, + ...history, + ].slice(0, 8)); + return ok({ + saved: true, + revision: nextRevision, + updated_at: updatedAt, + backup_status: json.save_source === "autosave" ? "pending" : "synced", + }); + } + if (path.endsWith("/freezone/generate")) { + const projectId = path.split("/")[1]; + const kindLabelMap = { + imageGenNode: "图像生成", + imageNode: "图像编辑", + videoStoryNode: "视频故事板", + videoNode: "视频生成", + audioNode: "配音生成", + storyboardGenNode: "故事板生成", + threeDWorldNode: "导演世界生成", + panoCaptureNode: "全景捕获", + skillNode: "技能节点", + assetBridgeNode: "资产桥接", + videoComposeNode: "成片合成", + }; + const title = kindLabelMap[json.kind] || json.label || "Freezone 节点生成"; + const task = { + id: `task-freezone-${Date.now()}`, + task_type: "freezone", + title, + project: projectId, + episode: json.target?.episode || 0, + status: json.kind === "videoNode" || json.kind === "videoComposeNode" ? "running" : "completed", + progress: json.kind === "videoNode" || json.kind === "videoComposeNode" ? 0.36 : 1, + current_task: `Mock ${title}已${json.kind === "videoNode" ? "进入队列" : "完成"}`, + created_at: new Date().toISOString(), + logs: ["读取画布上下文", "整理上游节点输入", "匹配模型参数", "写入 Freezone 结果"], + }; + tasks = [task, ...tasks]; + return ok({ + task, + result: { + preview_url: "", + label: `${title}结果`, + status: task.status, + }, + }); + } + if (path.endsWith("/freezone/commit")) { + const projectId = path.split("/")[1]; + const commitResult = applyFreezoneCommit(projectId, json); + const task = { + id: `task-commit-${Date.now()}`, + task_type: "commit", + title: "Freezone 结果提交", + project: projectId, + episode: json.target?.episode || 0, + status: "completed", + progress: 1, + current_task: `已提交到 ${json.target?.kind || "项目资产"}`, + created_at: new Date().toISOString(), + logs: ["校验节点结果", "匹配提交目标", "更新项目资产索引", "完成"], + }; + tasks = [task, ...tasks]; + return ok({ + target_path: commitResult.targetPath, + target_url: commitResult.targetUrl, + generated_at: commitResult.generatedAt, + task, + }); + } + if (path.includes("/episodes/") && path.endsWith("/generate")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const stage = parts[4]; + const storedBeats = listEpisodeBeats(projectId, episodeNum); + const sourceBeats = json.beats?.length ? normalizeEpisodeBeats(json.beats) : storedBeats; + const generatedAt = new Date().toISOString(); + const settings = json.settings || {}; + const generatedBeats = sourceBeats.map((beat) => { + const suffix = `${projectId}-ep${episodeNum}-b${beat.beat_number}`; + if (stage === "sketches") { + const url = `mock://sketch/${suffix}/${Date.now().toString(36)}`; + const version = createEpisodeVersion(stage, beat, url, settings, generatedAt); + return { + ...beat, + status: "ready", + sketch_url: url, + sketch_status: "completed", + sketch_generated_at: generatedAt, + versions: { + ...(beat.versions || {}), + sketches: [version, ...(beat.versions?.sketches || [])], + }, + active_versions: { + ...(beat.active_versions || {}), + sketches: version.id, + }, + }; + } + if (stage === "audio") { + const url = `mock://audio/${suffix}/${Date.now().toString(36)}`; + const version = createEpisodeVersion(stage, beat, url, settings, generatedAt); + return { + ...beat, + audio_url: url, + audio_status: "completed", + audio_generated_at: generatedAt, + versions: { + ...(beat.versions || {}), + audio: [version, ...(beat.versions?.audio || [])], + }, + active_versions: { + ...(beat.active_versions || {}), + audio: version.id, + }, + }; + } + if (stage === "video") { + const url = `mock://video/${suffix}/${Date.now().toString(36)}`; + const version = createEpisodeVersion(stage, beat, url, settings, generatedAt); + return { + ...beat, + video_url: url, + video_status: "running", + video_generated_at: generatedAt, + versions: { + ...(beat.versions || {}), + video: [version, ...(beat.versions?.video || [])], + }, + active_versions: { + ...(beat.active_versions || {}), + video: version.id, + }, + }; + } + if (stage === "compose") { + const url = `mock://compose/${suffix}/${Date.now().toString(36)}`; + const version = createEpisodeVersion(stage, beat, url, settings, generatedAt); + return { + ...beat, + compose_status: "queued", + compose_generated_at: generatedAt, + versions: { + ...(beat.versions || {}), + compose: [version, ...(beat.versions?.compose || [])], + }, + active_versions: { + ...(beat.active_versions || {}), + compose: version.id, + }, + }; + } + return beat; + }); + if (["sketches", "audio", "video", "compose"].includes(stage)) { + const nextBeats = json.beats?.length + ? storedBeats.map((beat) => generatedBeats.find((item) => item.beat_number === beat.beat_number) || beat) + : generatedBeats; + beatsByEpisode.set(episodeKey(projectId, episodeNum), nextBeats); + updateEpisodeFromBeats(projectId, episodeNum, nextBeats); + } + const stageNameMap = { + overview: "剧集概览", + script: "脚本润色", + beats: "分镜生成", + sketches: "草图生成", + audio: "配音生成", + video: "视频生成", + compose: "成片合成", + }; + const title = stageNameMap[stage] || "阶段生成"; + const task = { + id: `task-${stage}-${Date.now()}`, + task_type: stage, + title: `第 ${episodeNum} 集${title}`, + project: projectId, + episode: episodeNum, + status: stage === "compose" || stage === "video" ? "running" : "completed", + progress: stage === "compose" || stage === "video" ? 0.38 : 1, + current_task: + stage === "compose" || stage === "video" + ? `正在处理第 ${episodeNum} 集${title}` + : `Mock 第 ${episodeNum} 集${title}已完成`, + created_at: new Date().toISOString(), + logs: [ + "读取剧集脚本和分镜", + "匹配角色、场景和风格资产", + stage === "audio" ? "生成旁白和角色对白" : "生成阶段预览结果", + stage === "compose" || stage === "video" ? "任务已进入生成队列" : "Mock 结果已写入工作台", + ], + }; + tasks = [task, ...tasks]; + return ok(task, "Mock 生成任务已创建"); + } + if (path.includes("/episodes/") && path.endsWith("/publish")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const episode = (episodesByProject.get(projectId) || []).find((item) => item.number === episodeNum); + const selectedStyleId = selectedStyleByProject.get(projectId) || "chinese_period_drama"; + const style = [...(customStylesByProject.get(projectId) || []), ...stylePresets].find( + (item) => item.id === selectedStyleId, + ); + const beats = json.beats?.length ? json.beats : listEpisodeBeats(projectId, episodeNum); + const durationSeconds = beats.reduce((sum, beat) => sum + Number(beat.estimated_duration || 5), 0); + const workId = `${projectId}-ep${episodeNum}-${Date.now().toString(36)}`; + const work = { + id: workId, + title: json.title || episode?.title || `第 ${episodeNum} 集试看`, + description: json.description || episode?.summary || "AI 生成的剧集试看版本。", + likes: 0, + duration: `00:${String(Math.max(8, durationSeconds)).padStart(2, "0")}`, + genre: style?.name || "AI 短剧", + cover: "https://nfg-web-assets.cdnfg.com/dramaclaw/luban/luban-cover.png", + preview: "https://nfg-web-assets.cdnfg.com/dramaclaw/luban/luban-ep01.mp4", + gradient: style?.previewGradient || "linear-gradient(135deg, #0b1020, #4f8cff 48%, #2dd4bf)", + project: projectId, + episode: episodeNum, + generated: true, + }; + publicWorks = [work, ...publicWorks.filter((item) => !(item.project === projectId && item.episode === episodeNum))]; + const task = { + id: `task-publish-${Date.now()}`, + task_type: "publish", + title: `第 ${episodeNum} 集发布试看`, + project: projectId, + episode: episodeNum, + status: "completed", + progress: 1, + current_task: "Mock 试看作品已发布", + created_at: new Date().toISOString(), + logs: ["读取合成时间线", "生成封面和试看信息", "写入公开作品列表", "发布完成"], + }; + tasks = [task, ...tasks]; + return ok({ work, task }, "Mock 试看作品已发布"); + } + if (path === "projects") { + const name = json.name || `project_${projects.length + 1}`; + const project = { + id: name, + name, + displayName: name, + status: "active", + episodeCount: 0, + updatedAt: new Date().toISOString(), + ownerUsername: mockUser.username, + role: "owner", + }; + projects = [project, ...projects]; + return ok(project); + } + if (path.endsWith("/styles/select")) { + const projectId = path.split("/")[1]; + selectedStyleByProject.set(projectId, json.styleId); + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, updatedAt: new Date().toISOString() } + : item, + ); + return ok({ project: projectId, styleId: json.styleId }); + } + if (path.endsWith("/styles/analyze")) { + const projectId = path.split("/")[1]; + const seed = json.referenceText || json.filename || "参考图"; + const id = `custom-${Date.now().toString(36)}`; + const style = { + id, + name: json.name || "AI 分析风格", + category: "自定义", + description: `根据「${seed}」分析出的项目专属视觉方向,适合当前小说生成视频流程。`, + tags: ["AI 分析", "项目专属", "可编辑"], + tone: "电影感高对比", + palette: json.palette || ["#0b1020", "#4f8cff", "#2dd4bf", "#f8fafc"], + previewGradient: "linear-gradient(135deg, #0b1020, #4f8cff 48%, #2dd4bf)", + recommended: true, + custom: true, + style_instructions: + json.style_instructions || + "保持角色轮廓清晰,使用电影感布光,画面干净,场景细节服务叙事重点。", + avoid_instructions: + json.avoid_instructions || "避免低清晰度、过度磨皮、主体变形、杂乱背景和不稳定角色脸。", + style_tag: json.style_tag || "ai_project_style", + }; + customStylesByProject.set(projectId, [style, ...(customStylesByProject.get(projectId) || [])]); + selectedStyleByProject.set(projectId, id); + tasks = [ + { + id: `task-style-analyze-${Date.now()}`, + task_type: "style", + title: "AI 分析参考图", + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 风格分析已完成", + created_at: new Date().toISOString(), + logs: ["读取参考输入", "提取色彩和光影", "生成风格提示词", "应用到项目"], + }, + ...tasks, + ]; + return ok(style, "Mock 风格分析已完成"); + } + if (path.endsWith("/styles")) { + const projectId = path.split("/")[1]; + const id = `custom-${Date.now().toString(36)}`; + const palette = json.palette?.length ? json.palette : ["#101827", "#4f8cff", "#ec4899", "#f8fafc"]; + const style = { + id, + name: json.name || "自定义风格", + category: json.category || "自定义", + description: json.description || "用户手动创建的项目视觉风格。", + tags: json.tags || ["自定义"], + tone: json.tone || "自定义", + palette, + previewGradient: `linear-gradient(135deg, ${palette[0]}, ${palette[1] || palette[0]} 52%, ${palette[2] || palette[0]})`, + recommended: false, + custom: true, + style_instructions: json.style_instructions || "", + avoid_instructions: json.avoid_instructions || "", + style_tag: json.style_tag || "custom_style", + }; + customStylesByProject.set(projectId, [style, ...(customStylesByProject.get(projectId) || [])]); + selectedStyleByProject.set(projectId, id); + return ok(style, "Mock 自定义风格已创建"); + } + if (path.endsWith("/assistant/messages")) { + const projectId = path.split("/")[1]; + const messages = fallbackChatMessages(projectId); + const userMessage = { + id: `user-${Date.now()}`, + role: "user", + text: json.text || "", + created_at: new Date().toISOString(), + }; + const reply = { + id: `assistant-${Date.now()}`, + role: "assistant", + text: assistantReply(projectId, userMessage.text), + created_at: new Date().toISOString(), + }; + chatMessagesByProject.set(projectId, [...messages, userMessage, reply]); + return ok(reply); + } + if (path.includes("/characters/") && path.endsWith("/save")) { + const parts = path.split("/"); + const projectId = parts[1]; + const characterName = decodeURIComponent(parts[3]); + const updated = updateCharacter(projectId, characterName, (character) => ({ + ...character, + display_name: json.display_name ?? character.display_name, + role: json.role ?? character.role, + gender: json.gender ?? character.gender, + is_main: Boolean(json.is_main), + description: json.description ?? character.description, + reference_audio_url: json.reference_audio_url ?? character.reference_audio_url, + })); + return ok(updated, "Mock 角色资料已保存"); + } + if (path.includes("/characters/") && path.endsWith("/generate")) { + const parts = path.split("/"); + const projectId = parts[1]; + const characterName = decodeURIComponent(parts[3]); + const generatedAt = Date.now().toString(36); + const updated = updateCharacter(projectId, characterName, (character) => ({ + ...character, + portrait_url: `mock://character/${projectId}/${character.name}/portrait-${generatedAt}`, + identities: (character.identities || []).map((identity) => ({ + ...identity, + image_url: identity.image_url || `mock://identity/${projectId}/${identity.identity_id}/portrait-${generatedAt}`, + costume_image_url: + identity.costume_image_url || `mock://identity/${projectId}/${identity.identity_id}/costume-${generatedAt}`, + })), + })); + tasks = [ + { + id: `task-character-generate-${Date.now()}`, + task_type: "characters", + title: `${updated?.display_name || characterName} 形象生成`, + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 角色形象已生成", + created_at: new Date().toISOString(), + logs: ["读取角色设定", "匹配项目风格", "生成头像和身份图", "写入角色资产"], + }, + ...tasks, + ]; + return ok(updated, "Mock 角色形象已生成"); + } + if (path.includes("/characters/") && path.endsWith("/identities")) { + const parts = path.split("/"); + const projectId = parts[1]; + const characterName = decodeURIComponent(parts[3]); + const identity = { + identity_id: `${characterName}-${Date.now().toString(36)}`, + identity_name: json.identity_name || "新身份形象", + age_group: json.age_group || "adult", + appearance_details: json.appearance_details || "补充这一身份的服装、年龄状态和视觉特征。", + image_url: "", + costume_image_url: "", + }; + const updated = updateCharacter(projectId, characterName, (character) => ({ + ...character, + identities: [...(character.identities || []), identity], + })); + return ok(updated, "Mock 身份形象已添加"); + } + if (path.endsWith("/assets/scenes")) { + const projectId = path.split("/")[1]; + const sceneId = json.scene_id || `scene_${Date.now().toString(36)}`; + const scene = { + scene_id: sceneId, + name: json.name || "新场景", + description: json.description || "补充场景在故事中的作用。", + environment_prompt: json.environment_prompt || "补充空间、光线、时间、天气和关键物件。", + asset_url: "", + director_world_url: "", + status: "draft", + usage_count: 0, + }; + sceneAssetsByProject.set(projectId, [scene, ...fallbackSceneAssets(projectId)]); + return ok(scene, "Mock 场景已创建"); + } + if (path.includes("/assets/scenes/") && path.endsWith("/save")) { + const parts = path.split("/"); + const projectId = parts[1]; + const sceneId = decodeURIComponent(parts[4]); + const updated = updateSceneAsset(projectId, sceneId, (scene) => ({ + ...scene, + name: json.name ?? scene.name, + description: json.description ?? scene.description, + environment_prompt: json.environment_prompt ?? scene.environment_prompt, + status: json.status ?? scene.status, + })); + return ok(updated, "Mock 场景已保存"); + } + if (path.includes("/assets/scenes/") && path.endsWith("/generate")) { + const parts = path.split("/"); + const projectId = parts[1]; + const sceneId = decodeURIComponent(parts[4]); + const stamp = Date.now().toString(36); + const updated = updateSceneAsset(projectId, sceneId, (scene) => ({ + ...scene, + asset_url: `mock://scene/${projectId}/${scene.scene_id}/master-${stamp}`, + director_world_url: `mock://scene/${projectId}/${scene.scene_id}/director-${stamp}`, + status: "ready", + })); + tasks = [ + { + id: `task-scene-${Date.now()}`, + task_type: "scene", + title: `${updated?.name || sceneId} 场景生成`, + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 场景资产已生成", + created_at: new Date().toISOString(), + logs: ["读取场景提示", "生成场景主图", "生成导演世界引用", "写入资产库"], + }, + ...tasks, + ]; + return ok(updated, "Mock 场景已生成"); + } + if (path.endsWith("/assets/props")) { + const projectId = path.split("/")[1]; + const propId = json.prop_id || `prop_${Date.now().toString(36)}`; + const prop = { + prop_id: propId, + name: json.name || "新道具", + description: json.description || "补充道具在故事中的作用。", + visual_prompt: json.visual_prompt || "补充材质、形状、磨损程度和特写重点。", + asset_url: "", + status: "draft", + usage_count: 0, + }; + propAssetsByProject.set(projectId, [prop, ...fallbackPropAssets(projectId)]); + return ok(prop, "Mock 道具已创建"); + } + if (path.includes("/assets/props/") && path.endsWith("/save")) { + const parts = path.split("/"); + const projectId = parts[1]; + const propId = decodeURIComponent(parts[4]); + const updated = updatePropAsset(projectId, propId, (prop) => ({ + ...prop, + name: json.name ?? prop.name, + description: json.description ?? prop.description, + visual_prompt: json.visual_prompt ?? prop.visual_prompt, + status: json.status ?? prop.status, + })); + return ok(updated, "Mock 道具已保存"); + } + if (path.includes("/assets/props/") && path.endsWith("/generate")) { + const parts = path.split("/"); + const projectId = parts[1]; + const propId = decodeURIComponent(parts[4]); + const stamp = Date.now().toString(36); + const updated = updatePropAsset(projectId, propId, (prop) => ({ + ...prop, + asset_url: `mock://prop/${projectId}/${prop.prop_id}/ref-${stamp}`, + status: "ready", + })); + tasks = [ + { + id: `task-prop-${Date.now()}`, + task_type: "prop", + title: `${updated?.name || propId} 道具生成`, + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 道具资产已生成", + created_at: new Date().toISOString(), + logs: ["读取道具提示", "生成道具参考图", "写入资产库"], + }, + ...tasks, + ]; + return ok(updated, "Mock 道具已生成"); + } + if (path.endsWith("/ingest/upload")) { + const projectId = path.split("/")[1]; + const filename = json?.filename || "pasted-story.txt"; + const content = json?.content || ""; + const chapters = content.trim() + ? content + .split(/\n\s*\n/) + .filter(Boolean) + .slice(0, 8) + .map((block, index) => ({ + number: index + 1, + title: `第 ${index + 1} 章`, + content: block.trim().slice(0, 120), + word_count: block.trim().length, + char_count: block.trim().length, + })) + : sampleChapters; + const totalChars = chapters.reduce((sum, chapter) => sum + (chapter.char_count || 0), 0); + const payload = { + filename, + size: json?.size || Math.max(totalChars, 128), + total_chars: totalChars, + count: chapters.length, + chapters, + format_check: { + level: "ok", + summary: "Mock 格式检测通过", + issues: [], + metrics: { + chapters: chapters.length, + total_chars: totalChars, + }, + }, + }; + ingestState.set(projectId, { + filename: payload.filename, + size: payload.size, + chapters, + total_chars: totalChars, + imported: false, + }); + return ok(payload); + } + if (path.endsWith("/ingest/start")) { + const projectId = path.split("/")[1]; + const state = ingestState.get(projectId); + ingestState.set(projectId, { ...(state || {}), imported: true }); + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { + ...item, + episodeCount: Math.max(item.episodeCount || 0, state?.chapters?.length || 3), + updatedAt: new Date().toISOString(), + } + : item, + ); + const chapters = state?.chapters?.length ? state.chapters : sampleChapters; + episodesByProject.set( + projectId, + chapters.map((chapter) => ({ + number: chapter.number, + title: chapter.title, + summary: chapter.content, + identity_ids: ["hero"], + scene_menu: ["mock_scene"], + prop_menu: ["mock_prop"], + })), + ); + chapters.forEach((chapter) => { + beatsByEpisode.set( + episodeKey(projectId, chapter.number), + [ + { + beat_number: 1, + narration_segment: chapter.content, + visual_description: "根据小说正文生成的开场镜头,突出环境与人物状态。", + speaker: "旁白", + status: "draft", + }, + { + beat_number: 2, + narration_segment: "AI 提取本章关键冲突,准备进入画面化表达。", + visual_description: "中景,角色面对选择,背景光线形成强烈对比。", + speaker: "主角", + status: "draft", + }, + ], + ); + }); + if (!charactersByProject.has(projectId)) { + charactersByProject.set(projectId, fallbackCharacters(projectId)); + } + tasks = [ + { + id: `task-ingest-${Date.now()}`, + task_type: "ingest", + title: "小说章节识别", + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 导入完成", + created_at: new Date().toISOString(), + logs: ["读取文本", "识别章节", "创建剧集", "完成"], + }, + ...tasks, + ]; + return { + ok: true, + task_type: "ingest", + message: "Mock 导入任务已完成", + data: { project: projectId }, + }; + } + if (path.endsWith("/archive")) { + const projectId = path.split("/")[1]; + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, status: "archived" } + : item, + ); + return ok(null); + } + if (path.endsWith("/characters/build")) { + const projectId = path.split("/")[1]; + fallbackCharacters(projectId); + tasks = [ + { + id: `task-characters-${Date.now()}`, + task_type: "characters", + title: "AI 识别角色", + project: projectId, + episode: 0, + status: "completed", + progress: 1, + current_task: "Mock 角色识别完成", + created_at: new Date().toISOString(), + logs: ["扫描剧集脚本", "识别角色关系", "生成身份形象", "完成"], + }, + ...tasks, + ]; + return { + ok: true, + task_type: "characters", + message: "Mock 角色任务已完成", + data: { project: projectId }, + }; + } + if (path.endsWith("/restore")) { + const projectId = path.split("/")[1]; + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, status: "active" } + : item, + ); + return ok(null); + } + + return ok(null); +} + +export async function mockDelete(path) { + await wait(); + + if (path.includes("/episodes/") && path.includes("/beats/")) { + const parts = path.split("/"); + const projectId = parts[1]; + const episodeNum = Number(parts[3]); + const beatNumber = Number(parts[5]); + const current = listEpisodeBeats(projectId, episodeNum); + const beats = normalizeEpisodeBeats(current.filter((beat) => beat.beat_number !== beatNumber)); + beatsByEpisode.set(episodeKey(projectId, episodeNum), beats); + updateEpisodeFromBeats(projectId, episodeNum, beats); + return ok(beats, "Mock 镜头已删除"); + } + + if (path.startsWith("projects/")) { + const projectId = path.split("/")[1]; + projects = projects.map((item) => + item.id === projectId || item.name === projectId + ? { ...item, status: "deleted" } + : item, + ); + return ok(null); + } + + if (path.startsWith("tasks/")) { + const [, taskType, project, episode] = path.split("/"); + tasks = tasks.map((item) => + item.task_type === taskType && + item.project === project && + String(item.episode) === String(episode) + ? { + ...item, + status: "cancelled", + progress: item.progress ?? 0, + current_task: "用户已取消", + logs: [...(item.logs || []), "用户取消任务"], + } + : item, + ); + return ok(null); + } + + return ok(null); +} diff --git a/src/api/projects.js b/src/api/projects.js new file mode 100644 index 0000000..cb3bf31 --- /dev/null +++ b/src/api/projects.js @@ -0,0 +1,29 @@ +import { deleteJson, getJson, postJson } from "./http"; + +export function listProjects() { + return getJson("projects"); +} + +export function getProject(project) { + return getJson(`projects/${project}`); +} + +export function createProject(name) { + return postJson("projects", { name }); +} + +export function archiveProject(project) { + return postJson(`projects/${project}/archive`, {}); +} + +export function unarchiveProject(project) { + return postJson(`projects/${project}/restore`, {}); +} + +export function restoreProject(project) { + return postJson(`projects/${project}/restore`, {}); +} + +export function deleteProject(project) { + return deleteJson(`projects/${project}`); +} diff --git a/src/api/styles.js b/src/api/styles.js new file mode 100644 index 0000000..f932d37 --- /dev/null +++ b/src/api/styles.js @@ -0,0 +1,23 @@ +import { getJson, postJson } from "./http"; + +export function listStyles(project) { + const options = project ? { searchParams: { project } } : undefined; + return getJson("styles", options); +} + +export function getStyle(styleId, project) { + const options = project ? { searchParams: { project } } : undefined; + return getJson(`styles/${styleId}`, options); +} + +export function selectProjectStyle(project, styleId) { + return postJson(`projects/${project}/styles/select`, { styleId }); +} + +export function analyzeProjectStyle(project, payload) { + return postJson(`projects/${project}/styles/analyze`, payload); +} + +export function createProjectStyle(project, payload) { + return postJson(`projects/${project}/styles`, payload); +} diff --git a/src/api/tasks.js b/src/api/tasks.js new file mode 100644 index 0000000..467f531 --- /dev/null +++ b/src/api/tasks.js @@ -0,0 +1,18 @@ +import { deleteJson, getJson, postJson } from "./http"; + +export function listTasks(project) { + const searchParams = project ? { project } : undefined; + return getJson("tasks", { searchParams }); +} + +export function cancelTask(taskType, project, episode = 0) { + return deleteJson(`tasks/${taskType}/${project}/${episode}`); +} + +export function retryTask(taskId) { + return postJson(`tasks/${encodeURIComponent(taskId)}/retry`, {}); +} + +export function clearCompletedTasks(project) { + return postJson("tasks/clear-completed", { project }); +} diff --git a/src/api/watch.js b/src/api/watch.js new file mode 100644 index 0000000..78e803c --- /dev/null +++ b/src/api/watch.js @@ -0,0 +1,11 @@ +import { getJson } from "@/api/http"; + +export async function listPublicWorks() { + const response = await getJson("watch/works"); + return response.data || []; +} + +export async function getPublicWork(workId) { + const response = await getJson(`watch/works/${encodeURIComponent(workId)}`); + return response.data || null; +} diff --git a/src/config/runtime.js b/src/config/runtime.js new file mode 100644 index 0000000..2f742de --- /dev/null +++ b/src/config/runtime.js @@ -0,0 +1 @@ +export const useMockApi = import.meta.env.VITE_USE_MOCK !== "false"; diff --git a/src/i18n/en-US.js b/src/i18n/en-US.js new file mode 100644 index 0000000..83a3cd2 --- /dev/null +++ b/src/i18n/en-US.js @@ -0,0 +1,36 @@ +export default { + common: { + appName: "SuperTale", + confirm: "Confirm", + cancel: "Cancel", + create: "Create", + loading: "Loading", + comingSoon: "Migration in progress", + }, + auth: { + loginTitle: "Sign in", + username: "Username", + password: "Password", + submit: "Sign in", + watch: "Watch", + }, + nav: { + projects: "Projects", + account: "Account", + overview: "Overview", + ingest: "Ingest", + characters: "Characters", + assets: "Assets", + styles: "Styles", + episodes: "Episodes", + tasks: "Tasks", + freezone: "Canvas", + assistant: "Assistant", + }, + dashboard: { + title: "Projects", + subtitle: "Manage novel-to-video creator projects.", + newProject: "New project", + empty: "No projects yet", + }, +}; diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..b4e895c --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,15 @@ +import { createI18n } from "vue-i18n"; +import zhCN from "./zh-CN"; +import enUS from "./en-US"; + +const savedLocale = window.localStorage.getItem("supertale-vue-locale"); + +export default createI18n({ + legacy: false, + locale: savedLocale || "zh-CN", + fallbackLocale: "en-US", + messages: { + "zh-CN": zhCN, + "en-US": enUS, + }, +}); diff --git a/src/i18n/zh-CN.js b/src/i18n/zh-CN.js new file mode 100644 index 0000000..fed3254 --- /dev/null +++ b/src/i18n/zh-CN.js @@ -0,0 +1,36 @@ +export default { + common: { + appName: "SuperTale", + confirm: "确认", + cancel: "取消", + create: "创建", + loading: "加载中", + comingSoon: "迁移中", + }, + auth: { + loginTitle: "登录创作台", + username: "用户名", + password: "密码", + submit: "登录", + watch: "试看作品", + }, + nav: { + projects: "项目", + account: "账户", + overview: "总览", + ingest: "导入", + characters: "角色", + assets: "资产", + styles: "风格", + episodes: "剧集", + tasks: "任务", + freezone: "画布", + assistant: "助手", + }, + dashboard: { + title: "项目", + subtitle: "管理小说到视频的创作项目。", + newProject: "新建项目", + empty: "暂无项目", + }, +}; diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue new file mode 100644 index 0000000..8e08799 --- /dev/null +++ b/src/layouts/AppLayout.vue @@ -0,0 +1,708 @@ + + + + + diff --git a/src/layouts/ProjectLayout.vue b/src/layouts/ProjectLayout.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/src/layouts/ProjectLayout.vue @@ -0,0 +1,3 @@ + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..e44b8b2 --- /dev/null +++ b/src/main.js @@ -0,0 +1,30 @@ +import { createApp } from "vue"; +import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import "@vue-flow/core/dist/style.css"; +import "@vue-flow/core/dist/theme-default.css"; + +import App from "./App.vue"; +import router from "./router"; +import { pinia } from "./stores"; +import i18n from "./i18n"; +import "./styles/index.scss"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +createApp(App) + .use(pinia) + .use(router) + .use(i18n) + .use(ElementPlus) + .use(VueQueryPlugin, { queryClient }) + .mount("#app"); diff --git a/src/queries/account.js b/src/queries/account.js new file mode 100644 index 0000000..d897cbf --- /dev/null +++ b/src/queries/account.js @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { computed, unref } from "vue"; +import { + getAccountSummary, + listAccountNotifications, + listCreditUsage, + markNotificationRead, + topUpCredits, +} from "@/api/account"; + +export const accountKeys = { + all: ["account"], + summary: () => [...accountKeys.all, "summary"], + usage: (params) => [...accountKeys.all, "usage", params || {}], + notifications: () => [...accountKeys.all, "notifications"], +}; + +export function useAccountSummaryQuery() { + return useQuery({ + queryKey: accountKeys.summary(), + queryFn: getAccountSummary, + }); +} + +export function useCreditUsageQuery(params) { + return useQuery({ + queryKey: computed(() => accountKeys.usage(unref(params))), + queryFn: () => listCreditUsage(unref(params)), + }); +} + +export function useAccountNotificationsQuery() { + return useQuery({ + queryKey: accountKeys.notifications(), + queryFn: listAccountNotifications, + }); +} + +export function useTopUpCreditsMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: topUpCredits, + onSuccess: () => queryClient.invalidateQueries({ queryKey: accountKeys.all }), + }); +} + +export function useMarkNotificationReadMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: markNotificationRead, + onSuccess: () => queryClient.invalidateQueries({ queryKey: accountKeys.notifications() }), + }); +} diff --git a/src/queries/assets.js b/src/queries/assets.js new file mode 100644 index 0000000..034e949 --- /dev/null +++ b/src/queries/assets.js @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { + createPropAsset, + createSceneAsset, + generatePropAsset, + generateSceneAsset, + listPropAssets, + listSceneAssets, + savePropAsset, + saveSceneAsset, +} from "@/api/assets"; +import { freezoneKeys } from "@/queries/freezone"; +import { taskKeys } from "@/queries/tasks"; + +export const assetKeys = { + all: ["assets"], + scenes: (project) => [...assetKeys.all, "scenes", project], + props: (project) => [...assetKeys.all, "props", project], +}; + +export function useSceneAssetsQuery(project) { + return useQuery({ + queryKey: assetKeys.scenes(project), + queryFn: () => listSceneAssets(project), + enabled: Boolean(project), + }); +} + +export function usePropAssetsQuery(project) { + return useQuery({ + queryKey: assetKeys.props(project), + queryFn: () => listPropAssets(project), + enabled: Boolean(project), + }); +} + +function invalidateAssets(queryClient, project) { + queryClient.invalidateQueries({ queryKey: assetKeys.all }); + queryClient.invalidateQueries({ queryKey: freezoneKeys.assets(project) }); + queryClient.invalidateQueries({ queryKey: taskKeys.all }); +} + +export function useCreateSceneAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => createSceneAsset(project, payload), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} + +export function useSaveSceneAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ sceneId, payload }) => saveSceneAsset(project, sceneId, payload), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} + +export function useGenerateSceneAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (sceneId) => generateSceneAsset(project, sceneId), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} + +export function useCreatePropAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => createPropAsset(project, payload), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} + +export function useSavePropAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ propId, payload }) => savePropAsset(project, propId, payload), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} + +export function useGeneratePropAssetMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (propId) => generatePropAsset(project, propId), + onSuccess: () => invalidateAssets(queryClient, project), + }); +} diff --git a/src/queries/assistant.js b/src/queries/assistant.js new file mode 100644 index 0000000..afdd6e0 --- /dev/null +++ b/src/queries/assistant.js @@ -0,0 +1,26 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { + listAssistantMessages, + sendAssistantMessage, +} from "@/api/assistant"; + +export const assistantKeys = { + all: ["assistant"], + messages: (project) => [...assistantKeys.all, "messages", project], +}; + +export function useAssistantMessagesQuery(project) { + return useQuery({ + queryKey: assistantKeys.messages(project), + queryFn: () => listAssistantMessages(project), + enabled: Boolean(project), + }); +} + +export function useSendAssistantMessageMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (text) => sendAssistantMessage(project, text), + onSuccess: () => queryClient.invalidateQueries({ queryKey: assistantKeys.messages(project) }), + }); +} diff --git a/src/queries/characters.js b/src/queries/characters.js new file mode 100644 index 0000000..a3d1ee1 --- /dev/null +++ b/src/queries/characters.js @@ -0,0 +1,66 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { + addCharacterIdentity, + buildCharacters, + generateCharacterAssets, + listCharacterIdentities, + listCharacters, + saveCharacter, +} from "@/api/characters"; + +export const characterKeys = { + all: ["characters"], + list: (project) => [...characterKeys.all, "list", project], + identities: (project, character) => [...characterKeys.all, "identities", project, character], +}; + +export function useCharactersQuery(project) { + return useQuery({ + queryKey: characterKeys.list(project), + queryFn: () => listCharacters(project), + enabled: Boolean(project), + }); +} + +export function useCharacterIdentitiesQuery(project, character) { + return useQuery({ + queryKey: characterKeys.identities(project, character), + queryFn: () => listCharacterIdentities(project, character), + enabled: Boolean(project && character), + }); +} + +export function useBuildCharactersMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => buildCharacters(project), + onSuccess: () => queryClient.invalidateQueries({ queryKey: characterKeys.all }), + }); +} + +export function useSaveCharacterMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ character, payload }) => saveCharacter(project, character, payload), + onSuccess: () => queryClient.invalidateQueries({ queryKey: characterKeys.all }), + }); +} + +export function useGenerateCharacterAssetsMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (character) => generateCharacterAssets(project, character), + onSuccess: () => queryClient.invalidateQueries({ queryKey: characterKeys.all }), + }); +} + +export function useAddCharacterIdentityMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ character, payload }) => addCharacterIdentity(project, character, payload), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: characterKeys.list(project) }); + queryClient.invalidateQueries({ queryKey: characterKeys.identities(project, variables.character) }); + }, + }); +} diff --git a/src/queries/episodes.js b/src/queries/episodes.js new file mode 100644 index 0000000..684ae3f --- /dev/null +++ b/src/queries/episodes.js @@ -0,0 +1,115 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { + getEpisode, + deleteEpisodeBeat, + listEpisodeBeats, + listEpisodes, + planEpisodes, + publishEpisodePreview, + runEpisodeStage, + saveEpisodeBeat, + saveEpisodeBeats, +} from "@/api/episodes"; +import { projectKeys } from "@/queries/projects"; +import { taskKeys } from "@/queries/tasks"; +import { watchKeys } from "@/queries/watch"; + +export const episodeKeys = { + all: ["episodes"], + list: (project) => [...episodeKeys.all, "list", project], + detail: (project, episode) => [...episodeKeys.all, "detail", project, episode], + beats: (project, episode) => [...episodeKeys.all, "beats", project, episode], +}; + +export function useEpisodesQuery(project) { + return useQuery({ + queryKey: episodeKeys.list(project), + queryFn: () => listEpisodes(project), + enabled: Boolean(project), + }); +} + +export function useEpisodeQuery(project, episode) { + return useQuery({ + queryKey: episodeKeys.detail(project, episode), + queryFn: () => getEpisode(project, episode), + enabled: Boolean(project && episode), + }); +} + +export function useEpisodeBeatsQuery(project, episode) { + return useQuery({ + queryKey: episodeKeys.beats(project, episode), + queryFn: () => listEpisodeBeats(project, episode), + enabled: Boolean(project && episode), + }); +} + +export function usePlanEpisodesMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => planEpisodes(project), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.all }); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + +export function useRunEpisodeStageMutation(project, episode) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input) => { + const stage = typeof input === "string" ? input : input.stage; + const payload = typeof input === "string" ? {} : input.payload; + return runEpisodeStage(project, episode, stage, payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.all }); + queryClient.invalidateQueries({ queryKey: taskKeys.all }); + }, + }); +} + +export function useSaveEpisodeBeatsMutation(project, episode) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (beats) => saveEpisodeBeats(project, episode, beats), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.beats(project, episode) }); + queryClient.invalidateQueries({ queryKey: episodeKeys.detail(project, episode) }); + }, + }); +} + +export function useSaveEpisodeBeatMutation(project, episode) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ beatNumber, beat }) => saveEpisodeBeat(project, episode, beatNumber, beat), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.beats(project, episode) }); + }, + }); +} + +export function useDeleteEpisodeBeatMutation(project, episode) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (beatNumber) => deleteEpisodeBeat(project, episode, beatNumber), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.beats(project, episode) }); + }, + }); +} + +export function usePublishEpisodePreviewMutation(project, episode) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => publishEpisodePreview(project, episode, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: episodeKeys.all }); + queryClient.invalidateQueries({ queryKey: taskKeys.all }); + queryClient.invalidateQueries({ queryKey: watchKeys.all }); + }, + }); +} diff --git a/src/queries/freezone.js b/src/queries/freezone.js new file mode 100644 index 0000000..eed1737 --- /dev/null +++ b/src/queries/freezone.js @@ -0,0 +1,113 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { computed, unref } from "vue"; +import { + commitFreezoneNode, + createFreezoneCanvas, + getFreezoneCanvas, + listFreezoneCanvasHistory, + listFreezoneAssets, + listFreezoneCanvases, + restoreFreezoneCanvasHistory, + runFreezoneNode, + saveFreezoneCanvas, +} from "@/api/freezone"; +import { taskKeys } from "@/queries/tasks"; + +export const freezoneKeys = { + all: ["freezone"], + assets: (project) => [...freezoneKeys.all, "assets", project], + canvases: (project) => [...freezoneKeys.all, "canvases", project], + canvas: (project, canvasId) => [...freezoneKeys.all, "canvas", project, canvasId], + history: (project, canvasId) => [...freezoneKeys.all, "history", project, canvasId], +}; + +function valueOf(source) { + return unref(source); +} + +export function useFreezoneAssetsQuery(project) { + return useQuery({ + queryKey: computed(() => freezoneKeys.assets(valueOf(project))), + queryFn: () => listFreezoneAssets(valueOf(project)), + enabled: computed(() => Boolean(valueOf(project))), + }); +} + +export function useFreezoneCanvasesQuery(project) { + return useQuery({ + queryKey: computed(() => freezoneKeys.canvases(valueOf(project))), + queryFn: () => listFreezoneCanvases(valueOf(project)), + enabled: computed(() => Boolean(valueOf(project))), + }); +} + +export function useFreezoneCanvasQuery(project, canvasId) { + return useQuery({ + queryKey: computed(() => freezoneKeys.canvas(valueOf(project), valueOf(canvasId))), + queryFn: () => getFreezoneCanvas(valueOf(project), valueOf(canvasId)), + enabled: computed(() => Boolean(valueOf(project) && valueOf(canvasId))), + }); +} + +export function useFreezoneCanvasHistoryQuery(project, canvasId) { + return useQuery({ + queryKey: computed(() => freezoneKeys.history(valueOf(project), valueOf(canvasId))), + queryFn: () => listFreezoneCanvasHistory(valueOf(project), valueOf(canvasId)), + enabled: computed(() => Boolean(valueOf(project) && valueOf(canvasId))), + }); +} + +export function useCreateFreezoneCanvasMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => createFreezoneCanvas(valueOf(project), payload), + onSuccess: () => queryClient.invalidateQueries({ queryKey: freezoneKeys.canvases(valueOf(project)) }), + }); +} + +export function useSaveFreezoneCanvasMutation(project, canvasId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => saveFreezoneCanvas(valueOf(project), valueOf(canvasId), payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: freezoneKeys.canvases(valueOf(project)) }); + queryClient.invalidateQueries({ queryKey: freezoneKeys.history(valueOf(project), valueOf(canvasId)) }); + queryClient.invalidateQueries({ queryKey: freezoneKeys.canvas(valueOf(project), valueOf(canvasId)) }); + }, + }); +} + +export function useRestoreFreezoneCanvasHistoryMutation(project, canvasId) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (historyId) => restoreFreezoneCanvasHistory(valueOf(project), valueOf(canvasId), historyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: freezoneKeys.canvases(valueOf(project)) }); + queryClient.invalidateQueries({ queryKey: freezoneKeys.history(valueOf(project), valueOf(canvasId)) }); + queryClient.invalidateQueries({ queryKey: freezoneKeys.canvas(valueOf(project), valueOf(canvasId)) }); + }, + }); +} + +export function useRunFreezoneNodeMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => runFreezoneNode(valueOf(project), payload), + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }), + }); +} + +export function useCommitFreezoneNodeMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => commitFreezoneNode(valueOf(project), payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: freezoneKeys.assets(valueOf(project)) }); + queryClient.invalidateQueries({ queryKey: ["episodes"] }); + queryClient.invalidateQueries({ queryKey: ["assets"] }); + queryClient.invalidateQueries({ queryKey: ["characters"] }); + queryClient.invalidateQueries({ queryKey: ["watch"] }); + queryClient.invalidateQueries({ queryKey: taskKeys.all }); + }, + }); +} diff --git a/src/queries/ingest.js b/src/queries/ingest.js new file mode 100644 index 0000000..cd2cff2 --- /dev/null +++ b/src/queries/ingest.js @@ -0,0 +1,48 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { getChapters, startIngest, uploadNovel } from "@/api/ingest"; +import { projectKeys } from "@/queries/projects"; + +export const ingestKeys = { + all: ["ingest"], + chapters: (project) => [...ingestKeys.all, "chapters", project], +}; + +export function useChaptersQuery(project) { + return useQuery({ + queryKey: ingestKeys.chapters(project), + queryFn: () => getChapters(project), + enabled: Boolean(project), + }); +} + +export function useUploadNovelMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => uploadNovel(project, payload), + onSuccess: (response) => { + if (response?.data?.chapters) { + queryClient.setQueryData(ingestKeys.chapters(project), { + ok: true, + data: { + chapters: response.data.chapters, + total_chars: response.data.total_chars || 0, + count: response.data.count || response.data.chapters.length, + }, + }); + } else { + queryClient.invalidateQueries({ queryKey: ingestKeys.chapters(project) }); + } + }, + }); +} + +export function useStartIngestMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => startIngest(project, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + queryClient.invalidateQueries({ queryKey: ingestKeys.all }); + }, + }); +} diff --git a/src/queries/projects.js b/src/queries/projects.js new file mode 100644 index 0000000..3f42a70 --- /dev/null +++ b/src/queries/projects.js @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { + archiveProject, + createProject, + deleteProject, + getProject, + listProjects, + restoreProject, + unarchiveProject, +} from "@/api/projects"; + +export const projectKeys = { + all: ["projects"], + list: () => [...projectKeys.all, "list"], + detail: (project) => [...projectKeys.all, "detail", project], +}; + +export function useProjectsQuery() { + return useQuery({ + queryKey: projectKeys.list(), + queryFn: listProjects, + }); +} + +export function useProjectQuery(project) { + return useQuery({ + queryKey: projectKeys.detail(project), + queryFn: () => getProject(project), + enabled: Boolean(project), + }); +} + +export function useCreateProjectMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createProject, + onSuccess: () => queryClient.invalidateQueries({ queryKey: projectKeys.all }), + }); +} + +export function useProjectLifecycleMutation(action) { + const queryClient = useQueryClient(); + const mutationMap = { + archive: archiveProject, + unarchive: unarchiveProject, + restore: restoreProject, + delete: deleteProject, + }; + + return useMutation({ + mutationFn: mutationMap[action], + onSuccess: () => queryClient.invalidateQueries({ queryKey: projectKeys.all }), + }); +} diff --git a/src/queries/styles.js b/src/queries/styles.js new file mode 100644 index 0000000..8352f55 --- /dev/null +++ b/src/queries/styles.js @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { analyzeProjectStyle, createProjectStyle, getStyle, listStyles, selectProjectStyle } from "@/api/styles"; +import { projectKeys } from "@/queries/projects"; + +export const styleKeys = { + all: ["styles"], + list: (project) => [...styleKeys.all, "list", project || "global"], + detail: (styleId, project) => [...styleKeys.all, "detail", styleId, project || "global"], +}; + +export function useStylesQuery(project) { + return useQuery({ + queryKey: styleKeys.list(project), + queryFn: () => listStyles(project), + }); +} + +export function useStyleQuery(styleId, project) { + return useQuery({ + queryKey: styleKeys.detail(styleId, project), + queryFn: () => getStyle(styleId, project), + enabled: Boolean(styleId), + }); +} + +export function useSelectProjectStyleMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (styleId) => selectProjectStyle(project, styleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: styleKeys.all }); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + +export function useAnalyzeProjectStyleMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => analyzeProjectStyle(project, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: styleKeys.all }); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} + +export function useCreateProjectStyleMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => createProjectStyle(project, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: styleKeys.all }); + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +} diff --git a/src/queries/tasks.js b/src/queries/tasks.js new file mode 100644 index 0000000..468af91 --- /dev/null +++ b/src/queries/tasks.js @@ -0,0 +1,39 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { computed, unref } from "vue"; +import { cancelTask, clearCompletedTasks, listTasks, retryTask } from "@/api/tasks"; + +export const taskKeys = { + all: ["tasks"], + list: (project) => [...taskKeys.all, "list", project || "all"], +}; + +export function useTasksQuery(project) { + return useQuery({ + queryKey: computed(() => taskKeys.list(unref(project))), + queryFn: () => listTasks(unref(project)), + }); +} + +export function useCancelTaskMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ taskType, episode }) => cancelTask(taskType, project, episode), + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }), + }); +} + +export function useRetryTaskMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (taskId) => retryTask(taskId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }), + }); +} + +export function useClearCompletedTasksMutation(project) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => clearCompletedTasks(project), + onSuccess: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }), + }); +} diff --git a/src/queries/watch.js b/src/queries/watch.js new file mode 100644 index 0000000..b182124 --- /dev/null +++ b/src/queries/watch.js @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/vue-query"; +import { getPublicWork, listPublicWorks } from "@/api/watch"; + +export const watchKeys = { + all: ["watch"], + list: () => [...watchKeys.all, "works"], + detail: (workId) => [...watchKeys.all, "work", workId], +}; + +export function usePublicWorksQuery() { + return useQuery({ + queryKey: watchKeys.list(), + queryFn: listPublicWorks, + }); +} + +export function usePublicWorkQuery(workId) { + return useQuery({ + queryKey: watchKeys.detail(workId), + queryFn: () => getPublicWork(workId), + enabled: Boolean(workId), + }); +} diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..3c61a15 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,125 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "@/stores/auth"; +import { useMockApi } from "@/config/runtime"; + +const routes = [ + { + path: "/login", + name: "login", + component: () => import("@/views/LoginView.vue"), + meta: { public: true }, + }, + { + path: "/watch/:work", + name: "watch", + component: () => import("@/views/WatchView.vue"), + meta: { public: true }, + }, + { + path: "/", + component: () => import("@/layouts/AppLayout.vue"), + children: [ + { + path: "", + name: "projects", + component: () => import("@/views/projects/ProjectDashboardView.vue"), + }, + { + path: "account", + name: "account", + component: () => import("@/views/account/AccountView.vue"), + }, + { + path: "projects/:project", + component: () => import("@/layouts/ProjectLayout.vue"), + children: [ + { + path: "", + redirect: (to) => `/projects/${to.params.project}/overview`, + }, + { + path: "overview", + name: "project-overview", + component: () => import("@/views/projects/ProjectOverviewView.vue"), + }, + { + path: "ingest", + name: "project-ingest", + component: () => import("@/views/ingest/IngestView.vue"), + }, + { + path: "characters", + name: "project-characters", + component: () => import("@/views/characters/CharactersView.vue"), + }, + { + path: "assets", + name: "project-assets", + component: () => import("@/views/assets/AssetsView.vue"), + }, + { + path: "styles", + name: "project-styles", + component: () => import("@/views/styles/StylesView.vue"), + }, + { + path: "episodes", + name: "project-episodes", + component: () => import("@/views/episodes/EpisodesView.vue"), + }, + { + path: "episodes/:episode/:stage?", + name: "project-episode-stage", + component: () => import("@/views/episodes/EpisodeStageView.vue"), + }, + { + path: "tasks", + name: "project-tasks", + component: () => import("@/views/tasks/TasksView.vue"), + }, + { + path: "freezone", + name: "project-freezone", + component: () => import("@/views/freezone/FreezoneView.vue"), + meta: { fullBleed: true }, + }, + { + path: "assistant", + name: "project-assistant", + component: () => import("@/views/assistant/AssistantView.vue"), + }, + ], + }, + ], + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +router.beforeEach(async (to) => { + if (to.meta.public) return true; + + const auth = useAuthStore(); + if (useMockApi && !auth.ready) { + await auth.restore(); + } + if (useMockApi) return true; + + if (!auth.ready) { + await auth.restore(); + } + + if (!auth.isAuthenticated) { + return { + name: "login", + query: { redirect: to.fullPath }, + }; + } + + return true; +}); + +export default router; diff --git a/src/stores/app.js b/src/stores/app.js new file mode 100644 index 0000000..9b41cbf --- /dev/null +++ b/src/stores/app.js @@ -0,0 +1,44 @@ +import { defineStore } from "pinia"; + +export const useAppStore = defineStore("app", { + state: () => ({ + sidebarCollapsed: false, + taskDrawerOpen: false, + settingsOpen: false, + dashboardStatus: "active", + dashboardView: "card", + studioSettings: { + autoSave: true, + defaultRatio: "16:9", + quality: "standard", + density: "comfortable", + generationNotice: true, + }, + }), + actions: { + toggleSidebar() { + this.sidebarCollapsed = !this.sidebarCollapsed; + }, + openTaskDrawer() { + this.taskDrawerOpen = true; + }, + closeTaskDrawer() { + this.taskDrawerOpen = false; + }, + openSettings() { + this.settingsOpen = true; + }, + closeSettings() { + this.settingsOpen = false; + }, + updateStudioSetting(key, value) { + this.studioSettings[key] = value; + }, + setDashboardStatus(status) { + this.dashboardStatus = status; + }, + setDashboardView(view) { + this.dashboardView = view; + }, + }, +}); diff --git a/src/stores/auth.js b/src/stores/auth.js new file mode 100644 index 0000000..3a4293d --- /dev/null +++ b/src/stores/auth.js @@ -0,0 +1,54 @@ +import { defineStore } from "pinia"; +import { login, logout, fetchMe } from "@/api/auth"; +import { useMockApi } from "@/config/runtime"; + +export const useAuthStore = defineStore("auth", { + state: () => ({ + username: window.localStorage.getItem("supertale-vue-username") || "", + role: window.localStorage.getItem("supertale-vue-role") || "", + ready: false, + }), + getters: { + isAuthenticated: (state) => Boolean(state.username), + }, + actions: { + setSession(user) { + this.username = user?.username || ""; + this.role = user?.role || ""; + if (this.username) { + window.localStorage.setItem("supertale-vue-username", this.username); + window.localStorage.setItem("supertale-vue-role", this.role); + } else { + window.localStorage.removeItem("supertale-vue-username"); + window.localStorage.removeItem("supertale-vue-role"); + } + }, + async login(payload) { + const response = await login(payload); + this.setSession(response.data || response); + return response; + }, + async restore() { + if (useMockApi) { + this.setSession({ username: "mock_user", role: "admin" }); + this.ready = true; + return; + } + try { + const response = await fetchMe(); + this.setSession(response.data || response); + } catch { + this.setSession(null); + } finally { + this.ready = true; + } + }, + async logout() { + try { + await logout(); + } finally { + this.setSession(null); + } + }, + }, +}); diff --git a/src/stores/index.js b/src/stores/index.js new file mode 100644 index 0000000..cafea68 --- /dev/null +++ b/src/stores/index.js @@ -0,0 +1,3 @@ +import { createPinia } from "pinia"; + +export const pinia = createPinia(); diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 0000000..13ac254 --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,392 @@ +:root { + color-scheme: dark; + --app-bg: #070a12; + --app-surface: rgba(14, 18, 32, 0.78); + --app-surface-strong: rgba(20, 27, 46, 0.88); + --app-card: rgba(19, 25, 43, 0.72); + --app-card-hover: rgba(26, 36, 62, 0.84); + --app-border: rgba(148, 163, 184, 0.14); + --app-border-strong: rgba(125, 211, 252, 0.28); + --app-text: #f4f7fb; + --app-muted: #9ba8bc; + --app-subtle: #64748b; + --app-primary: #4f8cff; + --app-primary-strong: #7c5cff; + --app-cyan: #2dd4bf; + --app-pink: #ec4899; + --app-orange: #f97316; + --app-green: #22c55e; + --app-danger: #fb7185; + --app-warning: #fbbf24; + --app-shadow: 0 24px 70px rgba(0, 0, 0, 0.34); + --el-color-primary: #4f8cff; + --el-color-primary-dark-2: #9dbdff; + --el-color-primary-light-3: #76a5ff; + --el-color-primary-light-5: #9dbdff; + --el-color-primary-light-7: rgba(79, 140, 255, 0.28); + --el-color-primary-light-9: rgba(79, 140, 255, 0.12); + --el-color-success: #22c55e; + --el-color-success-dark-2: #86efac; + --el-color-success-light-3: #4ade80; + --el-color-success-light-5: rgba(34, 197, 94, 0.34); + --el-color-success-light-7: rgba(34, 197, 94, 0.22); + --el-color-success-light-9: rgba(34, 197, 94, 0.1); + --el-color-warning: #fbbf24; + --el-color-warning-dark-2: #fde68a; + --el-color-warning-light-3: #facc15; + --el-color-warning-light-5: rgba(251, 191, 36, 0.34); + --el-color-warning-light-7: rgba(251, 191, 36, 0.22); + --el-color-warning-light-9: rgba(251, 191, 36, 0.1); + --el-color-danger: #fb7185; + --el-color-danger-dark-2: #fecdd3; + --el-color-danger-light-3: #fda4af; + --el-color-danger-light-5: rgba(251, 113, 133, 0.34); + --el-color-danger-light-7: rgba(251, 113, 133, 0.22); + --el-color-danger-light-9: rgba(251, 113, 133, 0.1); + --el-color-info: #94a3b8; + --el-color-info-dark-2: #cbd5e1; + --el-color-info-light-3: #b6c2d1; + --el-color-info-light-5: rgba(148, 163, 184, 0.3); + --el-color-info-light-7: rgba(148, 163, 184, 0.2); + --el-color-info-light-9: rgba(148, 163, 184, 0.1); + --el-bg-color: #0c111d; + --el-bg-color-overlay: #121a2b; + --el-border-color: rgba(148, 163, 184, 0.18); + --el-border-color-light: rgba(148, 163, 184, 0.12); + --el-fill-color: rgba(148, 163, 184, 0.08); + --el-fill-color-light: rgba(148, 163, 184, 0.06); + --el-fill-color-lighter: rgba(148, 163, 184, 0.04); + --el-fill-color-blank: transparent; + --el-text-color-primary: var(--app-text); + --el-text-color-regular: #d7deeb; + --el-text-color-secondary: var(--app-muted); + font-family: + "Plus Jakarta Sans", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: var(--app-bg); + color: var(--app-text); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + min-width: 0; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: + radial-gradient(circle at 12% 8%, rgba(79, 140, 255, 0.22), transparent 28%), + radial-gradient(circle at 88% 18%, rgba(236, 72, 153, 0.16), transparent 30%), + linear-gradient(135deg, #070a12 0%, #101625 48%, #090d17 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +.page { + min-width: 0; + padding: 28px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.page-title { + margin: 0; + font-size: clamp(26px, 3vw, 38px); + font-weight: 760; + letter-spacing: 0; + line-height: 1.08; +} + +.page-subtitle { + max-width: 680px; + margin: 10px 0 0; + color: var(--app-muted); + font-size: 14px; + line-height: 1.65; +} + +.placeholder-panel { + border: 1px solid var(--app-border); + border-radius: 8px; + background: var(--app-card); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18); + padding: 20px; +} + +.placeholder-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.page-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + border: 1px solid rgba(45, 212, 191, 0.24); + border-radius: 999px; + background: rgba(45, 212, 191, 0.08); + padding: 6px 10px; + color: #9ff5e9; + font-size: 12px; + font-weight: 650; +} + +.metric-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.metric-card { + border: 1px solid var(--app-border); + border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.025)); + padding: 16px; +} + +.metric-card span { + display: block; + color: var(--app-muted); + font-size: 12px; +} + +.metric-card strong { + display: block; + margin-top: 8px; + font-size: 24px; +} + +.el-button { + --el-button-bg-color: rgba(9, 14, 25, 0.66); + --el-button-border-color: rgba(148, 163, 184, 0.24); + --el-button-text-color: #e8eefc; + --el-button-hover-bg-color: rgba(79, 140, 255, 0.15); + --el-button-hover-border-color: rgba(125, 211, 252, 0.42); + --el-button-hover-text-color: #ffffff; + --el-button-active-bg-color: rgba(79, 140, 255, 0.22); + --el-button-active-border-color: rgba(125, 211, 252, 0.56); + --el-button-active-text-color: #ffffff; + --el-button-disabled-bg-color: rgba(148, 163, 184, 0.08); + --el-button-disabled-border-color: rgba(148, 163, 184, 0.12); + --el-button-disabled-text-color: rgba(226, 232, 240, 0.42); + border-radius: 8px; + backdrop-filter: blur(14px); + font-weight: 650; + letter-spacing: 0; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.el-button:not(.is-disabled) { + cursor: pointer; +} + +.el-button--primary { + --el-button-text-color: #ffffff; + --el-button-hover-text-color: #ffffff; + --el-button-active-text-color: #ffffff; + border-color: rgba(125, 211, 252, 0.28); + background: linear-gradient(135deg, var(--app-primary), var(--app-primary-strong)); + box-shadow: 0 14px 32px rgba(79, 140, 255, 0.26); +} + +.el-button--primary:not(.is-disabled):hover, +.el-button--primary:not(.is-disabled):focus { + border-color: rgba(167, 243, 255, 0.48); + background: linear-gradient(135deg, #67a0ff, #8b6cff); + box-shadow: 0 18px 38px rgba(79, 140, 255, 0.34); +} + +.el-button.is-plain { + --el-button-bg-color: rgba(8, 12, 22, 0.5); + --el-button-border-color: rgba(148, 163, 184, 0.24); + --el-button-text-color: #dce8fb; + --el-button-hover-bg-color: rgba(79, 140, 255, 0.14); + --el-button-hover-border-color: rgba(125, 211, 252, 0.46); + --el-button-hover-text-color: #ffffff; +} + +.el-button--primary.is-plain { + --el-button-bg-color: rgba(79, 140, 255, 0.14); + --el-button-border-color: rgba(79, 140, 255, 0.42); + --el-button-text-color: #dbeafe; + --el-button-hover-bg-color: rgba(79, 140, 255, 0.24); + --el-button-hover-border-color: rgba(167, 243, 255, 0.48); + --el-button-hover-text-color: #ffffff; +} + +.el-button--primary.is-plain:not(.is-disabled):hover, +.el-button--primary.is-plain:not(.is-disabled):focus { + background: linear-gradient(135deg, rgba(79, 140, 255, 0.9), rgba(124, 92, 255, 0.9)); +} + +.el-button.is-text { + --el-button-bg-color: transparent; + --el-button-border-color: transparent; + --el-button-text-color: #cfe0ff; + --el-button-hover-bg-color: rgba(79, 140, 255, 0.13); + --el-button-hover-border-color: transparent; + --el-button-hover-text-color: #ffffff; + box-shadow: none; +} + +.el-button--success, +.el-button--warning, +.el-button--danger, +.el-button--info { + --el-button-text-color: #07101f; + --el-button-hover-text-color: #07101f; + --el-button-active-text-color: #07101f; +} + +.el-button--success.is-plain, +.el-button--warning.is-plain, +.el-button--danger.is-plain, +.el-button--info.is-plain { + --el-button-text-color: #f4f7fb; + --el-button-hover-text-color: #ffffff; + --el-button-active-text-color: #ffffff; +} + +.el-button--success.is-plain { + --el-button-bg-color: rgba(34, 197, 94, 0.1); + --el-button-border-color: rgba(34, 197, 94, 0.34); + --el-button-hover-bg-color: rgba(34, 197, 94, 0.24); + --el-button-hover-border-color: rgba(134, 239, 172, 0.58); +} + +.el-button--warning.is-plain { + --el-button-bg-color: rgba(251, 191, 36, 0.11); + --el-button-border-color: rgba(251, 191, 36, 0.36); + --el-button-hover-bg-color: rgba(251, 191, 36, 0.24); + --el-button-hover-border-color: rgba(253, 230, 138, 0.6); +} + +.el-button--danger.is-plain { + --el-button-bg-color: rgba(251, 113, 133, 0.11); + --el-button-border-color: rgba(251, 113, 133, 0.36); + --el-button-hover-bg-color: rgba(251, 113, 133, 0.24); + --el-button-hover-border-color: rgba(254, 205, 211, 0.6); +} + +.el-button--info.is-plain { + --el-button-bg-color: rgba(148, 163, 184, 0.1); + --el-button-border-color: rgba(148, 163, 184, 0.28); + --el-button-hover-bg-color: rgba(148, 163, 184, 0.2); + --el-button-hover-border-color: rgba(203, 213, 225, 0.46); +} + +.el-button:focus-visible { + outline: 2px solid rgba(125, 211, 252, 0.72); + outline-offset: 2px; +} + +.el-segmented { + --el-segmented-bg-color: rgba(8, 12, 22, 0.58); + --el-segmented-item-selected-bg-color: linear-gradient(135deg, rgba(79, 140, 255, 0.9), rgba(45, 212, 191, 0.74)); + --el-segmented-item-selected-color: #ffffff; + --el-segmented-item-hover-bg-color: rgba(79, 140, 255, 0.14); + --el-segmented-item-hover-color: #ffffff; + --el-segmented-item-active-bg-color: rgba(45, 212, 191, 0.18); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; + padding: 3px; + color: #dbe8fb; + backdrop-filter: blur(14px); +} + +.el-segmented__item { + border-radius: 6px; + color: #b9c7dc; + font-weight: 700; + transition: + background-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease; +} + +.el-segmented__item:not(.is-disabled) { + cursor: pointer; +} + +.el-segmented__item:not(.is-selected):hover { + background: rgba(79, 140, 255, 0.14); + color: #ffffff; +} + +.el-segmented__item.is-selected { + background: linear-gradient(135deg, rgba(79, 140, 255, 0.9), rgba(45, 212, 191, 0.74)); + color: #ffffff; + box-shadow: 0 10px 24px rgba(45, 212, 191, 0.18); +} + +.el-segmented__item.is-disabled { + color: rgba(226, 232, 240, 0.36); +} + +.el-input__wrapper, +.el-textarea__inner, +.el-select__wrapper { + border-radius: 8px; + background: rgba(8, 12, 22, 0.58); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14); +} + +.el-dialog { + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(15, 23, 42, 0.96); +} + +.el-table { + --el-table-bg-color: transparent; + --el-table-tr-bg-color: transparent; + --el-table-header-bg-color: rgba(79, 140, 255, 0.08); + --el-table-border-color: rgba(148, 163, 184, 0.12); + --el-table-row-hover-bg-color: rgba(79, 140, 255, 0.08); + border-radius: 8px; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} + +@media (max-width: 720px) { + .page { + padding: 18px; + } + + .page-header { + flex-direction: column; + } +} diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..e8470e1 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,381 @@ + + + + + diff --git a/src/views/WatchView.vue b/src/views/WatchView.vue new file mode 100644 index 0000000..7380ce9 --- /dev/null +++ b/src/views/WatchView.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/src/views/account/AccountView.vue b/src/views/account/AccountView.vue new file mode 100644 index 0000000..9043de0 --- /dev/null +++ b/src/views/account/AccountView.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/src/views/assets/AssetsView.vue b/src/views/assets/AssetsView.vue new file mode 100644 index 0000000..a0c470a --- /dev/null +++ b/src/views/assets/AssetsView.vue @@ -0,0 +1,700 @@ + + + + + diff --git a/src/views/assistant/AssistantView.vue b/src/views/assistant/AssistantView.vue new file mode 100644 index 0000000..94ac0ec --- /dev/null +++ b/src/views/assistant/AssistantView.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/src/views/characters/CharactersView.vue b/src/views/characters/CharactersView.vue new file mode 100644 index 0000000..1831d89 --- /dev/null +++ b/src/views/characters/CharactersView.vue @@ -0,0 +1,720 @@ + + + + + diff --git a/src/views/episodes/EpisodeStageView.vue b/src/views/episodes/EpisodeStageView.vue new file mode 100644 index 0000000..5ba07c0 --- /dev/null +++ b/src/views/episodes/EpisodeStageView.vue @@ -0,0 +1,1378 @@ + + + + + diff --git a/src/views/episodes/EpisodesView.vue b/src/views/episodes/EpisodesView.vue new file mode 100644 index 0000000..3ef792a --- /dev/null +++ b/src/views/episodes/EpisodesView.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/src/views/freezone/FreezoneView.vue b/src/views/freezone/FreezoneView.vue new file mode 100644 index 0000000..ce0d005 --- /dev/null +++ b/src/views/freezone/FreezoneView.vue @@ -0,0 +1,1245 @@ + + + + diff --git a/src/views/freezone/components/FreezoneCanvasPanel.vue b/src/views/freezone/components/FreezoneCanvasPanel.vue new file mode 100644 index 0000000..18ae8a7 --- /dev/null +++ b/src/views/freezone/components/FreezoneCanvasPanel.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/views/freezone/components/FreezoneDialogs.vue b/src/views/freezone/components/FreezoneDialogs.vue new file mode 100644 index 0000000..ba09f1b --- /dev/null +++ b/src/views/freezone/components/FreezoneDialogs.vue @@ -0,0 +1,491 @@ + + + diff --git a/src/views/freezone/components/FreezoneInspector.vue b/src/views/freezone/components/FreezoneInspector.vue new file mode 100644 index 0000000..c9ddcbf --- /dev/null +++ b/src/views/freezone/components/FreezoneInspector.vue @@ -0,0 +1,247 @@ + + + diff --git a/src/views/freezone/components/FreezoneLeftPanel.vue b/src/views/freezone/components/FreezoneLeftPanel.vue new file mode 100644 index 0000000..f6e317c --- /dev/null +++ b/src/views/freezone/components/FreezoneLeftPanel.vue @@ -0,0 +1,193 @@ + + + diff --git a/src/views/freezone/components/FreezoneToolbar.vue b/src/views/freezone/components/FreezoneToolbar.vue new file mode 100644 index 0000000..eebd979 --- /dev/null +++ b/src/views/freezone/components/FreezoneToolbar.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/views/freezone/freezone.config.js b/src/views/freezone/freezone.config.js new file mode 100644 index 0000000..190fcb8 --- /dev/null +++ b/src/views/freezone/freezone.config.js @@ -0,0 +1,427 @@ +import { + AudioLines, + Boxes, + BrainCircuit, + Clapperboard, + FileText, + Film, + Frame, + Home, + Image as ImageIcon, + Layers3, + MessageSquare, + Palette, + ScanEye, + SquareDashed, + Upload, + UserRound, + Video, + WandSparkles, +} from "lucide-vue-next"; + +export const nodeCatalog = [ + { + kind: "beatContextNode", + label: "Beat 上下文", + group: "主线", + icon: MessageSquare, + description: "带入剧集、镜头、角色、场景和道具上下文。", + }, + { + kind: "scriptNode", + label: "脚本文本", + group: "文本", + icon: FileText, + description: "整理旁白、对白、镜头目标和生成提示词。", + }, + { + kind: "imageGenNode", + label: "图像生成", + group: "生成", + icon: ImageIcon, + description: "根据提示词和参考资产生成分镜、封面或角色图。", + }, + { + kind: "imageNode", + label: "图像编辑", + group: "编辑", + icon: WandSparkles, + description: "对输入图做局部重绘、扩图、擦除或风格统一。", + }, + { + kind: "storyboardGenNode", + label: "故事板生成", + group: "生成", + icon: Frame, + description: "把文字剧情拆成连续镜头格。", + }, + { + kind: "videoStoryNode", + label: "视频故事", + group: "视频", + icon: Clapperboard, + description: "组织视频镜头的动作、运镜和时长。", + }, + { + kind: "videoNode", + label: "视频生成", + group: "视频", + icon: Video, + description: "基于首帧、尾帧和动作描述生成视频片段。", + }, + { + kind: "audioNode", + label: "配音 / 音频", + group: "音频", + icon: AudioLines, + description: "生成旁白、对白、环境音或背景音乐。", + }, + { + kind: "threeDWorldNode", + label: "导演世界", + group: "空间", + icon: Layers3, + description: "承载 3D 场景、360 全景和导演控制素材。", + }, + { + kind: "panoCaptureNode", + label: "全景捕获", + group: "空间", + icon: ScanEye, + description: "从场景或导演世界中截取全景参考、机位和空间状态。", + }, + { + kind: "skillNode", + label: "能力节点", + group: "能力", + icon: BrainCircuit, + description: "用于人像参考、场景修复、抠图、扩图等专项能力。", + }, + { + kind: "assetBridgeNode", + label: "资产桥接", + group: "能力", + icon: Boxes, + description: "把跨项目资产、角色、场景或道具整理成当前项目可用输入。", + }, + { + kind: "videoComposeNode", + label: "成片合成", + group: "发布", + icon: Film, + description: "把视频、音频、字幕和封面合成为可分享版本。", + }, + { + kind: "uploadNode", + label: "上传素材", + group: "输入", + icon: Upload, + description: "导入本地图片、视频、音频或模型资产。", + }, +]; + +export const nodeTheme = { + beatContextNode: { icon: MessageSquare, accent: "#2dd4bf", label: "上下文" }, + scriptNode: { icon: FileText, accent: "#a78bfa", label: "脚本" }, + imageGenNode: { icon: ImageIcon, accent: "#4f8cff", label: "生图" }, + imageNode: { icon: WandSparkles, accent: "#ec4899", label: "编辑" }, + storyboardGenNode: { icon: Frame, accent: "#f59e0b", label: "故事板" }, + videoStoryNode: { icon: Clapperboard, accent: "#38bdf8", label: "视频故事" }, + videoNode: { icon: Video, accent: "#22c55e", label: "视频" }, + audioNode: { icon: AudioLines, accent: "#f97316", label: "音频" }, + threeDWorldNode: { icon: Layers3, accent: "#8b5cf6", label: "3D" }, + panoCaptureNode: { icon: ScanEye, accent: "#7dd3fc", label: "全景" }, + skillNode: { icon: BrainCircuit, accent: "#06b6d4", label: "能力" }, + assetBridgeNode: { icon: Boxes, accent: "#14b8a6", label: "桥接" }, + videoComposeNode: { icon: Film, accent: "#f43f5e", label: "合成" }, + uploadNode: { icon: Upload, accent: "#94a3b8", label: "上传" }, + assetNode: { icon: Boxes, accent: "#2dd4bf", label: "资产" }, +}; + +export const baseCommitTargetOptions = [ + { label: "分镜图 frame", value: "frame" }, + { label: "视频片段 video", value: "video" }, + { label: "镜头音频 beat_audio", value: "beat_audio" }, + { label: "角色头像 portrait", value: "portrait" }, + { label: "身份形象 identity_portrait", value: "identity_portrait" }, + { label: "场景主图 scene_master", value: "scene_master" }, + { label: "道具参考 prop_ref", value: "prop_ref" }, + { label: "导演世界 scene_director_world", value: "scene_director_world" }, + { label: "成片 compose", value: "compose" }, +]; + +export const cameraMovementOptions = ["轻推", "慢拉", "横移", "环绕", "手持", "固定"]; +export const imageToolOptions = ["局部重绘", "扩图", "擦除", "风格统一", "清晰增强"]; +export const voiceOptions = ["旁白男声", "旁白女声", "少年音", "温柔女声", "低沉男声"]; +export const emotionOptions = ["克制", "紧张", "温柔", "悬疑", "热血"]; +export const composeTrackOptions = ["视频轨", "配音轨", "字幕轨", "音效轨", "封面"]; + +export const assetTabs = [ + { id: "beat", label: "Beat" }, + { id: "characters", label: "人物" }, + { id: "scenes", label: "场景" }, + { id: "props", label: "道具" }, +]; + +export function defaultNodes(canvasId = "default") { + if (canvasId.includes("beat")) { + return [ + makeNode("beat-context-1", "beatContextNode", "EP1 / Beat 1 上下文", 160, 120, { + description: "雨夜入城,王都城门,主角携旧信进入故事。", + target: { kind: "beat_context", episode: 1, beat: 1 }, + context: ["雨夜", "王都城门", "旧信", "悬疑"], + }), + makeNode("image-gen-1", "imageGenNode", "生成当前分镜", 480, 100, { + prompt: "黑云压城,雨夜王都城门,少年怀抱旧信,电影感远景。", + model: "Seedream 4.0", + ratio: "16:9", + target: { kind: "frame", episode: 1, beat: 1 }, + pushable: true, + }), + makeNode("video-1", "videoNode", "镜头视频", 820, 120, { + prompt: "镜头从雨幕推向少年,城门灯火在地面积水中拉长。", + model: "Seedance 2.0", + duration: 5, + target: { kind: "video", episode: 1, beat: 1 }, + pushable: true, + }), + ]; + } + return [ + makeNode("beat-context-1", "beatContextNode", "第 1 集主线上下文", 120, 120, { + description: "汇总小说章节、角色、场景和当前分镜产物。", + context: ["夜雨入城", "沈夜", "柳听澜", "铜镜"], + }), + makeNode("script-1", "scriptNode", "脚本整理", 420, 80, { + prompt: "把本集拆成 6 个强钩子镜头,保留悬疑和东方奇幻气质。", + model: "Story Planner", + }), + makeNode("image-gen-1", "imageGenNode", "关键画面生成", 720, 90, { + prompt: "国风古装电影感,雨夜旧宅,青灯照亮铜镜。", + model: "Seedream 4.0", + ratio: "16:9", + target: { kind: "frame", episode: 1, beat: 2 }, + pushable: true, + }), + makeNode("world-1", "threeDWorldNode", "旧宅导演世界", 720, 340, { + description: "记录旧宅空间、灯位、镜头机位和全景参考。", + model: "Director World", + target: { kind: "scene_director_world", episode: 1 }, + pushable: true, + }), + makeNode("audio-1", "audioNode", "旁白与对白", 1020, 350, { + prompt: "旁白低沉,师姐声线虚弱但克制。", + model: "IndexTTS2", + target: { kind: "beat_audio", episode: 1, beat: 2 }, + pushable: true, + }), + makeNode("video-1", "videoNode", "镜头视频生成", 1040, 110, { + prompt: "铜镜泛起水纹,镜中出现白衣师姐的模糊轮廓。", + model: "Seedance 2.0", + duration: 6, + target: { kind: "video", episode: 1, beat: 2 }, + pushable: true, + }), + makeNode("compose-1", "videoComposeNode", "成片合成", 1360, 220, { + description: "合并视频片段、配音、字幕和封面,产出试看版本。", + target: { kind: "compose", episode: 1 }, + pushable: true, + }), + ]; +} + +export function defaultEdgesForCanvas(canvasId = "default") { + if (canvasId.includes("beat")) { + return [ + { id: "beat-image", source: "beat-context-1", target: "image-gen-1" }, + { id: "image-video", source: "image-gen-1", target: "video-1" }, + ]; + } + return [ + { id: "ctx-script", source: "beat-context-1", target: "script-1" }, + { id: "script-image", source: "script-1", target: "image-gen-1" }, + { id: "image-world", source: "image-gen-1", target: "world-1" }, + { id: "image-video", source: "image-gen-1", target: "video-1" }, + { id: "world-video", source: "world-1", target: "video-1" }, + { id: "audio-compose", source: "audio-1", target: "compose-1" }, + { id: "video-compose", source: "video-1", target: "compose-1" }, + ]; +} + +export function makeNode(id, kind, label, x, y, data = {}) { + return { + id, + type: "freezone", + position: { x, y }, + data: { + label, + kind, + description: nodeCatalog.find((item) => item.kind === kind)?.description || data.description || "Freezone 创作节点", + status: data.status || "ready", + generationHistory: [], + previewUrl: "", + resultLabel: "", + ...data, + toolSettings: { + ...defaultToolSettings(kind, data), + ...(data.toolSettings || {}), + }, + }, + }; +} + +export function defaultToolSettings(kind, data = {}) { + if (kind === "imageGenNode" || kind === "imageNode" || data.mediaType === "image") { + return { + operation: kind === "imageNode" ? "局部重绘" : "风格统一", + strength: 62, + styleLock: true, + seed: "", + negativePrompt: "低清晰度、畸形手、文字水印", + }; + } + if (kind === "videoNode" || kind === "videoStoryNode") { + return { + camera: "电影镜头", + movement: "轻推", + duration: data.duration || 6, + fps: 24, + motionStrength: 58, + }; + } + if (kind === "audioNode") { + return { + voice: "旁白男声", + emotion: "克制", + speed: 1, + musicDuck: true, + }; + } + if (kind === "threeDWorldNode" || kind === "panoCaptureNode") { + return { + cameraHeight: 1.6, + lens: "35mm", + captureMode: kind === "panoCaptureNode" ? "360 全景" : "导演世界", + lighting: "电影布光", + }; + } + if (kind === "skillNode" || kind === "assetBridgeNode") { + return { + skill: kind === "assetBridgeNode" ? "跨项目资产桥接" : "智能抠图", + confidence: 75, + preserveIdentity: true, + outputType: "项目素材", + }; + } + if (kind === "videoComposeNode") { + return { + tracks: ["视频轨", "配音轨", "字幕轨"], + subtitleStyle: "电影字幕", + coverFrame: "首个关键画面", + exportRatio: data.ratio || "16:9", + }; + } + if (kind === "uploadNode") { + return { + accept: "image/*,video/*,audio/*", + fileName: data.fileName || "", + fileSize: data.fileSize || 0, + mediaType: data.mediaType || "", + previewUrl: data.previewUrl || "", + }; + } + return { + note: "", + priority: "标准", + }; +} + +export function canvasIcon(canvas) { + if (canvas?.canvas_scope === "default") return Home; + if (canvas?.canvas_scope === "episode") return Film; + if (canvas?.canvas_scope === "beat") return Frame; + if (canvas?.metadata?.canvas_origin === "personal") return UserRound; + return SquareDashed; +} + +export function canvasLabel(canvas) { + return canvas?.display_name || canvas?.metadata?.display_name || canvas?.id || "未命名画布"; +} + +export function assetIcon(asset) { + if (asset.kind === "beat_context") return MessageSquare; + if (asset.kind === "style") return Palette; + if (asset.kind === "video") return Video; + if (asset.kind === "audio") return AudioLines; + if (asset.kind === "director" || asset.role === "scene_director_world") return Layers3; + if (asset.tab === "characters") return UserRound; + if (asset.tab === "scenes") return Layers3; + if (asset.tab === "props") return Boxes; + return ImageIcon; +} + +export function nodeIcon(kind) { + return nodeTheme[kind]?.icon || Boxes; +} + +export function nodeAccent(kind) { + return nodeTheme[kind]?.accent || "#4f8cff"; +} + +export function kindForAsset(asset) { + if (!asset) return "assetNode"; + if (asset.scope === "cross_project") return "assetBridgeNode"; + if (asset.kind === "beat_context" || asset.kind === "episode") return "beatContextNode"; + if (asset.media_type === "video") return "videoNode"; + if (asset.media_type === "audio") return "audioNode"; + if (asset.kind === "director" || asset.role === "scene_director_world") return "threeDWorldNode"; + if (asset.kind === "style") return "scriptNode"; + if (asset.kind === "frame" || asset.media_type === "image") return "imageNode"; + return "assetNode"; +} + +export function defaultModelForKind(kind) { + return { + imageGenNode: "Seedream 4.0", + imageNode: "Image Edit", + videoNode: "Seedance 2.0", + audioNode: "IndexTTS2", + storyboardGenNode: "Storyboard Planner", + threeDWorldNode: "Director World", + panoCaptureNode: "Pano Capture", + skillNode: "Smart Skill", + assetBridgeNode: "Asset Bridge", + videoComposeNode: "Compose Engine", + }[kind] || "Mock Model"; +} + +export function toolProfileForNode(node) { + const kind = node?.data?.kind; + if (kind === "imageGenNode" || kind === "imageNode" || node?.data?.mediaType === "image") return "image"; + if (kind === "videoNode" || kind === "videoStoryNode") return "video"; + if (kind === "audioNode") return "audio"; + if (kind === "videoComposeNode") return "compose"; + if (kind === "uploadNode") return "upload"; + if (kind === "threeDWorldNode" || kind === "panoCaptureNode") return "space"; + if (kind === "skillNode" || kind === "assetBridgeNode") return "skill"; + return "basic"; +} + +export function previewKindForNode(node) { + if (!node) return "empty"; + if (node.data.mediaType === "audio" || node.data.kind === "audioNode") return "audio"; + if (node.data.mediaType === "video" || node.data.kind === "videoNode" || node.data.kind === "videoComposeNode") return "video"; + if (node.data.kind === "threeDWorldNode" || node.data.kind === "panoCaptureNode") return "space"; + if (node.data.mediaType === "image" || ["imageGenNode", "imageNode", "storyboardGenNode"].includes(node.data.kind)) return "image"; + return "text"; +} + +export function defaultTargetForKind(kind, asset) { + if (asset?.meta?.episode && asset?.meta?.beat) { + if (kind === "videoNode") return { kind: "video", episode: asset.meta.episode, beat: asset.meta.beat }; + if (kind === "audioNode") return { kind: "beat_audio", episode: asset.meta.episode, beat: asset.meta.beat }; + return { kind: "frame", episode: asset.meta.episode, beat: asset.meta.beat }; + } + if (kind === "threeDWorldNode") return { kind: "scene_director_world" }; + return null; +} diff --git a/src/views/freezone/freezone.scss b/src/views/freezone/freezone.scss new file mode 100644 index 0000000..1c0e538 --- /dev/null +++ b/src/views/freezone/freezone.scss @@ -0,0 +1,1466 @@ +.freezone-page { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + flex-direction: column; + background: + radial-gradient(circle at 15% 10%, rgba(79, 140, 255, 0.2), transparent 30%), + radial-gradient(circle at 80% 18%, rgba(236, 72, 153, 0.14), transparent 28%), + #070a12; +} + +.freezone-toolbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto minmax(360px, auto); + align-items: center; + gap: 16px; + z-index: 2; + border-bottom: 1px solid var(--app-border); + background: rgba(7, 10, 18, 0.76); + padding: 12px 16px; + backdrop-filter: blur(18px); +} + +.toolbar-title { + display: flex; + align-items: center; + gap: 12px; + + h1 { + margin: 0; + font-size: 18px; + } + + p { + margin: 4px 0 0; + color: var(--app-muted); + font-size: 12px; + } +} + +.title-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 8px; + background: linear-gradient(135deg, rgba(79, 140, 255, 0.85), rgba(236, 72, 153, 0.65)); +} + +.toolbar-center { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--app-border); + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + padding: 7px 12px; + + span { + font-weight: 800; + } + + small { + color: var(--app-muted); + } + + em { + border-radius: 999px; + padding: 3px 7px; + font-size: 11px; + font-style: normal; + font-weight: 800; + + &.synced { + background: rgba(34, 197, 94, 0.12); + color: #a7f3b8; + } + + &.pending, + &.dirty { + background: rgba(245, 158, 11, 0.14); + color: #fde68a; + } + + &.failed { + background: rgba(248, 113, 113, 0.14); + color: #fecaca; + } + } +} + +.toolbar-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.freezone-workbench { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: 320px minmax(0, 1fr) 340px; + + &.collapsed { + grid-template-columns: 0 minmax(0, 1fr) 340px; + + .left-panel { + overflow: hidden; + padding: 0; + border-right: 0; + } + } +} + +.left-panel, +.right-panel { + min-height: 0; + overflow: auto; + border-color: var(--app-border); + background: rgba(9, 13, 24, 0.8); + padding: 14px; + backdrop-filter: blur(18px); +} + +.left-panel { + border-right: 1px solid var(--app-border); +} + +.right-panel { + border-left: 1px solid var(--app-border); +} + +.left-switch { + width: 100%; + margin-bottom: 12px; +} + +.scope-switch { + width: 100%; +} + +.panel-title, +.asset-group-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + color: var(--app-muted); + font-size: 12px; + font-weight: 800; +} + +.create-canvas { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 12px; +} + +.canvas-row, +.asset-row, +.tool-button { + width: 100%; + cursor: pointer; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + margin-bottom: 8px; + padding: 10px; + color: #dbe8fb; + text-align: left; + transition: border-color 180ms ease, background 180ms ease, opacity 180ms ease; + + &:hover, + &.active { + border-color: var(--app-border-strong); + background: rgba(79, 140, 255, 0.12); + } +} + +.canvas-row, +.asset-row, +.tool-button { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + + strong, + small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + margin-top: 3px; + color: var(--app-muted); + font-size: 11px; + } +} + +.tool-button { + grid-template-columns: 24px minmax(0, 1fr); +} + +.asset-library { + display: flex; + min-height: 0; + flex-direction: column; + gap: 12px; +} + +.asset-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + + button { + cursor: pointer; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 8px 4px; + color: var(--app-muted); + font-size: 12px; + font-weight: 800; + + small { + display: block; + margin-top: 3px; + font-weight: 700; + } + + &.active { + border-color: var(--app-border-strong); + color: #fff; + background: rgba(45, 212, 191, 0.12); + } + } +} + +.asset-groups { + min-height: 0; + overflow: auto; +} + +.history-panel { + margin-top: 18px; + border-top: 1px solid var(--app-border); + padding-top: 14px; +} + +.history-row { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.1); + padding: 8px 0; + align-items: center; + + span { + color: var(--app-cyan); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + } + + strong { + color: #dbe8fb; + font-size: 12px; + } + + small { + grid-column: 2; + color: var(--app-muted); + font-size: 11px; + } + + .el-button { + grid-row: 1 / span 2; + grid-column: 3; + } +} + +.empty-note { + border: 1px dashed var(--app-border); + border-radius: 8px; + padding: 12px; + color: var(--app-muted); + font-size: 12px; + text-align: center; +} + +.asset-thumb { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border-radius: 6px; + background: rgba(79, 140, 255, 0.12); + color: #cfe0ff; +} + +.canvas-panel { + position: relative; + min-width: 0; + min-height: 0; + + &.dragging::after { + position: absolute; + inset: 14px; + z-index: 10; + pointer-events: none; + border: 1px dashed rgba(45, 212, 191, 0.72); + border-radius: 8px; + background: rgba(45, 212, 191, 0.06); + content: "拖到这里创建节点,拖到已有节点上替换输入"; + display: grid; + place-items: center; + color: #a7f3ff; + font-size: 13px; + font-weight: 800; + backdrop-filter: blur(2px); + } +} + +.canvas-loading { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(7, 10, 18, 0.56); + color: #dbe8fb; + font-size: 13px; + backdrop-filter: blur(4px); +} + +.freezone-flow { + width: 100%; + height: 100%; +} + +.fz-node { + width: 244px; + border: 1px solid color-mix(in srgb, var(--node-accent) 46%, transparent); + border-radius: 8px; + background: + linear-gradient(150deg, color-mix(in srgb, var(--node-accent) 18%, transparent), transparent 50%), + rgba(12, 18, 32, 0.95); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28); + padding: 12px; + color: #eef5ff; + + &.selected { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--node-accent) 20%, transparent), 0 18px 42px rgba(0, 0, 0, 0.32); + } + + &.batch-selected { + outline: 1px solid rgba(45, 212, 191, 0.72); + outline-offset: 3px; + } + + &.committed { + border-color: rgba(34, 197, 94, 0.58); + } + + p { + display: -webkit-box; + overflow: hidden; + min-height: 42px; + margin: 10px 0; + color: #b8c4d8; + font-size: 12px; + line-height: 1.55; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +.fz-node-result { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + align-items: center; + gap: 6px; + margin-bottom: 8px; + border-radius: 8px; + background: rgba(45, 212, 191, 0.1); + padding: 6px 7px; + color: #bffbf3; + font-size: 11px; + font-weight: 800; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.fz-node-head { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; + + strong, + small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + margin-top: 3px; + color: var(--app-muted); + font-size: 11px; + } +} + +.fz-node-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 8px; + background: color-mix(in srgb, var(--node-accent) 24%, transparent); + color: #fff; +} + +.fz-node-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + + span { + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + padding: 4px 7px; + color: #dce8f8; + font-size: 11px; + } +} + +.canvas-status { + position: absolute; + left: 16px; + bottom: 16px; + display: flex; + gap: 8px; + pointer-events: none; + + span { + border: 1px solid var(--app-border); + border-radius: 999px; + background: rgba(8, 12, 22, 0.72); + padding: 6px 10px; + color: var(--app-muted); + font-size: 12px; + backdrop-filter: blur(14px); + } +} + +.viewport-bookmarks { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 12; + display: flex; + align-items: center; + gap: 5px; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(8, 12, 22, 0.78); + padding: 6px; + backdrop-filter: blur(14px); + + button { + display: grid; + min-width: 26px; + height: 26px; + cursor: pointer; + place-items: center; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 6px; + background: rgba(255, 255, 255, 0.045); + color: rgba(219, 232, 251, 0.62); + font-size: 12px; + font-weight: 800; + + &:hover, + &.filled { + border-color: rgba(45, 212, 191, 0.38); + color: #e8fbff; + background: rgba(45, 212, 191, 0.12); + } + + &.active { + border-color: rgba(255, 255, 255, 0.72); + background: rgba(255, 255, 255, 0.92); + color: #07101f; + } + } + + .bookmark-clear { + width: auto; + padding: 0 8px; + color: var(--app-muted); + } +} + +.batch-toolbar { + position: absolute; + left: 50%; + bottom: 16px; + z-index: 12; + display: flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); + border: 1px solid rgba(45, 212, 191, 0.28); + border-radius: 8px; + background: rgba(8, 12, 22, 0.86); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.34); + padding: 8px; + backdrop-filter: blur(16px); + + strong { + margin: 0 6px; + color: #dff8ff; + font-size: 12px; + white-space: nowrap; + } +} + +.node-summary { + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 14px; + + span { + color: var(--app-cyan); + font-size: 12px; + font-weight: 800; + } + + h2 { + margin: 8px 0; + font-size: 21px; + } + + p { + color: var(--app-muted); + line-height: 1.6; + } +} + +.inspector-tabs { + margin-top: 12px; +} + +.field-stack { + display: grid; + gap: 12px; + + label span { + display: block; + margin-bottom: 6px; + color: var(--app-muted); + font-size: 12px; + font-weight: 800; + } +} + +.context-row { + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--app-border); + padding: 10px 0; + color: #dce8f8; +} + +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.special-panel { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + margin-top: 14px; + border: 1px solid rgba(45, 212, 191, 0.24); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(45, 212, 191, 0.09), rgba(79, 140, 255, 0.07)), + rgba(255, 255, 255, 0.035); + padding: 12px; + + > svg { + color: var(--app-cyan); + } + + strong, + p { + display: block; + margin: 0; + } + + p { + margin-top: 5px; + color: var(--app-muted); + font-size: 12px; + line-height: 1.55; + } + + .el-button { + grid-column: 1 / -1; + } +} + +.action-panel, +.commit-box, +.tool-summary { + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 16px; + text-align: center; + + h3 { + margin: 10px 0 6px; + } + + p { + color: var(--app-muted); + line-height: 1.6; + } +} + +.tool-summary { + display: grid; + place-items: center; + gap: 8px; + + svg { + color: var(--app-cyan); + } +} + +.commit-box { + text-align: left; + + span, + strong { + display: block; + } + + span { + color: var(--app-muted); + font-size: 12px; + font-weight: 800; + } + + strong { + margin-top: 7px; + color: #fff; + } +} + +.result-preview { + margin-top: 12px; +} + +.result-frame { + overflow: hidden; + border: 1px solid rgba(79, 140, 255, 0.26); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(45, 212, 191, 0.14), rgba(79, 140, 255, 0.1) 48%, rgba(236, 72, 153, 0.1)), + rgba(255, 255, 255, 0.045); + aspect-ratio: 16 / 9; + + img, + > div { + width: 100%; + height: 100%; + } + + img { + display: block; + object-fit: cover; + } + + > div { + display: grid; + place-items: center; + align-content: center; + gap: 8px; + color: #eaf6ff; + text-align: center; + + strong, + span { + max-width: 88%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--app-muted); + font-size: 12px; + } + } +} + +.generation-history { + display: grid; + gap: 10px; +} + +.generation-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 10px; + + strong, + span, + small { + display: block; + } + + strong { + overflow: hidden; + color: #fff; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; + } + + span, + small, + p { + color: var(--app-muted); + font-size: 11px; + } + + p { + grid-column: 1 / -1; + display: -webkit-box; + overflow: hidden; + margin: 4px 0 0; + line-height: 1.5; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +.side-action, +.node-actions { + width: 100%; + margin-top: 12px; +} + +.node-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + + .el-button { + width: 100%; + min-width: 0; + justify-content: center; + margin-left: 0; + } +} + +.replace-dialog { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 12px; + align-items: start; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 14px; + + .asset-thumb { + width: 42px; + height: 42px; + } + + strong { + color: #fff; + } + + p { + margin: 6px 0 0; + color: var(--app-muted); + line-height: 1.6; + } +} + +.commit-dialog { + display: grid; + gap: 14px; +} + +.commit-source { + border: 1px solid var(--app-border); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(79, 140, 255, 0.12), transparent 50%), + rgba(255, 255, 255, 0.045); + padding: 14px; + + span { + color: var(--app-cyan); + font-size: 12px; + font-weight: 800; + } + + strong { + display: block; + margin-top: 6px; + color: #fff; + font-size: 18px; + } + + p { + margin: 8px 0 0; + color: var(--app-muted); + line-height: 1.6; + } +} + +.commit-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.batch-commit-dialog { + display: grid; + gap: 10px; + max-height: 58vh; + overflow: auto; + padding-right: 2px; +} + +.batch-commit-row { + display: grid; + grid-template-columns: minmax(150px, 1.2fr) minmax(150px, 1fr) 104px 104px minmax(140px, 1fr); + align-items: center; + gap: 8px; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 10px; +} + +.batch-node-info { + min-width: 0; + + span, + strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--app-cyan); + font-size: 11px; + font-weight: 800; + } + + strong { + margin-top: 4px; + color: #fff; + font-size: 13px; + } +} + +.node-tool-dialog { + display: grid; + gap: 14px; +} + +.tool-dialog-head { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 12px; + align-items: start; + border: 1px solid var(--app-border); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(45, 212, 191, 0.1), transparent 55%), + rgba(255, 255, 255, 0.045); + padding: 12px; + + .fz-node-icon { + width: 42px; + height: 42px; + } + + strong { + display: block; + color: #fff; + font-size: 16px; + } + + p { + display: -webkit-box; + overflow: hidden; + margin: 6px 0 0; + color: var(--app-muted); + line-height: 1.55; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +.tool-editor { + display: grid; + gap: 14px; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); + padding: 14px; + + label > span { + display: block; + margin-bottom: 7px; + color: var(--app-muted); + font-size: 12px; + font-weight: 800; + } +} + +.space-tool-head { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 12px; + align-items: start; + border: 1px solid rgba(45, 212, 191, 0.2); + border-radius: 8px; + background: rgba(45, 212, 191, 0.06); + padding: 12px; + + svg { + color: var(--app-cyan); + } + + strong { + display: block; + color: #fff; + } + + p { + margin: 5px 0 0; + color: var(--app-muted); + font-size: 12px; + line-height: 1.55; + } +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + + button { + cursor: pointer; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 8px 10px; + color: #dbe8fb; + font-size: 12px; + font-weight: 800; + + &:hover, + &.active { + border-color: rgba(45, 212, 191, 0.42); + background: rgba(45, 212, 191, 0.12); + color: #ecfeff; + } + } +} + +.tool-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.compose-timeline { + display: grid; + gap: 10px; + border: 1px solid rgba(79, 140, 255, 0.2); + border-radius: 8px; + background: rgba(8, 12, 22, 0.52); + padding: 12px; +} + +.timeline-head { + display: flex; + align-items: center; + justify-content: space-between; + + strong { + color: #fff; + } + + span { + color: var(--app-cyan); + font-size: 12px; + font-weight: 800; + } +} + +.timeline-rows { + display: grid; + gap: 8px; +} + +.timeline-row { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 9px; + + span { + border-radius: 999px; + background: rgba(79, 140, 255, 0.14); + padding: 3px 7px; + color: #cfe0ff; + font-size: 11px; + font-weight: 800; + text-align: center; + } + + strong { + overflow: hidden; + color: #fff; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + color: var(--app-muted); + font-size: 11px; + } + + &.audio span { + background: rgba(249, 115, 22, 0.16); + color: #fed7aa; + } + + &.visual span { + background: rgba(45, 212, 191, 0.14); + color: #bffbf3; + } +} + +.upload-input { + width: 100%; + cursor: pointer; + border: 1px dashed rgba(45, 212, 191, 0.32); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 10px; + color: #dbe8fb; +} + +.upload-preview { + display: grid; + gap: 10px; +} + +.upload-stage, +.upload-empty { + overflow: hidden; + border: 1px solid rgba(79, 140, 255, 0.24); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(45, 212, 191, 0.12), rgba(79, 140, 255, 0.1), rgba(236, 72, 153, 0.1)), + #080d18; + min-height: 220px; +} + +.upload-stage { + img, + video, + audio, + > div { + width: 100%; + height: 100%; + min-height: 220px; + } + + img, + video { + display: block; + object-fit: cover; + } + + audio { + padding: 48px; + } + + > div { + display: grid; + place-items: center; + align-content: center; + gap: 8px; + color: #eaf6ff; + } +} + +.upload-empty { + display: grid; + place-items: center; + align-content: center; + gap: 10px; + color: #eaf6ff; + text-align: center; + + svg { + color: var(--app-cyan); + } + + span { + max-width: 320px; + color: var(--app-muted); + font-size: 12px; + } +} + +.upload-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + + p { + margin: 0; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 9px; + } + + strong, + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--app-cyan); + font-size: 11px; + } + + span { + margin-top: 4px; + color: #dbe8fb; + font-size: 12px; + } +} + +.media-preview-dialog { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(220px, 0.7fr); + gap: 14px; +} + +.preview-stage { + overflow: hidden; + position: relative; + border: 1px solid rgba(79, 140, 255, 0.26); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(45, 212, 191, 0.12), rgba(79, 140, 255, 0.1), rgba(236, 72, 153, 0.1)), + #080d18; + aspect-ratio: 16 / 9; + + img, + video, + audio, + > div { + width: 100%; + height: 100%; + } + + img, + video { + display: block; + object-fit: cover; + } + + audio { + padding: 40px; + } + + > div { + display: grid; + place-items: center; + align-content: center; + gap: 10px; + color: #eaf6ff; + text-align: center; + + svg { + color: var(--app-cyan); + } + + strong, + span { + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--app-muted); + font-size: 12px; + } + } +} + +.space-preview-scene { + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + background: + radial-gradient(circle at 72% 20%, rgba(45, 212, 191, 0.24), transparent 20%), + linear-gradient(180deg, rgba(15, 23, 42, 0.1), rgba(8, 13, 24, 0.96)); +} + +.space-sky { + position: absolute; + inset: 0 0 38%; +} + +.space-sun { + position: absolute; + top: 20%; + right: 22%; + width: 46px; + height: 46px; + border-radius: 50%; + background: rgba(45, 212, 191, 0.72); + box-shadow: 0 0 42px rgba(45, 212, 191, 0.46); +} + +.space-grid-line { + position: absolute; + left: 12%; + width: 76%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(125, 211, 252, 0.34), transparent); + + &.line-a { + top: 34%; + transform: rotate(-8deg); + } + + &.line-b { + top: 48%; + transform: rotate(6deg); + } + + &.line-c { + top: 62%; + transform: rotate(-2deg); + } +} + +.space-camera { + position: absolute; + right: 34%; + bottom: -12px; + width: 22px; + height: 22px; + border: 2px solid rgba(255, 255, 255, 0.78); + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + background: rgba(79, 140, 255, 0.22); +} + +.space-floor { + position: absolute; + inset: 58% -12% -16%; + display: grid; + grid-template-columns: repeat(7, 1fr); + transform: perspective(420px) rotateX(58deg); + transform-origin: top; + border-top: 1px solid rgba(125, 211, 252, 0.35); + background: + repeating-linear-gradient(0deg, rgba(125, 211, 252, 0.18) 0 1px, transparent 1px 28px), + rgba(45, 212, 191, 0.05); + + span { + border-left: 1px solid rgba(125, 211, 252, 0.16); + } +} + +.space-preview-copy { + position: absolute; + left: 18px; + bottom: 16px; + display: grid; + gap: 5px; + max-width: 70%; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; + background: rgba(8, 12, 22, 0.64); + padding: 12px; + color: #eaf6ff; + backdrop-filter: blur(12px); + + svg { + color: var(--app-cyan); + } + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + color: var(--app-muted); + } +} + +.preview-meta { + display: grid; + gap: 8px; + + p { + margin: 0; + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + padding: 10px; + } + + strong, + span { + display: block; + } + + strong { + color: var(--app-cyan); + font-size: 11px; + } + + span { + display: -webkit-box; + overflow: hidden; + margin-top: 5px; + color: #dbe8fb; + font-size: 12px; + line-height: 1.5; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } +} + +.shortcut-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.shortcut-group { + border: 1px solid var(--app-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + padding: 14px; + + strong { + display: block; + margin-bottom: 10px; + color: var(--app-cyan); + } + + p { + display: flex; + align-items: center; + gap: 6px; + margin: 8px 0; + color: #dbe8fb; + font-size: 12px; + } + + kbd { + min-width: 24px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + padding: 3px 6px; + color: #fff; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + text-align: center; + } + + span { + margin-left: auto; + color: var(--app-muted); + text-align: right; + } +} + +:deep(.vue-flow__edge-path) { + stroke: rgba(79, 140, 255, 0.82); + stroke-width: 2; +} + +@media (max-width: 1180px) { + .freezone-toolbar { + grid-template-columns: 1fr; + } + + .toolbar-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .freezone-workbench { + grid-template-columns: 280px minmax(0, 1fr); + + .right-panel { + display: none; + } + } +} diff --git a/src/views/ingest/IngestView.vue b/src/views/ingest/IngestView.vue new file mode 100644 index 0000000..e6457a3 --- /dev/null +++ b/src/views/ingest/IngestView.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/src/views/projects/ProjectDashboardView.vue b/src/views/projects/ProjectDashboardView.vue new file mode 100644 index 0000000..bbe7ac2 --- /dev/null +++ b/src/views/projects/ProjectDashboardView.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/src/views/projects/ProjectOverviewView.vue b/src/views/projects/ProjectOverviewView.vue new file mode 100644 index 0000000..08066cc --- /dev/null +++ b/src/views/projects/ProjectOverviewView.vue @@ -0,0 +1,670 @@ + + + + + diff --git a/src/views/styles/StylesView.vue b/src/views/styles/StylesView.vue new file mode 100644 index 0000000..1564187 --- /dev/null +++ b/src/views/styles/StylesView.vue @@ -0,0 +1,720 @@ + + + + + diff --git a/src/views/tasks/TasksView.vue b/src/views/tasks/TasksView.vue new file mode 100644 index 0000000..6561385 --- /dev/null +++ b/src/views/tasks/TasksView.vue @@ -0,0 +1,710 @@ + + + + + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..d06584d --- /dev/null +++ b/vite.config.js @@ -0,0 +1,46 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig, loadEnv } from "vite"; +import vue from "@vitejs/plugin-vue"; + +const DEFAULT_API_TARGET = "http://127.0.0.1:8780"; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const apiTarget = env.VITE_API_URL || DEFAULT_API_TARGET; + + return { + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + server: { + host: true, + port: 5174, + proxy: { + "/api/v1": { + target: apiTarget, + changeOrigin: true, + ws: true, + configure: (proxy) => { + proxy.on("proxyRes", (proxyRes) => { + const setCookie = proxyRes.headers["set-cookie"]; + if (!setCookie) return; + proxyRes.headers["set-cookie"] = setCookie.map((cookie) => + cookie + .replace(/;\s*Secure/gi, "") + .replace(/;\s*SameSite=None/gi, "; SameSite=Lax") + .replace(/;\s*Domain=[^;]+/gi, ""), + ); + }); + }, + }, + "/static": { + target: apiTarget, + changeOrigin: true, + }, + }, + }, + }; +});