初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/png" href="/assets/logo.png" />
<title>剧游AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2944
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "juhe-factory-web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:web": "vite",
"build": "vue-tsc --noEmit && vite build",
"format": "prettier --write src",
"format:check": "prettier --check src",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.ts,.tsx --fix"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.18.1",
"echarts": "^5.6.0",
"element-plus": "^2.5.6",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"prettier": "3.9.6",
"sass": "^1.71.1",
"sass-embedded": "^1.100.0",
"typescript": "^5.9.3",
"vite": "^5.1.5",
"vue-tsc": "^2.2.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+82
View File
@@ -0,0 +1,82 @@
import net from "node:net";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const apiRoot = resolve(webRoot, "..", "API");
const apiHost = "127.0.0.1";
const apiPort = 8123;
const viteBin = resolve(webRoot, "node_modules", "vite", "bin", "vite.js");
let apiProcess;
let viteProcess;
let stopping = false;
function portOpen() {
return new Promise(resolveResult => {
const socket = net.createConnection({ host: apiHost, port: apiPort });
const finish = open => {
socket.destroy();
resolveResult(open);
};
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
socket.setTimeout(500, () => finish(false));
});
}
async function waitForApi(timeoutMs = 120000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await portOpen()) return true;
if (apiProcess?.exitCode != null) return false;
await new Promise(resolveResult => setTimeout(resolveResult, 250));
}
return false;
}
function stop(code = 0) {
if (stopping) return;
stopping = true;
viteProcess?.kill();
apiProcess?.kill();
process.exit(code);
}
process.once("SIGINT", () => stop(0));
process.once("SIGTERM", () => stop(0));
if (!(await portOpen())) {
apiProcess = spawn("go", ["run", "./cmd/api"], {
cwd: apiRoot,
env: { ...process.env, SERVER_ADDRESS: `:${apiPort}` },
stdio: "inherit",
windowsHide: false,
});
apiProcess.once("error", error => {
console.error(`启动 API 失败:${error.message}`);
stop(1);
});
}
if (!(await waitForApi())) {
console.error(`API 未能在规定时间内监听 ${apiHost}:${apiPort}`);
stop(1);
}
viteProcess = spawn(process.execPath, [viteBin, ...process.argv.slice(2)], {
cwd: webRoot,
stdio: "inherit",
windowsHide: false,
});
viteProcess.once("error", error => {
console.error(`启动 Vite 失败:${error.message}`);
stop(1);
});
viteProcess.once("exit", code => stop(code ?? 0));
apiProcess?.once("exit", code => {
if (!stopping) {
console.error("API 进程已退出,停止 Vite。");
stop(code || 1);
}
});
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import ToastContainer from "@/components/ToastContainer.vue";
</script>
<template>
<RouterView />
<ToastContainer />
</template>
+519
View File
@@ -0,0 +1,519 @@
import http from "@/api/http";
export interface CreativeStyle {
id: string;
name: string;
image_url?: string;
reference_image_asset_id?: string;
reference_image_url?: string;
}
export interface VideoReferenceCapability {
supported: boolean;
max_count: number;
min_total_duration_ms?: number;
max_total_duration_ms?: number;
min_short_side_pixels?: number;
max_short_side_pixels?: number;
}
export interface VideoModelCapabilities {
reference_images: VideoReferenceCapability;
reference_audios: VideoReferenceCapability;
reference_videos: VideoReferenceCapability;
}
export interface CreativeModel {
id: string;
name: string;
model_type: "text" | "image" | "video";
channel_name: string;
multimodal?: boolean;
text_billing_mode?: "per_request" | "per_token";
capabilities?: VideoModelCapabilities;
}
export interface CreativePrice {
model_id: string;
price_key: string;
unit: string;
price: number;
}
export interface CreativeOptions {
styles: CreativeStyle[];
models: CreativeModel[];
prices?: CreativePrice[];
}
export interface PromptPreset {
id: string;
name: string;
type: string;
content: string;
scope?: "system" | "user";
editable?: boolean;
}
export interface ModelConfig {
purpose: string;
model_id: string;
model_name: string;
model_type: "text" | "image" | "video";
channel_name: string;
settings?: { aspect_ratio?: string; resolution?: string; prompt?: string };
capabilities?: VideoModelCapabilities;
}
export interface ProjectSummary {
id: string;
name: string;
project_type: string;
style_name: string;
era_type: string;
custom_era?: string;
aspect_ratio: string;
localization: string;
short_drama_type?: string;
play_count?: string;
audience_profile?: string;
producer?: string;
cast_members: ProjectCastMember[];
cover_url?: string;
cover_mime_type?: string;
cover_video_url?: string;
episode_count: number;
processing_count: number;
updated_at: string;
}
export interface ProjectCastMember {
name: string;
follower_count?: string;
}
/** 剧集摘要,包含项目列表和工作台所需的处理状态与产出标记。 */
export interface Episode {
id: string;
episode_no: number;
name: string;
audio_source: "video_audio" | "subtitle_file";
source_language?: string;
status: string;
analysis_message?: string;
source_video_asset_id?: string;
source_video_url?: string;
source_video_original_name?: string;
subtitle_asset_id?: string;
subtitle_original_name?: string;
cover_url?: string;
cover_video_url?: string;
storyboard_count: number;
completed_storyboard_count: number;
has_redraw_script?: boolean;
has_text_source?: boolean;
updated_at?: string;
}
export interface ProjectDetail extends ProjectSummary {
style_id: string;
style_image_url?: string;
model_configs: ModelConfig[];
episodes: Episode[];
}
export interface ProjectAsset {
id: string;
project_id: string;
source_episode_id?: string;
asset_type: "character" | "scene" | "prop" | "custom";
name: string;
description?: string;
image_prompt?: string;
appearances?: unknown[];
image_asset_id?: string;
audio_asset_id?: string;
audio_duration_ms?: number;
image_url?: string;
audio_url?: string;
video_url?: string;
user_edited: boolean;
}
export interface Storyboard {
id: string;
episode_id: string;
sequence_no: number;
start_ms: number;
end_ms: number;
duration_seconds: number;
title?: string;
thumbnail_asset_id?: string;
thumbnail_url?: string;
script_content?: string;
source_excerpt?: string;
prompt_content?: string;
image_prompt?: string;
dialogue?: unknown[];
asset_refs?: Array<{ id: string; name?: string }>;
locked: boolean;
active_output_id?: string;
status: string;
output_id?: string;
result_media_id?: string;
result_url?: string;
result_mime?: string;
history_count: number;
task_status?: string;
}
export interface WorkbenchData {
project: ProjectDetail;
episode: Episode;
assets: ProjectAsset[];
storyboards: Storyboard[];
}
export interface RedrawSource {
source_video_asset_id?: string;
source_video_url?: string;
source_video_original_name?: string;
subtitle_asset_id?: string;
subtitle_url?: string;
subtitle_original_name?: string;
audio_source: "video_audio" | "subtitle_file";
source_language?: string;
status: string;
analysis_message?: string;
}
export interface RedrawWorkbenchData {
project: ProjectDetail;
episode: Episode;
source: RedrawSource;
assets: ProjectAsset[];
storyboards: Storyboard[];
script_content: string;
}
export interface DramaChapterPreview {
title: string;
char_count: number;
}
export interface DramaImportPreview {
import_token: string;
has_chapters: boolean;
total_characters: number;
chapters: DramaChapterPreview[];
single_episode_confirmation_required: boolean;
}
export interface DramaEpisodeSource {
id: string;
episode_id: string;
title?: string;
raw_content: string;
char_count: number;
source_type: string;
source_filename?: string;
content_sha256: string;
}
export interface DramaParseTask {
id: string;
request_id: string;
batch_id?: string;
episode_id: string;
status: string;
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
estimated_points?: string;
prepaid_points?: string;
actual_points?: string;
cost_refunded?: boolean;
token_count_source?: string;
error_code?: string;
error_message?: string;
created_at: string;
finished_at?: string;
}
export interface GenerationTask {
id: string;
request_id: string;
task_type: string;
status: string;
episode_id?: string;
storyboard_id?: string;
mode?: string;
phase_started_at?: string;
sequence_no?: number;
asset_id?: string;
estimated_points?: string;
actual_points?: string;
error_code?: string;
error_message?: string;
result_url?: string;
created_at: string;
submitted_at?: string;
finished_at?: string;
last_result_at?: string;
}
export interface GenerationOutput {
id: string;
task_id: string;
media_asset_id: string;
output_type: string;
public_url: string;
mime_type: string;
size_bytes: number;
estimated_points?: string;
actual_points?: string;
active: boolean;
candidate?: boolean;
created_at: string;
}
export interface ProjectPayload {
project_type?: "video_redraw" | "premium_drama";
name: string;
style_id: string;
era_type: string;
custom_era?: string | null;
aspect_ratio: string;
localization?: string | null;
short_drama_type?: string;
play_count?: string;
audience_profile?: string;
producer?: string;
cast_members?: ProjectCastMember[];
}
export interface ScriptAnalysis {
id: string;
user_id: string;
name: string;
source_content: string;
result_content: string;
analysis_result: ScriptAnalysisResult;
analysis_status: "idle" | "queued" | "running" | "succeeded" | "failed";
analysis_message?: string;
characters?: ScriptAnalysisCharacter[];
created_at: string;
updated_at: string;
}
export interface ScriptAnalysisImportProject {
id: string;
name: string;
episode_count: number;
char_count: number;
updated_at: string;
}
export interface ScriptAnalysisCharacter {
id: string;
script_analysis_id: string;
name: string;
faction: "正派" | "反派" | "中立派";
biography: string;
motivation: string;
relationship: string;
sort_order: number;
}
export interface ScriptAnalysisResult {
tags?: { drama_tags?: string[]; applicable_scene_tags?: string[] };
selling_point?: { summary?: string; highlights?: Array<{ type: string; content: string }> };
story_structure?: {
synopsis?: string;
story_outline?: string[];
emotion_outline?: Array<{ stage: string; emotion: string; description: string }>;
episode_outlines?: Array<{ episode_no: number; title: string; outline: string; emotion: string }>;
characters?: Array<{
name: string;
faction: "正派" | "反派" | "中立派";
biography: string;
motivation: string;
relationship: string;
}>;
};
opening_hook?: {
first_scene?: string;
first_line?: string;
attention_methods?: string[];
analysis?: string;
optimization?: string;
};
episode_hooks?: Array<{ episode_no: number; hook_type: string; hook: string; basis: string }>;
episode_10_paywall_hooks?: Array<{ hook_type: string; content: string; appeal: string }>;
custom_analysis?: Array<{ objective: string; conclusion: string; evidence: string[] }>;
unmatched_objectives?: Array<{ objective: string; reason: string }>;
}
const data = <T>(response: { data: { data: T } }) => response.data.data;
export const creativeApi = {
options: () => http.get("/creative/options").then(data<CreativeOptions>),
allProjects: () => http.get("/projects").then(data<ProjectSummary[]>),
projects: (keyword = "") =>
http.get("/projects", { params: { project_type: "video_redraw", keyword } }).then(data<ProjectSummary[]>),
dramaProjects: (keyword = "") =>
http.get("/projects", { params: { project_type: "premium_drama", keyword } }).then(data<ProjectSummary[]>),
createProject: (payload: ProjectPayload) => http.post("/projects", payload).then(data<{ id: string }>),
project: (projectId: string) => http.get(`/projects/${projectId}`).then(data<ProjectDetail>),
updateProject: (projectId: string, payload: ProjectPayload) => http.put(`/projects/${projectId}`, payload),
deleteProject: (projectId: string) => http.delete(`/projects/${projectId}`),
saveModels: (projectId: string, payload: Record<string, unknown>) =>
http.put(`/projects/${projectId}/model-configs`, payload),
promptPresets: (type: string) =>
http.get(`/user/prompts/${encodeURIComponent(type)}`).then(data<{ prompts: PromptPreset[]; selected_id?: string }>),
selectPrompt: (type: string, promptId?: string) =>
http.put(`/user/prompts/${encodeURIComponent(type)}/select`, { prompt_id: promptId || null }),
createCustomPrompt: (payload: { name: string; type: string; content: string }) =>
http.post("/user/custom-prompts", payload).then(data<PromptPreset>),
updateCustomPrompt: (id: string, payload: { name: string; type?: string; content: string }) =>
http.put(`/user/custom-prompts/${id}`, payload).then(data<PromptPreset>),
deleteCustomPrompt: (id: string) => http.delete(`/user/custom-prompts/${id}`),
createEpisode: (projectId: string, payload: { episode_no?: number; name?: string } = {}) =>
http.post(`/projects/${projectId}/episodes`, payload).then(data<Episode>),
updateEpisodeName: (projectId: string, episodeId: string, name: string) =>
http.put(`/projects/${projectId}/episodes/${episodeId}`, { name }).then(data<Episode>),
previewDramaImport: (projectId: string, payload: { file?: File; rawText?: string }) => {
const form = new FormData();
if (payload.file) form.append("file", payload.file);
if (payload.rawText) form.append("raw_text", payload.rawText);
return http
.post(`/projects/${projectId}/episodes/import/preview`, form, {
timeout: 0,
headers: { "Content-Type": "multipart/form-data" },
})
.then(data<DramaImportPreview>);
},
confirmDramaImport: (
projectId: string,
payload: {
import_token: string;
chapters_per_episode: number;
start_episode_no: number;
treat_as_single_episode: boolean;
},
) => http.post(`/projects/${projectId}/episodes/import/confirm`, payload).then(data<Episode[]>),
dramaEpisodeSource: (projectId: string, episodeId: string) =>
http.get(`/projects/${projectId}/episodes/${episodeId}/text-source`).then(data<DramaEpisodeSource>),
saveDramaEpisodeSource: (projectId: string, episodeId: string, payload: { title?: string; raw_content: string }) =>
http.put(`/projects/${projectId}/episodes/${episodeId}/text-source`, payload).then(data<DramaEpisodeSource>),
parseDramaEpisode: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/parse`).then(data<DramaParseTask>),
reparseDramaEpisode: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/reparse`).then(data<DramaParseTask>),
createDramaStoryboard: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/storyboards`).then(data<Storyboard>),
dramaParseTasks: (projectId: string, episodeId?: string) =>
http.get(`/projects/${projectId}/parse-tasks`, { params: { episode_id: episodeId } }).then(data<DramaParseTask[]>),
cancelDramaParse: (projectId: string, taskId: string) =>
http.post(`/projects/${projectId}/parse-tasks/${taskId}/cancel`),
deleteEpisode: (projectId: string, episodeId: string) => http.delete(`/projects/${projectId}/episodes/${episodeId}`),
workbench: (projectId: string, episodeId: string) =>
http.get(`/projects/${projectId}/episodes/${episodeId}/workbench`).then(data<WorkbenchData>),
// episodeVideosArchive 下载当前剧集按项目类型筛选后的全部视频 ZIP。
episodeVideosArchive: (projectId: string, episodeId: string) =>
http
.get(`/projects/${projectId}/episodes/${episodeId}/videos.zip`, { responseType: "blob", timeout: 0 })
.then((response) => response.data as Blob),
redrawWorkbench: (projectId: string, episodeId: string) =>
http.get(`/projects/${projectId}/episodes/${episodeId}/redraw-workbench`).then(data<RedrawWorkbenchData>),
uploadSource: (projectId: string, episodeId: string, file: File) =>
upload(`/projects/${projectId}/episodes/${episodeId}/source`, file),
uploadSubtitle: (projectId: string, episodeId: string, file: File) =>
upload(`/projects/${projectId}/episodes/${episodeId}/subtitle`, file),
uploadRedrawSource: (projectId: string, file: File) => upload(`/projects/${projectId}/redraw-source`, file),
uploadRedrawSubtitle: (projectId: string, file: File) => upload(`/projects/${projectId}/redraw-subtitle`, file),
saveAnalysisSettings: (
projectId: string,
episodeId: string,
payload: { audio_source: string; source_language: string },
) => http.put(`/projects/${projectId}/episodes/${episodeId}/analysis-settings`, payload),
saveRedrawAnalysisSettings: (projectId: string, payload: { audio_source: string; source_language: string }) =>
http.put(`/projects/${projectId}/redraw-analysis-settings`, payload),
analyze: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/analyze`).then(data<GenerationTask>),
reanalyze: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/reanalyze`).then(data<GenerationTask>),
analyzeRedraw: (projectId: string) => http.post(`/projects/${projectId}/redraw-analyze`).then(data<GenerationTask>),
reanalyzeRedraw: (projectId: string) =>
http.post(`/projects/${projectId}/redraw-reanalyze`).then(data<GenerationTask>),
reverseRedrawScript: (projectId: string, episodeId: string) =>
http.post(`/projects/${projectId}/episodes/${episodeId}/redraw-script`).then(data<GenerationTask>),
saveRedrawScript: (projectId: string, episodeId: string, content: string) =>
http.put(`/projects/${projectId}/episodes/${episodeId}/redraw-script`, { content }),
createAsset: (projectId: string, payload: { asset_type: string; name: string }) =>
http.post(`/projects/${projectId}/assets`, payload).then(data<ProjectAsset>),
updateAsset: (projectId: string, assetId: string, payload: Partial<ProjectAsset>) =>
http.put(`/projects/${projectId}/assets/${assetId}`, payload),
deleteAsset: (projectId: string, assetId: string) => http.delete(`/projects/${projectId}/assets/${assetId}`),
uploadAssetImage: (projectId: string, assetId: string, file: File) =>
upload(`/projects/${projectId}/assets/${assetId}/image`, file),
removeAssetImage: (projectId: string, assetId: string) =>
http.delete(`/projects/${projectId}/assets/${assetId}/image`),
uploadAssetAudio: (projectId: string, assetId: string, file: File, durationMs?: number) =>
upload(
`/projects/${projectId}/assets/${assetId}/audio`,
file,
durationMs === undefined ? undefined : { duration_ms: String(durationMs) },
),
updateStoryboard: (projectId: string, storyboardId: string, payload: Partial<Storyboard>) =>
http.put(`/projects/${projectId}/storyboards/${storyboardId}`, payload),
insertStoryboard: (projectId: string, storyboardId: string, position: "before" | "after") =>
http.post(`/projects/${projectId}/storyboards/${storyboardId}/insert`, { position }).then(data<Storyboard>),
deleteStoryboard: (projectId: string, storyboardId: string) =>
http.delete(`/projects/${projectId}/storyboards/${storyboardId}`),
reinferStoryboard: (projectId: string, episodeId: string, storyboardId: string) =>
http
.post(`/projects/${projectId}/episodes/${episodeId}/storyboards/${storyboardId}/reinfer`)
.then(data<GenerationTask>),
reinferRedrawStoryboard: (projectId: string, storyboardId: string) =>
http.post(`/projects/${projectId}/storyboards/${storyboardId}/redraw-reinfer`).then(data<GenerationTask>),
generate: (
projectId: string,
episodeId: string,
storyboardId: string,
taskType: "image_generation" | "video_generation",
input: Record<string, unknown>,
) =>
http
.post(`/projects/${projectId}/episodes/${episodeId}/storyboards/${storyboardId}/generate`, {
task_type: taskType,
input,
})
.then(data<GenerationTask>),
tasks: (projectId: string, episodeId?: string) =>
http.get(`/projects/${projectId}/tasks`, { params: { episode_id: episodeId } }).then(data<GenerationTask[]>),
cancelTask: (projectId: string, taskId: string) => http.post(`/projects/${projectId}/tasks/${taskId}/cancel`),
outputs: (projectId: string, storyboardId: string) =>
http.get(`/projects/${projectId}/storyboards/${storyboardId}/outputs`).then(data<GenerationOutput[]>),
removeStoryboardImage: (projectId: string, storyboardId: string) =>
http.delete(`/projects/${projectId}/storyboards/${storyboardId}/image`),
activateOutput: (projectId: string, storyboardId: string, outputId: string) =>
http.put(`/projects/${projectId}/storyboards/${storyboardId}/outputs/${outputId}/activate`),
addStoryboardCandidate: (projectId: string, storyboardId: string, outputId: string) =>
http.put(`/projects/${projectId}/storyboards/${storyboardId}/outputs/${outputId}/candidate`),
removeStoryboardCandidate: (projectId: string, storyboardId: string, outputId: string) =>
http.delete(`/projects/${projectId}/storyboards/${storyboardId}/outputs/${outputId}/candidate`),
deleteOutput: (projectId: string, storyboardId: string, outputId: string) =>
http.delete(`/projects/${projectId}/storyboards/${storyboardId}/outputs/${outputId}`),
assetOutputs: (projectId: string, assetId: string) =>
http.get(`/projects/${projectId}/assets/${assetId}/outputs`).then(data<GenerationOutput[]>),
activateAssetOutput: (projectId: string, assetId: string, outputId: string) =>
http.put(`/projects/${projectId}/assets/${assetId}/outputs/${outputId}/activate`),
deleteAssetOutput: (projectId: string, assetId: string, outputId: string) =>
http.delete(`/projects/${projectId}/assets/${assetId}/outputs/${outputId}`),
media: (mediaId: string) =>
http.get(`/media/${mediaId}`, { responseType: "blob", timeout: 0 }).then((response) => response.data as Blob),
scriptAnalyses: () => http.get("/script-analyses").then(data<ScriptAnalysis[]>),
createScriptAnalysis: () => http.post("/script-analyses").then(data<ScriptAnalysis>),
scriptAnalysisImportProjects: () =>
http.get("/script-analyses/import-projects").then(data<ScriptAnalysisImportProject[]>),
scriptAnalysis: (id: string) => http.get(`/script-analyses/${id}`).then(data<ScriptAnalysis>),
updateScriptAnalysis: (id: string, payload: { name: string; source_content: string; result_content: string }) =>
http.put(`/script-analyses/${id}`, payload).then(data<ScriptAnalysis>),
deleteScriptAnalysis: (id: string) => http.delete(`/script-analyses/${id}`),
importScriptAnalysisProject: (id: string, projectId: string) =>
http.post(`/script-analyses/${id}/import-project`, { project_id: projectId }).then(data<ScriptAnalysis>),
importScriptAnalysisFile: (id: string, file: File) =>
upload<ScriptAnalysis>(`/script-analyses/${id}/import-file`, file),
analyzeScriptAnalysis: (id: string) => http.post(`/script-analyses/${id}/analyze`).then(data<GenerationTask>),
cancelScriptAnalysis: (id: string) => http.post(`/script-analyses/${id}/cancel`),
userModelConfigs: (scope: "script_analysis") => http.get(`/user/model-configs/${scope}`).then(data<ModelConfig[]>),
saveUserModelConfig: (scope: "script_analysis", modelType: "text", modelId: string) =>
http.put(`/user/model-configs/${scope}/${modelType}`, { model_id: modelId }),
};
function upload<T = Record<string, unknown>>(path: string, file: File, fields?: Record<string, string>) {
const form = new FormData();
form.append("file", file);
Object.entries(fields || {}).forEach(([key, value]) => form.append(key, value));
return http.post(path, form, { timeout: 0, headers: { "Content-Type": "multipart/form-data" } }).then(data<T>);
}
+101
View File
@@ -0,0 +1,101 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
import { notifyPointBalanceChanged } from "@/utils/pointBalance";
const http = axios.create({ baseURL: "/api/web", timeout: 20000 });
let refreshing: Promise<string> | null = null;
let lastActiveTaskSync = 0;
const activeTaskStatuses = new Set([
"pending_submission",
"submitting",
"submitted",
"queued",
"running",
"processing",
"retry_wait",
"cancel_requested",
"analyzing",
]);
const pointMutationPattern =
/\/(?:parse|reparse|analyze|reanalyze|redraw-analyze|redraw-reanalyze|redraw-script|reinfer|redraw-reinfer|generate|cancel)(?:\?|$)/;
function containsActivePointTask(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsActivePointTask);
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
if (
typeof item.id === "string" &&
typeof item.status === "string" &&
activeTaskStatuses.has(item.status) &&
("estimated_points" in item || "prepaid_points" in item || "actual_points" in item)
)
return true;
return Object.values(item).some(containsActivePointTask);
}
function shouldSyncPointBalance(response: { config: InternalAxiosRequestConfig; data: unknown }) {
const method = response.config.method?.toLowerCase();
const url = response.config.url || "";
if (method === "post" && pointMutationPattern.test(url)) return true;
if (!containsActivePointTask(response.data)) return false;
const now = Date.now();
if (now - lastActiveTaskSync < 1500) return false;
lastActiveTaskSync = now;
return true;
}
http.interceptors.request.use((config) => {
const token = localStorage.getItem("web_access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
http.interceptors.response.use(
(response) => {
if (shouldSyncPointBalance(response)) notifyPointBalanceChanged();
return response;
},
async (error: AxiosError) => {
const request = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
if (error.response?.status === 401 && request && !request._retried && !request.url?.includes("/auth/")) {
request._retried = true;
try {
const token = await refresh();
request.headers.Authorization = `Bearer ${token}`;
return http(request);
} catch {
clearAuth();
if (location.pathname != "/login")
location.href = `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`;
}
}
return Promise.reject(error);
},
);
async function refresh() {
if (!refreshing)
refreshing = axios
.post("/api/web/auth/refresh", { refresh_token: localStorage.getItem("web_refresh_token") })
.then(({ data }) => {
saveTokens(data.data);
return data.data.access_token as string;
})
.finally(() => (refreshing = null));
return refreshing;
}
export function saveTokens(data: { access_token: string; refresh_token: string; user?: unknown }) {
localStorage.setItem("web_access_token", data.access_token);
localStorage.setItem("web_refresh_token", data.refresh_token);
if (data.user) localStorage.setItem("web_profile", JSON.stringify(data.user));
}
export function clearAuth() {
localStorage.removeItem("web_access_token");
localStorage.removeItem("web_refresh_token");
localStorage.removeItem("web_profile");
}
export function apiError(error: unknown) {
const e = error as AxiosError<{ code?: string; message?: string; trace_id?: string }>;
const body = e.response?.data;
const message = body?.message || e.message || "请求失败";
return {
message,
copyText: `错误码:${body?.code || "request_failed"}\n错误描述:${message}\n请求追踪 ID:${body?.trace_id || e.response?.headers?.["x-request-id"] || "无"}`,
};
}
export default http;
+46
View File
@@ -0,0 +1,46 @@
// 图片生成生图 API,统一封装临时配置提交、生成历史查询和结果删除。
import http from "@/api/http";
export interface ProductImageGeneration {
task_id: string;
status: string;
prompt: string;
model_name: string;
error_message: string;
media_asset_id?: string;
public_url: string;
mime_type: string;
estimated_points: string;
actual_points?: string;
created_at: string;
finished_at?: string;
}
export interface ProductImageGenerationInput {
prompt: string;
model_id: string;
aspect_ratio: string;
resolution: string;
references: File[];
}
const data = <T>(response: { data: { data: T } }) => response.data.data;
export const productImageApi = {
generate: (input: ProductImageGenerationInput) => {
const form = new FormData();
form.append("prompt", input.prompt);
form.append("model_id", input.model_id);
form.append("aspect_ratio", input.aspect_ratio);
form.append("resolution", input.resolution);
input.references.forEach((file) => form.append("references", file, file.name));
return http
.post("/product-images/generations", form, {
timeout: 0,
headers: { "Content-Type": "multipart/form-data" },
})
.then(data<{ task_id: string }>);
},
generations: () => http.get("/product-images/generations").then(data<ProductImageGeneration[]>),
removeGeneration: (taskId: string) => http.delete(`/product-images/generations/${taskId}`),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+350
View File
@@ -0,0 +1,350 @@
@use "./variables.scss";
:root {
--web-dialog-bg: var(--web-raised);
color-scheme: light;
}
.el-dialog,
.el-message-box {
--el-bg-color: var(--web-dialog-bg);
--el-bg-color-overlay: var(--web-dialog-bg);
background: var(--web-dialog-bg) !important;
}
.el-dialog > .el-dialog__header,
.el-dialog > .el-dialog__body,
.el-dialog > .el-dialog__footer {
background: var(--web-dialog-bg) !important;
}
* {
scrollbar-color: var(--web-scrollbar-global-thumb) var(--web-scrollbar-global-track) !important;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px !important;
height: 10px !important;
}
*::-webkit-scrollbar-track,
*::-webkit-scrollbar-track-piece {
background: var(--web-scrollbar-global-track) !important;
border-radius: 999px;
}
*::-webkit-scrollbar-thumb {
min-width: 28px;
min-height: 28px;
background: var(--web-scrollbar-global-thumb) !important;
background-clip: padding-box !important;
border: 2px solid var(--web-scrollbar-global-track) !important;
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--web-scrollbar-global-hover) !important;
background-clip: padding-box !important;
}
*::-webkit-scrollbar-button,
*::-webkit-scrollbar-button:single-button {
width: 0 !important;
height: 0 !important;
display: none !important;
background: transparent !important;
}
*::-webkit-scrollbar-corner {
background: var(--web-scrollbar-global-track) !important;
}
.el-scrollbar__bar {
background: transparent !important;
border-radius: 999px;
}
.el-scrollbar__bar.is-vertical {
width: 8px !important;
right: 2px !important;
}
.el-scrollbar__bar.is-horizontal {
height: 8px !important;
bottom: 2px !important;
}
.el-scrollbar__thumb {
background: var(--web-scrollbar-global-thumb) !important;
border: 0 !important;
border-radius: 999px !important;
opacity: 0.82 !important;
}
.el-scrollbar__thumb:hover {
background: var(--web-scrollbar-global-hover) !important;
opacity: 1 !important;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scrollbar-gutter: stable both-edges;
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
color: var(--web-text);
background: var(--web-bg);
font-family: var(--font-ui);
line-height: 1.5;
user-select: none;
}
input,
textarea,
[contenteditable="true"] {
user-select: text;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
touch-action: manipulation;
}
#app {
width: 100%;
min-height: 100dvh;
}
a {
color: inherit;
text-decoration: none;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
img {
max-width: 100%;
height: auto;
}
.icon-button {
width: var(--control-height-md);
height: var(--control-height-md);
padding: 0;
display: grid;
place-items: center;
color: var(--web-icon-button-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s;
}
.icon-button:hover:not(:disabled) {
color: var(--web-text);
background: var(--web-hover);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.toast-region {
position: fixed;
z-index: 4000;
top: 18px;
left: 50%;
width: min(440px, calc(100vw - 32px));
display: flex;
flex-direction: column;
gap: 10px;
transform: translateX(-50%);
pointer-events: none;
}
.app-toast {
position: relative;
min-height: 68px;
padding: 14px 12px 16px;
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-emphasis);
border-radius: var(--radius-md);
box-shadow: var(--web-shadow-toast);
overflow: hidden;
pointer-events: auto;
}
.app-toast--success {
border-color: var(--web-success-border);
}
.app-toast--warning {
border-color: var(--web-warning);
}
.app-toast--error {
border-color: var(--web-danger);
}
.app-toast__copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.app-toast__copy span {
margin-top: 2px;
color: var(--web-text-secondary);
font-size: 13px;
overflow-wrap: anywhere;
}
.app-toast__progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: var(--web-accent);
transform: scaleX(var(--toast-progress, 1));
transform-origin: left center;
transition: transform 0.05s linear;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.2s;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
}
.app-toast {
align-items: center;
}
.app-toast--success strong,
.app-toast--warning strong,
.app-toast--info strong {
display: none;
}
.app-toast__copy span {
margin-top: 0;
}
.web-page {
--el-color-primary: var(--web-primary);
--el-color-primary-light-3: var(--web-primary-light);
--el-color-primary-dark-2: var(--web-primary-hover);
--el-bg-color: var(--web-surface);
--el-bg-color-overlay: var(--web-raised);
--el-fill-color-blank: var(--web-surface);
--el-fill-color-light: var(--web-raised);
--el-border-color: var(--web-line);
--el-border-color-light: var(--web-line);
--el-text-color-primary: var(--web-text);
--el-text-color-regular: var(--web-text-secondary);
--el-text-color-placeholder: var(--web-input-placeholder);
--el-mask-color: var(--web-page-mask);
--el-mask-color-extra-light: var(--web-page-mask-light);
--el-border-radius-base: var(--radius-sm);
}
.web-page .el-loading-mask {
background-color: var(--web-page-mask);
transition: opacity 0.16s ease;
}
.web-page .el-input__wrapper,
.web-page .el-textarea__inner {
background: var(--web-surface-soft);
box-shadow: 0 0 0 1px var(--web-line-strong) inset;
}
.web-page .el-input__wrapper:hover,
.web-page .el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--web-input-focus) inset;
}
.web-dialog.el-dialog {
--el-mask-color: var(--web-dialog-mask);
--el-mask-color-extra-light: var(--web-dialog-mask-light);
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
overflow: hidden;
border-radius: var(--radius-lg);
box-shadow: var(--web-shadow-dialog);
}
.web-dialog .el-dialog__title {
color: var(--web-text);
}
.web-dialog .el-dialog__headerbtn .el-dialog__close {
color: var(--web-accent-muted);
}
.el-overlay {
background-color: transparent !important;
}
.el-message-box {
--el-bg-color: var(--web-dialog-bg);
--el-bg-color-overlay: var(--web-dialog-bg);
--el-text-color-primary: var(--web-message-text);
--el-text-color-regular: var(--web-message-muted);
--el-border-color: var(--web-message-line);
width: min(430px, calc(100vw - 32px));
padding: 0 !important;
color: var(--web-text) !important;
background: var(--web-dialog-bg) !important;
border: 1px solid var(--web-line-emphasis) !important;
border-radius: var(--radius-lg);
box-shadow: var(--web-shadow-dialog);
}
.el-message-box__header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--web-line-strong);
}
.el-message-box__title {
color: var(--web-text) !important;
font-size: 17px;
font-weight: 600;
}
.el-message-box__headerbtn {
top: 14px;
right: 14px;
}
.el-message-box__headerbtn .el-message-box__close {
color: var(--web-text-note);
}
.el-message-box__headerbtn:hover .el-message-box__close {
color: var(--web-accent-dark);
}
.el-message-box__content {
padding: 18px 20px;
color: var(--web-text-secondary) !important;
font-size: 13px;
line-height: 1.7;
}
.el-message-box__status {
color: var(--web-accent-dark);
}
.el-message-box__message p {
color: var(--web-text-secondary) !important;
}
.el-message-box__btns {
padding: 0 20px 18px;
}
.el-message-box__btns .el-button {
min-height: var(--control-height-md);
min-width: 78px;
color: var(--web-text-secondary);
background: var(--web-surface);
border-color: var(--web-line-strong);
border-radius: var(--radius-sm);
}
.el-message-box__btns .el-button:hover {
color: var(--web-text);
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.el-message-box__btns .el-button--primary {
color: var(--web-text-strong);
background: var(--web-primary);
border-color: var(--web-primary);
}
.el-message-box__btns .el-button--primary:hover {
background: var(--web-primary-hover);
border-color: var(--web-primary-hover);
}
.source-upload-button input[type="file"] {
display: none;
}
+234
View File
@@ -0,0 +1,234 @@
/* 用户端全局设计令牌,统一颜色、间距、圆角和阴影语义。 */
:root {
/* 核心语义层用于统一页面画布、内容表面、文字、边界和品牌强调。 */
--web-canvas: #f4f5f7;
--web-surface-base: #ffffff;
--web-surface-raised: #f8f8f7;
--web-surface-hover: #f1f0ed;
--web-text-primary: #1d2024;
--web-text-muted: #676d75;
--web-border: #dfe2e6;
--web-border-strong: #c9cdd3;
--web-brand: #b57b3d;
--web-brand-hover: #99622d;
--web-brand-soft: #f2e6d7;
--web-accent: var(--web-brand);
/* 既有变量映射到核心语义层,供尚未迁移的页面继续使用。 */
--web-bg: var(--web-canvas);
--web-sidebar: var(--web-surface-raised);
--web-surface: var(--web-surface-base);
--web-raised: var(--web-surface-raised);
--web-hover: var(--web-surface-hover);
--web-primary: var(--web-brand);
--web-primary-hover: var(--web-brand-hover);
--web-primary-light: #c99a67;
--web-input-focus: var(--web-brand);
--web-creative-accent: var(--web-brand);
--web-creative-accent-hover: var(--web-brand-hover);
--web-creative-button-text: #17191c;
--web-creative-danger: #e49b92;
--web-text: var(--web-text-primary);
--web-muted: var(--web-text-muted);
--web-line: var(--web-border);
--web-surface-soft: var(--web-surface-raised);
--web-text-strong: #111317;
--web-text-secondary: #484e56;
--web-text-tertiary: #6f757d;
--web-text-subtle: #797f87;
--web-text-faint: #858b92;
--web-text-meta: #7d838b;
--web-text-detail: #5b6169;
--web-text-caption: #727880;
--web-text-placeholder: #858b93;
--web-text-quiet: #6f757d;
--web-text-hint: #747a82;
--web-text-note: #767c84;
--web-text-disabled: #92979e;
--web-text-input-placeholder: #858b93;
--web-input-placeholder: #858b93;
--web-text-context: #5f656d;
--web-text-panel-muted: #656b73;
--web-media-control-text: #f3f4f2;
--web-text-neutral: #565c64;
--web-text-subdued: #4f555d;
--web-prompt-placeholder: #686e76;
--web-text-critical-muted: #6a5552;
--web-text-media-muted: #807466;
--web-project-count: var(--web-text-muted);
--web-project-placeholder: #7d6a55;
--web-project-badge-text: #574737;
--web-project-tooltip-accent: #855722;
--web-project-tooltip-label: var(--web-text-muted);
--web-project-copy-muted: #747a82;
--web-project-divider-text: #5b6169;
--web-project-meta: var(--web-text-muted);
--web-project-state-text: #747a82;
--web-dialog-description: #666c74;
--web-creative-muted: var(--web-text-muted);
--web-script-hint: #666c74;
--web-panel-control-text: #777d85;
--web-panel-hint: #626870;
--web-select-option: #50565e;
--web-select-group: #686e76;
--web-task-idle-text: #c5beb5;
--web-task-idle-dot: #817d76;
--web-task-success-text: #8cc297;
--web-task-success-dot: #75ad82;
--web-task-failed-text: #dc9389;
--web-task-failed-dot: #d98377;
--web-task-cancelled: #a89e91;
--web-task-detail: #555;
--web-dialog-option-muted: #88857f;
--web-project-shared-note: #746b60;
--web-episode-placeholder: #9a866e;
--web-episode-status-text: #5f503e;
--web-episode-copy: #8f514a;
--web-episode-copy-hover: #f0e2d7;
--web-icon-button-muted: #747a82;
--web-message-text: #eee9e2;
--web-message-muted: #b7b1a8;
--web-empty-text: var(--web-text-muted);
--web-accent-dark: #9f4038;
--web-accent-muted: #846039;
--web-accent-soft: #8a7659;
--web-line-strong: var(--web-border);
--web-line-emphasis: var(--web-border-strong);
--web-line-active: #796348;
--web-line-hover: #7c6546;
--web-line-action-hover: #b68d57;
--web-line-danger-hover: #b85e55;
--web-line-critical: #85534d;
--web-line-accent: #8a704e;
--web-project-card-hover-line: #a58358;
--web-project-tooltip-line: var(--web-border);
--web-project-tooltip-line-soft: #eceef1;
--web-invalid-line: #b45d54;
--web-invalid-line-hover: #d9786b;
--web-timeline-action-line: #765d40;
--web-timeline-focus-line: #9a7a50;
--web-upload-hover-line: #8b6d47;
--web-search-line: #a47a4d;
--web-message-line: #4b4033;
--web-control-muted: #817b72;
--web-icon-muted: #8e7554;
--web-divider-soft: #e8eaed;
--web-scrollbar-thumb: #756047;
--web-scrollbar-track: #0b0c0a;
--web-scrollbar-track-panel: #151613;
--web-scrollbar-track-editor: #171815;
--web-scrollbar-compact: #5b554c;
--web-scrollbar-thumb-hover: #eee5d8;
--web-scrollbar-global-thumb: #aeb3ba;
--web-scrollbar-global-track: #eceef1;
--web-scrollbar-global-hover: #858b93;
--web-success: #3f8555;
--web-success-bright: #54c77a;
--web-success-border: #b5edc8;
--web-complete: #39794c;
--web-info: #426f9d;
--web-warning: #a96812;
--web-danger: #b84d43;
--web-danger-soft: #f6e3df;
--web-mention-default-line: #b4864d;
--web-mention-remove-line: #c36a62;
--web-mention-remove-hover: #a9433b;
--web-mention-remove-hover-line: #f0a098;
--web-mention-voice-line: #c8ab83;
--web-mention-scene-bg: #e5f0e5;
--web-mention-scene-line: #5c9a7a;
--web-mention-prop-bg: #eee5d8;
--web-mention-prop-line: #7180bd;
--web-mention-custom-line: #ad7770;
--web-mention-storyboard-line: #b99767;
--web-switch-track: #b5bac1;
--web-switch-track-active: #9f4038;
--web-switch-border: #806443;
--web-switch-thumb: #ffffff;
--web-control-outline: rgba(29, 32, 36, 0.18);
--web-loading-mask: rgba(7, 8, 7, 0.82);
--web-history-overlay: rgba(7, 8, 7, 0.68);
--web-media-overlay: rgba(9, 10, 8, 0.82);
--web-preview-overlay: rgba(0, 0, 0, 0.48);
--web-error-overlay: rgba(33, 23, 21, 0.9);
--web-current-outline: rgba(9, 10, 8, 0.72);
--web-media-overlay-soft: rgba(7, 8, 7, 0.72);
--web-media-overlay-medium: rgba(7, 8, 7, 0.76);
--web-media-overlay-dense: rgba(7, 8, 7, 0.78);
--web-page-mask: rgba(244, 245, 247, 0.9);
--web-page-mask-light: rgba(244, 245, 247, 0.72);
--web-dialog-mask: rgba(8, 9, 8, 0.82);
--web-dialog-mask-light: rgba(8, 9, 8, 0.48);
--web-video-bg: #000;
--web-video-controls: rgba(0, 0, 0, 0.78);
--web-video-dialog-controls: rgba(0, 0, 0, 0.84);
--web-card-menu-bg: rgba(255, 255, 255, 0.96);
--web-media-state-mask: rgba(7, 8, 7, 0.9);
--web-search-outline: rgba(138, 112, 78, 0.18);
--web-media-loading-text: #c8a66e;
--web-media-error-text: #d9786f;
--web-chart-primary: #e2aa61;
--web-chart-tooltip-bg: #171717;
--web-chart-tooltip-line: #3a3024;
--web-chart-tooltip-text: #eee;
--web-chart-axis-line: var(--web-border);
--web-chart-axis-text: var(--web-text-muted);
--web-chart-split-line: var(--web-divider-soft);
--web-tooltip-bg: rgba(17, 16, 13, 0.9);
--web-focus-inset: rgba(198, 160, 106, 0.22);
--web-focus-ring: rgba(198, 160, 106, 0.2);
--web-focus-ring-soft: rgba(198, 160, 106, 0.12);
--web-focus-ring-search: rgba(198, 160, 106, 0.14);
--web-focus-ring-active: rgba(198, 160, 106, 0.28);
--web-focus-ring-strong: rgba(198, 160, 106, 0.35);
--web-invalid-outline: rgba(180, 93, 84, 0.2);
--web-card-focus-ring: rgba(165, 131, 88, 0.28);
--web-timeline-focus-ring: rgba(154, 122, 80, 0.28);
--web-dialog-focus-ring: rgba(154, 122, 80, 0.12);
--web-task-running-ring: rgba(198, 160, 106, 0.12);
--web-shadow-lg: 0 20px 54px rgba(24, 28, 33, 0.1);
--web-shadow-toast: 0 16px 50px rgba(24, 28, 33, 0.14);
--web-shadow-dialog: 0 20px 60px rgba(24, 28, 33, 0.16);
--web-shadow-mention: 0 18px 44px rgba(24, 28, 33, 0.14);
--web-shadow-preview: 0 10px 24px rgba(24, 28, 33, 0.14);
--web-shadow-project-badge: 0 3px 10px rgba(24, 28, 33, 0.08);
--web-shadow-project-tooltip: 0 14px 36px rgba(24, 28, 33, 0.12);
--web-shadow-duration: 0 16px 38px rgba(24, 28, 33, 0.14);
--web-shadow-creative-shell: 0 26px 70px rgba(24, 28, 33, 0.14);
--web-shadow-select: 0 18px 46px rgba(24, 28, 33, 0.14);
--web-shadow-card-action: 0 4px 12px rgba(24, 28, 33, 0.08);
--web-shadow-card-action-hover: 0 5px 14px rgba(24, 28, 33, 0.12);
--web-shadow-card-menu: 0 12px 30px rgba(24, 28, 33, 0.14);
/* 字体、控件、容器、圆角和间距令牌为后续页面迁移提供统一尺寸。 */
--font-ui: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
--font-display: "Songti SC", "SimSun", serif;
--font-size-xs: 11px;
--font-size-sm: 12px;
--font-size-md: 13px;
--font-size-lg: 14px;
--font-size-xl: 16px;
--font-size-metric: 20px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--control-height-sm: 32px;
--control-height-md: 36px;
--control-height-lg: 40px;
--sidebar-width: 216px;
--sidebar-width-collapsed: 68px;
--mobile-bar-height: 58px;
--page-content-max: 1440px;
--page-content-compact: 1320px;
--page-gutter: 32px;
--page-gutter-mobile: 18px;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
}
+172
View File
@@ -0,0 +1,172 @@
<!-- 卡片操作菜单,统一项目卡片的编辑和删除入口。 -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
import { MoreHorizontal, Pencil, Trash2 } from "@/icons";
withDefaults(defineProps<{ label?: string; editLabel?: string; deleteLabel?: string }>(), {
label: "更多操作",
editLabel: "编辑",
deleteLabel: "删除",
});
const emit = defineEmits<{ edit: []; delete: [] }>();
const dropdown = ref<{ handleClose: () => void }>();
const trigger = ref<HTMLButtonElement>();
const menuVisible = ref(false);
const popperOptions = { modifiers: [{ name: "offset", options: { offset: [20, -1] } }] };
/** 将菜单命令转换为编辑或删除事件。 */
function handleCommand(command: string) {
if (command === "edit") emit("edit");
if (command === "delete") emit("delete");
}
/** 移除菜单关闭监听,避免组件卸载后残留全局事件。 */
function removeDismissListeners() {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("focusin", handleFocusIn, true);
window.removeEventListener("blur", closeMenu);
}
/** 关闭当前操作菜单。 */
function closeMenu() {
dropdown.value?.handleClose();
trigger.value?.blur();
removeDismissListeners();
}
/** 判断事件目标是否位于菜单触发器或弹层内部。 */
function isInsideMenu(target: EventTarget | null) {
return (
target instanceof Element && (trigger.value?.contains(target) || Boolean(target.closest(".card-action-popper")))
);
}
/** 点击菜单外部时关闭菜单。 */
function handlePointerDown(event: PointerEvent) {
if (!isInsideMenu(event.target)) closeMenu();
}
/** 焦点移出菜单时关闭菜单。 */
function handleFocusIn(event: FocusEvent) {
if (!isInsideMenu(event.target)) closeMenu();
}
/** 根据弹层可见状态安装或移除关闭监听。 */
function handleVisibleChange(visible: boolean) {
menuVisible.value = visible;
removeDismissListeners();
if (!visible) {
trigger.value?.blur();
return;
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("focusin", handleFocusIn, true);
window.addEventListener("blur", closeMenu);
}
onBeforeUnmount(removeDismissListeners);
</script>
<template>
<el-dropdown
ref="dropdown"
class="card-action-menu"
:class="{ 'is-open': menuVisible }"
trigger="click"
placement="bottom-end"
:show-arrow="false"
:popper-options="popperOptions"
popper-class="card-action-popper"
@command="handleCommand"
@visible-change="handleVisibleChange"
>
<button
ref="trigger"
class="card-action-trigger"
type="button"
:title="label"
@click.stop
>
<MoreHorizontal :size="19" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit"
><Pencil :size="15" /><span>{{ editLabel }}</span></el-dropdown-item
>
<el-dropdown-item
command="delete"
divided
class="is-danger"
><Trash2 :size="15" /><span>{{ deleteLabel }}</span></el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style scoped>
.card-action-menu {
position: absolute;
top: 10px;
right: 10px;
z-index: 6;
opacity: 1;
pointer-events: auto;
}
.card-action-trigger {
width: var(--control-height-md);
height: var(--control-height-md);
padding: 0;
display: grid;
place-items: center;
color: var(--web-text-secondary);
background: var(--web-card-menu-bg);
border: 1px solid var(--web-line-emphasis);
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.card-action-trigger:hover:not(:disabled) {
color: var(--web-text);
background: var(--web-hover);
border-color: var(--web-timeline-focus-line);
}
:global(.card-action-popper.el-popper) {
width: 112px;
min-width: 112px;
padding: 4px;
background: var(--web-surface);
border-color: var(--web-line-strong);
box-shadow: var(--web-shadow-card-menu);
}
:global(.card-action-popper .el-popper__arrow) {
display: none;
}
:global(.card-action-popper .el-dropdown-menu) {
padding: 0;
background: transparent;
}
:global(.card-action-popper .el-dropdown-menu__item) {
min-height: 34px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--web-text-secondary);
background: transparent;
font-size: 12px;
}
:global(.card-action-popper .el-dropdown-menu__item:hover),
:global(.card-action-popper .el-dropdown-menu__item:focus) {
color: var(--web-accent-muted);
background: var(--web-hover);
}
:global(.card-action-popper .el-dropdown-menu__item.is-danger) {
color: var(--web-accent-dark);
}
:global(.card-action-popper .el-dropdown-menu__item.is-danger:hover),
:global(.card-action-popper .el-dropdown-menu__item.is-danger:focus) {
color: var(--web-accent-dark);
background: var(--web-hover);
}
:global(.card-action-popper .el-dropdown-menu__item--divided) {
margin-top: 4px;
border-top-color: var(--web-line-strong);
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!-- 积分消耗图表,负责展示账户时间范围内的积分变化。 -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
const props = defineProps<{ labels: string[]; values: number[] }>();
const element = ref<HTMLDivElement>();
let chart: echarts.ECharts | undefined, observer: ResizeObserver | undefined;
/** 从全局设计令牌读取 ECharts Canvas 可使用的实际颜色值。 */
function themeColor(name: string) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** 根据当前消费数据渲染积分变化图表。 */
function render() {
const labelInterval = props.labels.length > 12 ? Math.ceil(props.labels.length / 6) - 1 : 0;
chart?.setOption({
color: [themeColor("--web-chart-primary")],
tooltip: {
trigger: "axis",
backgroundColor: themeColor("--web-chart-tooltip-bg"),
borderColor: themeColor("--web-chart-tooltip-line"),
textStyle: { color: themeColor("--web-chart-tooltip-text") },
},
grid: { left: 42, right: 18, top: 36, bottom: 28 },
xAxis: {
type: "category",
boundaryGap: false,
data: props.labels,
axisLine: { lineStyle: { color: themeColor("--web-chart-axis-line") } },
axisLabel: { color: themeColor("--web-chart-axis-text"), interval: labelInterval },
},
yAxis: {
type: "value",
minInterval: 1,
name: "积分",
nameTextStyle: { color: themeColor("--web-chart-axis-text") },
axisLabel: { color: themeColor("--web-chart-axis-text") },
splitLine: { lineStyle: { color: themeColor("--web-chart-split-line") } },
},
series: [
{ type: "line", smooth: true, symbol: "circle", symbolSize: 5, lineStyle: { width: 2 }, data: props.values },
],
});
}
onMounted(() => {
if (element.value) {
chart = echarts.init(element.value);
render();
observer = new ResizeObserver(() => chart?.resize());
observer.observe(element.value);
}
});
watch(() => [props.labels, props.values], render, { deep: true });
onBeforeUnmount(() => {
observer?.disconnect();
chart?.dispose();
});
</script>
<template>
<div
ref="element"
class="consumption-chart"
/>
</template>
<style scoped>
.consumption-chart {
width: 100%;
height: 210px;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<!-- 项目搜索输入组件,统一项目列表的搜索和清空交互。 -->
<script setup lang="ts">
import { Search, X } from "@/icons";
withDefaults(defineProps<{ modelValue: string; placeholder?: string }>(), {
placeholder: "搜索项目",
});
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
</script>
<template>
<div class="project-search-field">
<Search :size="17" />
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="modelValue"
type="button"
title="清空搜索"
@click="emit('update:modelValue', '')"
>
<X :size="16" />
</button>
</div>
</template>
<style scoped>
.project-search-field {
width: min(380px, 100%);
height: var(--control-height-lg);
padding: 0 var(--space-xs) 0 var(--space-md);
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--web-text-tertiary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
transition:
color 0.18s,
border-color 0.18s,
box-shadow 0.18s;
}
.project-search-field:focus-within {
color: var(--web-accent-dark);
border-color: var(--web-line-accent);
box-shadow: 0 0 0 2px var(--web-search-outline);
}
.project-search-field input {
min-width: 0;
width: 100%;
color: var(--web-text);
background: transparent;
border: 0;
font-size: 14px;
outline: 0;
appearance: none;
}
.project-search-field input::-webkit-search-cancel-button {
display: none;
appearance: none;
}
.project-search-field button {
width: var(--control-height-sm);
height: var(--control-height-sm);
padding: 0;
display: grid;
place-items: center;
flex: 0 0 var(--control-height-sm);
color: var(--web-control-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s;
}
.project-search-field button:hover:not(:disabled) {
color: var(--web-accent-muted);
background: var(--web-hover);
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<!-- 全局提示容器,负责展示、暂停、关闭和复制不同状态的临时消息。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch } from "vue";
import { Check, Close, CopyDocument, InfoFilled, WarningFilled } from "@element-plus/icons-vue";
import { useToastStore, type ToastItem } from "@/stores/toast";
const store = useToastStore(),
elapsed = reactive<Record<number, number>>({}),
paused = reactive<Record<number, boolean>>({});
let last = performance.now();
const timer = setInterval(() => {
const now = performance.now(),
delta = now - last;
last = now;
for (const item of store.visible) {
if (!paused[item.id]) elapsed[item.id] = (elapsed[item.id] || 0) + delta;
if (elapsed[item.id] >= item.duration) store.remove(item.id);
}
}, 50);
watch(
() => store.visible.map((i) => i.id),
(ids) =>
ids.forEach((id) => {
if (elapsed[id] === undefined) elapsed[id] = 0;
}),
);
onBeforeUnmount(() => clearInterval(timer));
const icons = { success: Check, warning: WarningFilled, error: WarningFilled, info: InfoFilled };
// 计算提示剩余时长比例,供进度条使用合成层动画展示。
const progress = (item: ToastItem) =>
computed(() => Math.max(0, 1 - (elapsed[item.id] || 0) / item.duration)).value;
// 复制错误消息的完整内容,便于用户反馈或排查问题。
async function copy(item: ToastItem) {
await navigator.clipboard.writeText(item.copyText || item.message);
}
</script>
<template>
<div class="toast-region">
<TransitionGroup name="toast-fade"
><section
v-for="item in store.visible"
:key="item.id"
class="app-toast"
:class="`app-toast--${item.type}`"
@mouseenter="paused[item.id] = true"
@mouseleave="paused[item.id] = false"
>
<el-icon><component :is="icons[item.type]" /></el-icon>
<div class="app-toast__copy">
<strong>{{ item.title }}</strong
><span>{{ item.message }}</span>
</div>
<button
v-if="item.type === 'error'"
class="icon-button"
title="复制完整错误"
@click="copy(item)"
>
<el-icon><CopyDocument /></el-icon></button
><button
class="icon-button"
title="关闭提示"
@click="store.remove(item.id)"
>
<el-icon><Close /></el-icon>
</button>
<div
class="app-toast__progress"
:style="{ '--toast-progress': progress(item) }"
/></section
></TransitionGroup>
</div>
</template>
+184
View File
@@ -0,0 +1,184 @@
<!-- 用户端侧栏,负责产品导航、品牌展示和账户入口。 -->
<script setup lang="ts">
import { Clapperboard, FileSearch, Film, Image, UserRound } from "@/icons";
import logoUrl from "@/assets/logo.png";
import WebUserEntry from "@/components/WebUserEntry.vue";
defineProps<{ activePath: string; collapsed?: boolean }>();
const emit = defineEmits<{ navigate: [path: string] }>();
const menus = [
{ path: "/drama-projects", label: "短剧创作", icon: Clapperboard, disabled: false },
{ path: "/redraw-projects", label: "剧本反推", icon: Film, disabled: false },
{ path: "/script-analysis", label: "剧本分析", icon: FileSearch, disabled: false },
{ path: "/product-images", label: "图片生成", icon: Image, disabled: false },
] as const;
</script>
<template>
<div
class="web-sidebar"
:class="{ 'is-collapsed': collapsed }"
>
<button
class="web-sidebar__brand"
type="button"
title="短剧创作"
@click="emit('navigate', '/drama-projects')"
>
<span
><img
:src="logoUrl"
alt=""
/></span>
<strong v-if="!collapsed">剧游AI</strong>
</button>
<nav class="web-sidebar__nav">
<button
v-for="item in menus"
:key="item.path"
type="button"
:disabled="item.disabled"
:class="{ active: activePath.startsWith(item.path) }"
:title="collapsed ? item.label : undefined"
@click="emit('navigate', item.path)"
>
<component
:is="item.icon"
:size="20"
/>
<span v-if="!collapsed">{{ item.label }}</span>
</button>
</nav>
<div class="web-sidebar__footer">
<button
v-if="collapsed"
type="button"
class="web-sidebar__account"
:class="{ active: activePath === '/account' }"
title="用户中心"
@click="emit('navigate', '/account')"
>
<UserRound :size="20" />
</button>
<WebUserEntry
v-else
:active="activePath === '/account'"
@navigate="emit('navigate', '/account')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.web-sidebar {
height: 100%;
padding: var(--space-lg) var(--space-md) var(--space-md);
display: flex;
flex-direction: column;
background: var(--web-sidebar);
border-right: 1px solid var(--web-line);
}
.web-sidebar__brand {
height: 48px;
padding: 0 var(--space-sm);
display: flex;
align-items: center;
gap: var(--space-md);
color: var(--web-text);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
border-radius: var(--radius-md);
transition: background-color 0.18s;
}
.web-sidebar__brand:hover {
background: var(--web-hover);
}
.web-sidebar__brand > span {
width: 36px;
height: 36px;
flex: 0 0 36px;
display: grid;
place-items: center;
overflow: hidden;
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-sm);
}
.web-sidebar__brand img {
width: 32px;
height: 32px;
object-fit: contain;
}
.web-sidebar__brand strong {
display: flex;
flex-direction: column;
font-size: 17px;
font-weight: 650;
line-height: 1.2;
white-space: nowrap;
}
.web-sidebar__nav {
margin-top: var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.web-sidebar__nav button,
.web-sidebar__account {
min-height: 44px;
padding: 0 var(--space-md);
display: flex;
align-items: center;
gap: var(--space-md);
color: var(--web-muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition:
background-color 0.18s,
color 0.18s,
border-color 0.18s;
}
.web-sidebar__nav button:hover:not(:disabled):not(.active),
.web-sidebar__account:hover:not(.active) {
color: var(--web-text);
background: var(--web-hover);
}
.web-sidebar__nav button:disabled {
color: var(--web-muted);
background: transparent;
cursor: not-allowed;
opacity: 0.55;
}
.web-sidebar__nav button.active,
.web-sidebar__account.active {
color: var(--web-brand-hover);
background: var(--web-brand-soft);
border-color: transparent;
font-weight: 600;
}
.web-sidebar__footer {
margin-top: auto;
padding-top: var(--space-md);
border-top: 1px solid var(--web-line);
}
.web-sidebar.is-collapsed {
padding-inline: var(--space-sm);
}
.web-sidebar.is-collapsed .web-sidebar__brand {
justify-content: center;
padding: 0;
}
.web-sidebar.is-collapsed .web-sidebar__nav button,
.web-sidebar.is-collapsed .web-sidebar__account {
width: 44px;
margin-inline: auto;
padding: 0;
justify-content: center;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<!-- 用户账户入口,负责资料入口展示和退出登录操作。 -->
<script setup lang="ts">
import { ref } from "vue";
import { ElMessageBox } from "element-plus";
import { LogOut, UserRound } from "@/icons";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
defineProps<{ active?: boolean }>();
const emit = defineEmits<{ navigate: [] }>();
const auth = useAuthStore();
const router = useRouter();
const loggingOut = ref(false);
/** 确认用户主动退出后注销当前会话,取消时保持登录状态。 */
async function logout() {
if (loggingOut.value) return;
try {
await ElMessageBox.confirm("退出后需要重新登录,确定退出当前账号吗?", "退出登录", {
type: "warning",
confirmButtonText: "退出登录",
cancelButtonText: "取消",
});
} catch (error) {
if (error === "cancel" || error === "close") return;
throw error;
}
loggingOut.value = true;
try {
await auth.logout();
await router.replace("/login");
} finally {
loggingOut.value = false;
}
}
</script>
<template>
<div
class="web-user-entry"
:class="{ active }"
>
<button
type="button"
class="web-user-entry__profile"
@click="emit('navigate')"
>
<span class="web-user-entry__avatar">
<img
v-if="auth.profile?.avatar_url"
:src="auth.profile.avatar_url"
alt=""
/>
<UserRound
v-else
:size="19"
/>
</span>
<span class="web-user-entry__copy">
<strong>{{ auth.profile?.username || auth.profile?.account || "用户中心" }}</strong>
<small>{{ Number(auth.account?.point_balance || 0).toFixed(2) }} 积分</small>
</span>
</button>
<button
type="button"
class="web-user-entry__logout"
title="退出登录"
:disabled="loggingOut"
@click="logout"
>
<LogOut :size="18" />
</button>
</div>
</template>
<style scoped lang="scss">
.web-user-entry {
padding: var(--space-sm);
display: flex;
align-items: center;
gap: var(--space-xs);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
}
.web-user-entry.active {
background: var(--web-brand-soft);
border-color: transparent;
}
.web-user-entry__profile {
min-width: 0;
padding: 0;
display: flex;
align-items: center;
flex: 1;
gap: var(--space-sm);
color: var(--web-text);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
}
.web-user-entry__avatar {
width: 36px;
height: 36px;
flex: 0 0 36px;
display: grid;
place-items: center;
overflow: hidden;
color: var(--web-muted);
background: var(--web-raised);
border-radius: var(--radius-sm);
}
.web-user-entry__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.web-user-entry__copy {
min-width: 0;
display: flex;
flex-direction: column;
}
.web-user-entry__copy strong,
.web-user-entry__copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-user-entry__copy strong {
font-size: 13px;
}
.web-user-entry__copy small {
margin-top: 2px;
color: var(--web-muted);
font-size: 11px;
}
.web-user-entry__logout {
width: var(--control-height-md);
height: var(--control-height-md);
flex: 0 0 var(--control-height-md);
display: grid;
place-items: center;
color: var(--web-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s;
}
.web-user-entry__logout:hover:not(:disabled) {
color: var(--web-danger);
background: var(--web-hover);
}
</style>
@@ -0,0 +1,53 @@
<!-- 用户中心分区错误状态,统一展示加载失败原因和重试入口。 -->
<script setup lang="ts">
import { CircleAlert } from "@/icons";
defineProps<{ message: string }>();
const emit = defineEmits<{ retry: [] }>();
</script>
<template>
<div class="account-section-state">
<CircleAlert :size="20" />
<div>
<strong>加载失败</strong>
<p>{{ message }}</p>
</div>
<el-button @click="emit('retry')">重新加载</el-button>
</div>
</template>
<style scoped lang="scss">
.account-section-state {
min-height: 116px;
padding: var(--space-lg);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-md);
color: var(--web-danger);
background: var(--web-danger-soft);
border: 1px solid var(--web-line-critical);
border-radius: var(--radius-md);
}
.account-section-state strong {
color: var(--web-text);
font-size: 14px;
}
.account-section-state p {
margin-top: var(--space-xs);
color: var(--web-text-secondary);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 560px) {
.account-section-state {
grid-template-columns: auto minmax(0, 1fr);
}
.account-section-state .el-button {
grid-column: 1 / -1;
width: 100%;
margin-left: 0;
}
}
</style>
@@ -0,0 +1,109 @@
<!-- 积分流水列表,统一用户中心摘要区和完整记录弹窗的展示规则。 -->
<script setup lang="ts">
import { formatDateTime } from "@/utils/datetime";
import type { PointRecord } from "@/components/account/types";
defineProps<{ records: PointRecord[]; emptyText: string }>();
/** formatAmount 将接口金额统一格式化为两位小数。 */
function formatAmount(value: unknown) {
return Number(value || 0).toFixed(2);
}
/** isIncome 根据有符号变动额区分积分收入和支出。 */
function isIncome(record: PointRecord) {
return Number(record.change_amount || 0) > 0;
}
</script>
<template>
<div
v-if="records.length"
class="record-list"
>
<div
v-for="record in records"
:key="record.id"
class="record-row"
>
<div>
<strong>{{ record.remark || "积分变动" }}</strong>
<time>{{ formatDateTime(record.created_at) }}</time>
</div>
<strong :class="{ income: isIncome(record) }">
{{ isIncome(record) ? "+" : "-" }}{{ formatAmount(record.amount) }}
</strong>
<span>余额 {{ formatAmount(record.balance_after) }}</span>
</div>
</div>
<div
v-else
class="empty-state"
>
{{ emptyText }}
</div>
</template>
<style scoped lang="scss">
.record-list {
display: flex;
flex-direction: column;
}
.record-row {
min-height: 44px;
padding: 4px 2px;
display: grid;
grid-template-columns: minmax(0, 1fr) 92px 104px;
align-items: center;
gap: var(--space-md);
border-bottom: 1px solid var(--web-line);
}
.record-row:last-child {
border-bottom: 0;
}
.record-row > div {
min-width: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 2px var(--space-sm);
overflow-wrap: anywhere;
}
.record-row > div > strong {
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
line-height: 1.35;
}
.record-row time,
.record-row > span {
color: var(--web-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: 1.4;
}
.record-row > strong {
color: var(--web-danger);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
font-variant-numeric: tabular-nums;
line-height: 1.3;
text-align: right;
}
.record-row > strong.income {
color: var(--web-success);
}
.empty-state {
padding: 48px 16px;
color: var(--web-muted);
line-height: 1.6;
text-align: center;
}
@media (max-width: 767px) {
.record-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.record-row > span {
grid-column: 1 / -1;
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
// 用户中心展示类型,统一积分流水及其分页响应的数据契约。
/** PointRecord 描述一条积分收入或支出流水。 */
export interface PointRecord {
id: string;
amount: string;
change_amount: string;
balance_after: string;
business_type: string;
remark?: string;
created_at: string;
}
/** PointRecordPage 描述后端返回的积分流水分页结果。 */
export interface PointRecordPage {
items: PointRecord[];
total: number;
page: number;
page_size: number;
}
@@ -0,0 +1,490 @@
/* 资产面板核心样式,负责面板主体、资产卡片和编辑器基础布局。 */
.asset-panel {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 11px;
overflow: hidden;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.asset-panel > header {
height: 40px;
padding: 0 12px 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 40px;
border-bottom: 1px solid var(--web-line-strong);
}
.asset-panel header div {
display: flex;
align-items: center;
gap: 8px;
}
.asset-panel header span {
color: var(--web-accent-dark);
font:
11px Arial,
sans-serif;
}
.asset-panel header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
cursor: pointer;
}
.asset-panel header button:disabled {
color: var(--web-text-subdued);
border-color: var(--web-line-strong);
cursor: not-allowed;
}
.asset-detail-header {
justify-content: flex-start !important;
gap: 9px;
}
.asset-detail-header strong {
min-width: 0;
overflow: hidden;
color: var(--web-text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-tabs {
padding: 8px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
border-bottom: 1px solid var(--web-line-strong);
}
.asset-tabs.has-media {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.asset-tabs button {
min-width: 0;
height: 32px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
color: var(--web-text-note);
background: var(--web-surface);
border: 1px solid transparent;
border-radius: 6px;
font-size: 10px;
cursor: pointer;
}
.asset-tabs button > .el-icon {
font-size: 15px;
}
.asset-tabs button.active {
color: var(--web-accent-dark);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.asset-tabs small {
font:
9px Arial,
sans-serif;
}
.asset-list {
min-height: 86px;
padding: 10px;
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
overflow: auto;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.asset-section {
min-width: 0;
}
.asset-section-header {
margin-bottom: 7px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text-caption);
}
.asset-section-header strong {
font-size: 10px;
font-weight: 600;
}
.asset-section-header span {
font:
9px Arial,
sans-serif;
}
.asset-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 8px;
}
.episode-assets .asset-card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.asset-section-empty {
height: 42px;
display: grid;
place-items: center;
color: var(--web-text-neutral);
background: var(--web-surface-soft);
border: 1px dashed var(--web-line-strong);
border-radius: 6px;
font-size: 9px;
}
.asset-card {
position: relative;
min-width: 0;
overflow: hidden;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
transition:
border-color 0.16s,
background-color 0.16s;
}
.asset-card:hover,
.asset-card:focus-within {
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.asset-card-main {
width: 100%;
padding: 7px;
display: flex;
flex-direction: column;
gap: 7px;
color: var(--web-text-secondary);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.episode-assets .asset-card-main {
padding: 4px;
gap: 4px;
}
.asset-card-main:focus-visible {
outline: 2px solid var(--web-focus-ring-strong);
outline-offset: -2px;
}
.asset-thumb {
position: relative;
width: 100%;
aspect-ratio: 1;
display: grid;
place-items: center;
overflow: hidden;
background: var(--web-hover);
border-radius: 5px;
}
.asset-thumb img,
.asset-thumb video,
.asset-thumb :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-thumb i {
color: var(--web-accent-dark);
font-size: 25px;
font-style: normal;
}
.episode-assets .asset-thumb i {
font-size: 18px;
}
.asset-copy {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
gap: 4px;
}
.asset-copy b {
min-width: 0;
overflow: hidden;
flex: 1;
font-size: 11px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-assets .asset-copy b {
font-size: 9px;
}
.asset-audio-badge {
position: absolute;
right: 5px;
bottom: 5px;
z-index: 2;
min-width: 20px;
height: 20px;
padding: 0 5px;
display: grid;
grid-auto-flow: column;
place-items: center;
gap: 3px;
color: var(--web-accent-dark);
background: var(--web-media-overlay);
border: 1px solid var(--web-line-emphasis);
border-radius: 10px;
}
.asset-audio-badge small,
.asset-preview-audio-badge small {
font-size: 9px;
white-space: nowrap;
}
.asset-empty {
min-height: 68px;
display: grid;
place-items: center;
color: var(--web-text-tertiary);
font-size: 11px;
}
.asset-editor {
min-height: 0;
padding: 12px;
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
overflow: auto;
background: var(--web-surface-soft);
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.asset-preview {
position: relative;
width: 100%;
height: 150px;
display: grid;
place-items: center;
flex: 0 0 150px;
overflow: hidden;
color: var(--web-accent-dark);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.asset-preview > img,
.asset-preview > video,
.asset-preview > :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: contain;
}
.asset-preview > i {
font-size: 32px;
font-style: normal;
}
.asset-preview-audio-badge {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
min-width: 24px;
height: 24px;
padding: 0 6px;
display: grid;
grid-auto-flow: column;
place-items: center;
gap: 4px;
color: var(--web-accent-dark);
background: var(--web-media-overlay);
border: 1px solid var(--web-line-emphasis);
border-radius: 12px;
}
.asset-preview-actions {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: var(--web-preview-overlay);
opacity: 0;
transition: opacity 0.18s;
}
.asset-preview:hover .asset-preview-actions,
.asset-preview:focus-within .asset-preview-actions {
opacity: 1;
}
.asset-preview-actions label,
.asset-preview-actions button {
width: 36px;
height: 36px;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 50%;
cursor: pointer;
}
.asset-preview-actions label:hover,
.asset-preview-actions button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-accent);
}
.asset-preview-actions label.disabled,
.asset-preview-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.asset-preview-actions input,
.audio-upload input {
display: none;
}
.asset-name-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px;
gap: 8px;
}
.asset-editor:not(.storyboard-media-editor) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-content: start;
}
.asset-editor:not(.storyboard-media-editor) > .prompt-field,
.asset-editor:not(.storyboard-media-editor) > .asset-actions,
.asset-editor:not(.storyboard-media-editor) > .asset-preview {
grid-column: 1 / -1;
}
.asset-reference-upload {
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 5px;
color: var(--web-text-subtle);
font-size: 10px;
}
.asset-reference-box {
position: relative;
width: 100%;
height: 92px;
flex: 0 0 92px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
overflow: hidden;
color: var(--web-accent-dark);
background: var(--web-surface-soft);
border: 1px dashed var(--web-line-strong);
border-radius: 7px;
cursor: pointer;
}
.asset-reference-box img,
.asset-reference-box :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-reference-box input {
position: absolute;
inset: 0;
z-index: 1;
opacity: 0;
cursor: pointer;
}
.asset-reference-remove {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
width: 26px;
height: 26px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-accent-dark);
background: var(--web-error-overlay);
border: 1px solid var(--web-line-critical);
border-radius: 50%;
cursor: pointer;
}
.asset-editor:not(.storyboard-media-editor) > .asset-name-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.asset-name-row > label,
.asset-editor > label {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.asset-name-row label > span:first-child,
.asset-editor > label > span {
color: var(--web-text-subtle);
font-size: 10px;
}
.audio-upload {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--web-text-secondary);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
font-size: 10px;
cursor: pointer;
}
.audio-upload:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-emphasis);
}
.asset-editor .prompt-field {
height: 260px;
min-height: 260px;
flex: 0 0 260px;
}
.prompt-field :deep(.el-textarea),
.prompt-field :deep(.el-textarea__inner) {
height: 100%;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
scrollbar-width: thin;
}
.asset-actions {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 8px;
}
.asset-detail-enter-active,
.asset-detail-leave-active {
transition:
opacity 0.18s,
transform 0.18s;
}
.asset-detail-enter-from,
.asset-detail-leave-to {
opacity: 0;
transform: translateX(16px);
}
@media (max-width: 820px) {
.asset-editor:not(.storyboard-media-editor) {
display: flex;
}
.asset-name-row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,47 @@
/* 资产预览对话框全局主题,负责弹窗边框、标题和关闭按钮样式。 */
.asset-preview-dialog.el-dialog {
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
/* 按图片比例自然收缩,再限制在当前视口内,避免底部留白或内容溢出。 */
height: auto;
max-height: calc(100dvh - 32px);
/* 覆盖 Element Plus 默认的顶部 15vh 间距,让预览窗口始终在视口中垂直居中。 */
position: absolute;
top: 50%;
left: 50%;
margin: 0 !important;
transform: translate(-50%, -50%);
}
.asset-preview-dialog.el-dialog .el-dialog__header {
margin-right: 0;
border-bottom: 1px solid var(--web-line-strong);
}
.asset-preview-dialog.el-dialog .el-dialog__title,
.asset-preview-dialog.el-dialog .el-dialog__headerbtn .el-dialog__close {
color: var(--web-text);
}
.asset-preview-dialog.el-dialog .el-dialog__body {
position: relative;
min-height: 0;
padding: 14px;
background: var(--web-surface-soft);
overflow: hidden;
}
/* 图片预览按原始比例占用空间,避免弹窗为较矮图片保留无效高度。 */
.asset-preview-dialog.el-dialog .asset-original {
width: 100%;
height: auto;
min-height: 0;
max-height: calc(100dvh - 120px);
}
@media (max-width: 600px) {
.asset-preview-dialog.el-dialog {
max-height: calc(100dvh - 24px);
max-width: calc(100vw - 24px);
}
}
@@ -0,0 +1,496 @@
/* 资产媒体样式,负责资产操作、历史记录及分镜媒体状态展示。 */
.asset-card-actions {
position: absolute;
top: 6px;
right: 6px;
z-index: 3;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 0.16s;
}
.asset-card:hover .asset-card-actions,
.asset-card:focus-within .asset-card-actions {
opacity: 1;
}
.asset-delete,
.asset-add {
width: 24px;
height: 24px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
cursor: pointer;
}
.asset-delete:hover,
.asset-delete:focus-visible {
color: var(--web-accent-dark);
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.asset-add:hover,
.asset-add:focus-visible {
color: var(--web-accent-dark);
background: var(--web-surface);
border-color: var(--web-switch-border);
}
.asset-add:disabled {
color: var(--web-text-subdued);
border-color: var(--web-line-strong);
cursor: not-allowed;
}
.current-asset-indicator {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
width: 7px;
height: 7px;
background: var(--web-success-bright);
border: 1px solid var(--web-success-border);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--web-current-outline);
}
.current-asset-indicator:after {
position: absolute;
inset: -1px;
content: "";
border: 1px solid var(--web-success-bright);
border-radius: 50%;
animation: asset-current-ripple 1.8s ease-out infinite;
}
@keyframes asset-current-ripple {
0% {
opacity: 0.8;
transform: scale(1);
}
70%,
100% {
opacity: 0;
transform: scale(2.6);
}
}
@media (hover: none) {
.asset-card-actions {
opacity: 1;
}
}
.asset-detail-leave-active {
display: none;
}
.asset-name-row.single {
grid-template-columns: 1fr;
}
.asset-image-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--web-accent-dark);
background: var(--web-media-overlay-soft);
font-size: 11px;
line-height: 1.3;
}
.asset-image-loading .el-icon {
font-size: 28px;
animation: asset-spin 1s linear infinite;
}
.asset-image-loading b {
display: block;
line-height: 1.3;
}
@keyframes asset-spin {
to {
transform: rotate(360deg);
}
}
.asset-thumb {
position: relative;
}
.asset-thumb-loading {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: var(--web-accent-dark);
background: var(--web-media-overlay-medium);
font-size: 21px;
}
.asset-thumb-loading :deep(svg) {
animation: asset-spin 1s linear infinite;
}
.asset-actions {
grid-template-columns: minmax(0, 1fr);
}
.asset-actions :deep(.el-button) {
min-width: 0;
}
.asset-preview.storyboard-media-preview {
height: 210px !important;
flex-basis: 210px !important;
}
.storyboard-media-empty {
display: flex;
align-items: center;
flex-direction: column;
gap: 8px;
color: var(--web-text-tertiary);
font-size: 11px;
}
.storyboard-media-empty .el-icon {
color: var(--web-accent-dark);
font-size: 30px;
}
.storyboard-prompt-field {
min-height: 200px;
height: 200px;
flex: 0 0 200px;
display: flex;
flex-direction: column;
gap: 5px;
padding-right: 3px;
box-sizing: border-box;
}
.storyboard-prompt-field > span,
.storyboard-reference-upload > span:first-child {
color: var(--web-text-placeholder);
font-size: 10px;
}
.storyboard-prompt-field :deep(.asset-prompt-editor),
.storyboard-prompt-field :deep(.prompt-content) {
height: 100%;
}
.storyboard-prompt-field :deep(.asset-prompt-editor) {
min-width: 0;
padding: 0;
}
.storyboard-prompt-field :deep(.prompt-content) {
box-sizing: border-box;
border: 1px solid var(--web-line-strong);
border-radius: 7px;
background: var(--web-surface-soft);
color: var(--web-text-secondary);
line-height: 1.65;
scrollbar-gutter: stable;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
scrollbar-width: thin;
}
.storyboard-prompt-field :deep(.prompt-content:hover) {
border-color: var(--web-line-emphasis);
}
.storyboard-prompt-field :deep(.prompt-content:focus) {
border-color: var(--web-accent-dark);
box-shadow: 0 0 0 1px var(--web-accent-dark);
}
.storyboard-reference-upload {
display: flex;
flex-direction: column;
gap: 5px;
}
.storyboard-reference-upload .asset-reference-box {
height: 72px;
flex-basis: 72px;
}
.storyboard-media-summary {
display: flex;
align-items: center;
justify-content: space-between;
}
.storyboard-media-summary strong {
color: var(--web-text);
font-size: 13px;
}
.storyboard-media-summary span {
color: var(--web-text-note);
font-size: 10px;
}
.storyboard-media-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.asset-history {
flex: 0 0 auto;
padding: 0 12px 12px;
background: var(--web-surface-soft);
}
.asset-history > header {
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text);
font-size: 11px;
}
.asset-history > header small,
.asset-history-limit {
color: var(--web-accent-dark);
font-size: 9px;
}
.asset-history-list {
min-height: 72px;
display: flex;
gap: 7px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.asset-history-list::-webkit-scrollbar {
height: 7px;
}
.asset-history-list::-webkit-scrollbar-track {
background: var(--web-surface);
border-radius: 7px;
}
.asset-history-list::-webkit-scrollbar-thumb {
background: var(--web-danger-soft);
border: 2px solid var(--web-line-strong);
border-radius: 7px;
}
.asset-history-item {
position: relative;
width: 82px;
height: 72px;
flex: 0 0 82px;
overflow: hidden;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
}
.asset-history-item > img,
.asset-history-item > video,
.asset-history-item > :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-history-item > video,
.asset-history-item > :deep(.loading-media-element) {
pointer-events: none;
}
.asset-history-actions {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
background: var(--web-media-overlay-dense);
opacity: 0;
transition: opacity 0.15s;
}
.asset-history-item:hover .asset-history-actions,
.asset-history-item:focus-within .asset-history-actions {
opacity: 1;
}
.asset-history-actions button {
width: 20px;
height: 20px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-hover);
border: 1px solid var(--web-line-emphasis);
border-radius: 4px;
cursor: pointer;
}
.asset-history-actions button:hover {
color: var(--web-text-strong);
background: var(--web-primary);
}
.asset-history-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.asset-history-empty {
display: grid;
min-width: 100%;
place-items: center;
color: var(--web-text-disabled);
font-size: 10px;
}
.storyboard-video-assets {
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.storyboard-video-actions {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.storyboard-video-actions :deep(.duration-trigger) {
width: 80px;
flex: 0 0 80px;
}
.storyboard-video-generate {
width: 100%;
flex: 1;
}
.storyboard-video-limit {
margin-top: -5px;
color: var(--web-accent-dark);
font-size: 10px;
line-height: 1.5;
text-align: center;
}
.storyboard-video-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.storyboard-video-card {
position: relative;
min-width: 0;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
cursor: pointer;
transition:
background-color 0.15s,
border-color 0.15s;
}
.storyboard-video-card:hover,
.storyboard-video-card:focus-within {
color: var(--web-text);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.storyboard-video-card:focus-within {
outline: 1px solid var(--web-switch-border);
outline-offset: 1px;
}
.storyboard-video-card.active {
border-color: var(--web-switch-border);
background: var(--web-scrollbar-thumb-hover);
}
.storyboard-video-preview {
position: relative;
min-width: 0;
padding: 0;
display: flex;
flex-direction: column;
color: inherit;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.storyboard-video-card video,
.storyboard-video-card :deep(.loading-media-element),
.storyboard-video-loading {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--web-surface-soft);
pointer-events: none;
}
.storyboard-video-loading {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
color: var(--web-text-media-muted);
}
.storyboard-video-loading .el-icon {
animation: asset-spin 1s linear infinite;
}
.storyboard-video-card strong {
padding: 7px 8px;
font-size: 10px;
font-weight: 500;
}
.storyboard-video-delete,
.storyboard-video-download {
position: absolute;
top: 5px;
width: 24px;
height: 24px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 5px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.15s,
background-color 0.15s,
color 0.15s;
}
.storyboard-video-delete {
right: 5px;
}
.storyboard-video-download {
right: 34px;
}
.storyboard-video-card:hover .storyboard-video-delete,
.storyboard-video-card:hover .storyboard-video-download,
.storyboard-video-card:focus-within .storyboard-video-delete,
.storyboard-video-card:focus-within .storyboard-video-download {
opacity: 1;
pointer-events: auto;
}
.storyboard-video-delete:hover {
color: var(--web-text-strong);
background: var(--web-primary);
}
.storyboard-video-download:hover {
color: var(--web-text-strong);
background: var(--web-primary);
}
.storyboard-video-empty {
grid-column: 1 / -1;
padding: 28px 8px;
text-align: center;
color: var(--web-text-disabled);
font-size: 11px;
}
.storyboard-video-player-launcher {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.storyboard-media-actions :deep(.el-button) {
min-width: 0;
margin-left: 0;
}
@media (max-width: 520px) {
.storyboard-video-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (hover: none) {
.asset-preview-actions {
opacity: 1;
}
.storyboard-video-delete {
opacity: 1;
pointer-events: auto;
}
}
@@ -0,0 +1,29 @@
/* 资产预览样式,负责预览尺寸、缩放入口及历史播放器覆盖。 */
.asset-preview {
height: 112px !important;
flex-basis: 112px !important;
}
.asset-editor .prompt-field {
min-height: 190px;
}
.asset-preview > img,
.asset-preview > :deep(.loading-media-element) {
cursor: zoom-in;
}
.asset-original {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
.asset-history-player {
width: 100%;
height: 100%;
min-height: 0;
}
@media (max-width: 600px) {
.asset-history-player {
height: 100%;
min-height: 0;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,791 @@
<!-- 资产提示词编辑器,负责资产提及输入、候选筛选和内容同步。 -->
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { X } from "@/icons";
import type { ProjectAsset } from "@/api/creative";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
const props = defineProps<{
modelValue: string;
assets: ProjectAsset[];
placeholder?: string;
expanded?: boolean;
readonly?: boolean;
variant?: "project" | "media" | "drama-storyboard" | "redraw";
floatingPreview?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [value: string]; blur: [] }>();
const editor = ref<HTMLElement>();
const mentionOpen = ref(false);
const mentionQuery = ref("");
const mentionCategory = ref("character");
const mentionLength = ref(0);
const activeIndex = ref(0);
const floatingPreview = ref<{ url: string; alt: string; left: string; top: string }>();
let mentionRange: Range | null = null;
let rendering = false;
const typeNames: Record<string, string> = { character: "角色", scene: "场景", prop: "道具", custom: "自定义" };
const projectMentionCategories = [
{ key: "character", label: "角色" },
{ key: "voice", label: "音色" },
{ key: "scene", label: "场景" },
{ key: "prop", label: "道具" },
{ key: "custom", label: "自定义" },
];
const mentionCategories = computed(() =>
props.variant === "media"
? [
{ key: "character", label: "图片" },
{ key: "voice", label: "音频" },
{ key: "video", label: "视频" },
]
: props.variant === "redraw"
? projectMentionCategories.filter(
(item) => item.key === "character" || item.key === "scene" || item.key === "prop",
)
: projectMentionCategories,
);
const mentionAssets = computed(() => {
const query = mentionQuery.value.trim().toLowerCase();
const assets = props.assets.filter((asset) => {
if (props.variant === "drama-storyboard") return !query || asset.name.toLowerCase().includes(query);
const matchesCategory =
mentionCategory.value === "voice"
? Boolean(asset.audio_url)
: mentionCategory.value === "video"
? Boolean(asset.video_url)
: asset.asset_type === mentionCategory.value &&
(props.variant !== "media" || (!asset.audio_url && !asset.video_url));
return matchesCategory && (!query || asset.name.toLowerCase().includes(query));
});
return props.variant === "drama-storyboard" ? assets : assets.slice(0, 12);
});
/** 判断资产是否为剧集分镜图片。 */
function isStoryboardImage(asset: ProjectAsset) {
return props.variant === "drama-storyboard" && asset.id.startsWith("storyboard-image-");
}
/** 获取资产提及标记使用的语义类型。 */
function tokenType(asset: ProjectAsset) {
if (isStoryboardImage(asset)) return "storyboard-image";
return asset.audio_url && asset.asset_type === "character" ? "voice" : asset.asset_type;
}
/** 根据编辑器模式生成资产提及标记的类型文案。 */
function tokenLabel(asset: ProjectAsset) {
if (props.variant === "media") return asset.video_url ? "视频" : asset.audio_url ? "音频" : "图片";
if (isStoryboardImage(asset)) return "分镜图";
if (props.variant === "drama-storyboard" && asset.asset_type === "character" && asset.audio_url) return "人物 / 音色";
return tokenType(asset) === "voice" ? "音色" : typeNames[asset.asset_type] || "资产";
}
/** 创建可插入编辑器的资产提及节点及其预览内容。 */
function createToken(asset: ProjectAsset) {
const token = document.createElement("span");
token.className = `mention-token mention-token--${tokenType(asset)}`;
token.contentEditable = "false";
token.dataset.assetId = asset.id;
token.dataset.assetName = asset.name;
const showImage = Boolean(asset.image_url) && props.variant !== "media" && props.variant !== "redraw";
const visual = showImage ? document.createElement("img") : document.createElement("b");
if (showImage) {
(visual as HTMLImageElement).src = asset.image_url!;
(visual as HTMLImageElement).alt = asset.name;
token.dataset.previewUrl = asset.image_url!;
} else visual.textContent = tokenLabel(asset);
const name = document.createElement("span");
name.className = "mention-name";
name.textContent = asset.name;
const nodes: Node[] = [visual, name];
if (!props.readonly) {
const remove = document.createElement("button");
remove.type = "button";
remove.className = "mention-remove";
remove.dataset.removeMention = "true";
remove.title = `移除资产 ${asset.name}`;
remove.textContent = "删除";
nodes.push(remove);
}
if (showImage && !props.floatingPreview) {
const preview = document.createElement("span");
preview.className = "mention-preview";
preview.contentEditable = "false";
const previewImage = document.createElement("img");
previewImage.src = asset.image_url;
previewImage.alt = asset.name;
preview.append(previewImage);
nodes.push(preview);
}
token.append(...nodes);
return token;
}
/** 在页面级浮层中展示资产缩略图,避免被编辑器及其父容器裁切。 */
function showFloatingPreview(event: MouseEvent) {
if (!props.floatingPreview || !(event.target instanceof Element)) return;
const token = event.target.closest<HTMLElement>(".mention-token[data-preview-url]");
const root = editor.value;
if (!token || !root || !token.dataset.previewUrl) return;
const tokenRect = token.getBoundingClientRect();
const previewSize = 154;
const gap = 8;
const viewportPadding = 8;
const maxLeft = window.innerWidth - previewSize - viewportPadding;
const maxTop = window.innerHeight - previewSize - viewportPadding;
// 优先放在标签右侧,空间不足时依次尝试左侧、下方和上方,避免预览遮挡标签或编辑器外的标题。
const candidates = [
{ left: tokenRect.right + gap, top: tokenRect.top },
{ left: tokenRect.left - previewSize - gap, top: tokenRect.top },
{ left: tokenRect.left, top: tokenRect.bottom + gap },
{ left: tokenRect.left, top: tokenRect.top - previewSize - gap },
];
const placement = candidates.find(
(candidate) =>
candidate.left >= viewportPadding &&
candidate.left <= maxLeft &&
candidate.top >= viewportPadding &&
candidate.top <= maxTop,
) || {
left: Math.min(Math.max(tokenRect.left, viewportPadding), maxLeft),
top: Math.min(Math.max(tokenRect.bottom + gap, viewportPadding), maxTop),
};
floatingPreview.value = {
url: token.dataset.previewUrl,
alt: token.dataset.assetName || "",
left: `${placement.left}px`,
top: `${placement.top}px`,
};
}
/** 鼠标离开资产标签时关闭页面级缩略图浮层。 */
function hideFloatingPreview(event: MouseEvent) {
if (!(event.target instanceof Element)) return;
const token = event.target.closest<HTMLElement>(".mention-token[data-preview-url]");
if (token && event.relatedTarget instanceof Node && token.contains(event.relatedTarget)) return;
floatingPreview.value = undefined;
}
/** 在编辑器滚动或失焦时关闭缩略图浮层,避免浮层停留在旧位置。 */
function closeFloatingPreview() {
floatingPreview.value = undefined;
}
/** 将外部字符串值解析并渲染为文本与资产提及节点。 */
function renderValue() {
closeFloatingPreview();
const root = editor.value;
if (!root || document.activeElement === root) return;
rendering = true;
root.replaceChildren();
const assets = [...props.assets].filter((a) => a.name.trim()).sort((a, b) => b.name.length - a.name.length);
const byName = new Map(assets.map((a) => [a.name, a]));
const names = assets.map((a) => a.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
// 生成后的提示词可能直接连接中英文内容,因此提及匹配不能依赖尾随空格或标点。
const regex = names.length ? new RegExp(`@(${names.join("|")})`, "g") : null;
let index = 0;
if (regex)
for (const match of props.modelValue.matchAll(regex)) {
const start = match.index || 0;
if (start > index) root.append(document.createTextNode(props.modelValue.slice(index, start)));
const asset = byName.get(match[1]);
root.append(asset ? createToken(asset) : document.createTextNode(match[0]));
index = start + match[0].length;
}
if (index < props.modelValue.length) root.append(document.createTextNode(props.modelValue.slice(index)));
rendering = false;
}
/** 将编辑器 DOM 还原为包含资产提及语法的纯文本。 */
function rawValue() {
const root = editor.value;
if (!root) return "";
/** 递归提取单个编辑器节点对应的纯文本。 */
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
const element = node as HTMLElement;
if (element.classList?.contains("mention-token")) return `@${element.dataset.assetName || ""}`;
if (element.tagName === "BR") return "\n";
const value = Array.from(node.childNodes).map(walk).join("");
return element.tagName === "DIV" ? `${value}\n` : value;
};
return Array.from(root.childNodes).map(walk).join("").replace(/\n$/, "");
}
/** 在非内部渲染阶段向父组件同步编辑结果。 */
function emitValue() {
if (!rendering) emit("update:modelValue", rawValue());
}
/** 检测光标前的提及查询并保存候选插入位置。 */
function detectMention() {
const root = editor.value,
selection = window.getSelection();
if (!root || !selection?.rangeCount || !root.contains(selection.anchorNode)) {
mentionOpen.value = false;
return;
}
const caretRange = selection.getRangeAt(0).cloneRange();
const range = caretRange.cloneRange();
range.selectNodeContents(root);
range.setEnd(selection.anchorNode!, selection.anchorOffset);
mentionRange = caretRange;
const match = range.toString().match(/@([^@\s,。;:,.!?]*)$/);
mentionOpen.value = Boolean(match);
if (!match) return;
mentionQuery.value = match[1] || "";
mentionLength.value = match[0].length;
activeIndex.value = 0;
}
/** 处理编辑器输入并刷新提及候选状态。 */
function input() {
closeFloatingPreview();
emitValue();
detectMention();
}
/** 关闭提及面板并恢复编辑器焦点。 */
async function closeMention() {
mentionOpen.value = false;
mentionRange = null;
await nextTick();
editor.value?.focus();
}
/** 在当前提及范围插入资产标记并同步外部值。 */
async function insert(asset: ProjectAsset) {
const root = editor.value,
selection = window.getSelection();
if (!root || (!selection?.rangeCount && !mentionRange)) return;
const range = mentionRange?.cloneRange() || selection!.getRangeAt(0);
let node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) {
node = document.createTextNode("");
range.insertNode(node);
range.setStart(node, 0);
}
const text = node.textContent || "";
const end = range.startOffset;
const start = Math.max(0, end - mentionLength.value);
node.textContent = text.slice(0, start) + text.slice(end);
range.setStart(node, start);
range.collapse(true);
const token = createToken(asset),
space = document.createTextNode(" ");
range.insertNode(space);
range.insertNode(token);
range.setStartAfter(space);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
mentionOpen.value = false;
mentionRange = null;
emitValue();
await nextTick();
root.focus();
}
/** 删除用户点击的资产提及标记。 */
function removeMention(event: MouseEvent) {
const target = event.target as HTMLElement;
const button = target.closest<HTMLButtonElement>("[data-remove-mention]");
if (!button) return;
event.preventDefault();
event.stopPropagation();
closeFloatingPreview();
const token = button.closest<HTMLElement>(".mention-token");
token?.remove();
emitValue();
editor.value?.focus();
}
/** 编辑器失焦后延迟关闭候选面板并通知父组件。 */
function blur() {
closeFloatingPreview();
window.setTimeout(() => {
if (!document.activeElement?.closest(".mention-panel")) {
mentionOpen.value = false;
mentionRange = null;
}
}, 120);
emit("blur");
}
watch(
() => [props.modelValue, props.assets] as const,
() => nextTick(renderValue),
{ deep: true },
);
onMounted(renderValue);
</script>
<template>
<div
class="asset-prompt-editor"
:class="{
expanded,
readonly,
media: variant === 'media',
'drama-storyboard': variant === 'drama-storyboard',
}"
>
<div
ref="editor"
class="prompt-content"
:contenteditable="readonly ? 'false' : 'true'"
:data-placeholder="placeholder"
@input="input"
@mousedown="removeMention"
@mouseover="showFloatingPreview"
@mouseout="hideFloatingPreview"
@scroll="closeFloatingPreview"
@blur="blur"
></div>
<Teleport to="body">
<span
v-if="floatingPreview"
class="mention-floating-preview"
:style="{ left: floatingPreview.left, top: floatingPreview.top }"
>
<img
:src="floatingPreview.url"
:alt="floatingPreview.alt"
/>
</span>
</Teleport>
<div
v-if="mentionOpen"
class="mention-panel"
@mousedown.stop
>
<button
class="mention-close"
type="button"
title="关闭资产面板"
@mousedown.prevent.stop="closeMention"
>
<X :size="16" />
</button>
<div
v-if="variant === 'drama-storyboard'"
class="mention-flat-header"
>
<strong>当前分镜可用资产</strong><small>{{ mentionAssets.length }} 项</small>
</div>
<div
v-else
class="mention-header"
>
<input
v-if="variant !== 'media'"
v-model="mentionQuery"
class="mention-search"
type="search"
placeholder="搜索资产"
@blur="blur"
/>
<div class="mention-categories">
<button
v-for="category in mentionCategories"
:key="category.key"
type="button"
:class="{ active: mentionCategory === category.key }"
@mousedown.prevent="
mentionCategory = category.key;
activeIndex = 0;
"
>
{{ category.label }}
</button>
</div>
</div>
<div class="mention-list">
<button
v-for="(asset, index) in mentionAssets"
:key="asset.id"
type="button"
:class="{ active: index === activeIndex }"
@mouseenter="activeIndex = index"
@mousedown.prevent="insert(asset)"
>
<span
><LoadingMedia
v-if="asset.image_url"
type="image"
compact
:src="asset.image_url"
alt=""
/><i v-else>{{ asset.name.slice(0, 1) }}</i></span
><b>{{ asset.name }}</b
><small>{{ tokenLabel(asset) }}</small>
</button>
<div v-if="!mentionAssets.length">没有匹配的资产</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.asset-prompt-editor {
position: relative;
min-height: 0;
height: 100%;
overflow: hidden;
}
.prompt-content {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
padding: 13px 13px 28px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
outline: none;
font:
13px/1.7 Arial,
"Microsoft YaHei",
sans-serif;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
scrollbar-width: thin;
}
.prompt-content:empty:before {
content: attr(data-placeholder);
color: var(--web-prompt-placeholder);
pointer-events: none;
}
.prompt-content:focus {
border-color: var(--web-line-emphasis);
box-shadow: 0 0 0 2px var(--web-focus-ring-soft);
}
.readonly .prompt-content {
cursor: text;
}
.expanded {
overflow: visible;
}
.expanded .prompt-content {
min-height: 60vh;
padding-right: 13px;
font-size: 14px;
}
.prompt-content :deep(.mention-token) {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 2px;
padding: 1px 5px;
border: 1px solid var(--web-mention-default-line);
border-radius: 4px;
background: var(--web-hover);
color: var(--web-text);
line-height: 1.45;
vertical-align: middle;
white-space: nowrap;
}
.prompt-content :deep(.mention-token b) {
color: var(--web-accent-dark);
font-size: 10px;
}
.prompt-content :deep(.mention-token .mention-name) {
min-width: 0;
max-width: min(110px, 22.5vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-content :deep(.mention-token .mention-remove) {
position: absolute;
top: 50%;
right: 3px;
z-index: 30;
width: 34px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transform: translateY(-50%);
border: 1px solid var(--web-mention-remove-line);
border-radius: 4px;
background: var(--web-danger-soft);
color: var(--web-text);
font-size: 10px;
font-weight: 600;
line-height: 1;
visibility: hidden;
opacity: 0;
pointer-events: none;
cursor: pointer;
}
.prompt-content :deep(.mention-token:hover .mention-remove),
.prompt-content :deep(.mention-token:focus-within .mention-remove) {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.prompt-content :deep(.mention-token .mention-remove:hover) {
background: var(--web-mention-remove-hover);
border-color: var(--web-mention-remove-hover-line);
}
.prompt-content :deep(.mention-token--voice) {
background: var(--web-hover);
border-color: var(--web-mention-voice-line);
}
.prompt-content :deep(.mention-token--scene) {
background: var(--web-mention-scene-bg);
border-color: var(--web-mention-scene-line);
}
.prompt-content :deep(.mention-token--prop) {
background: var(--web-mention-prop-bg);
border-color: var(--web-mention-prop-line);
}
.prompt-content :deep(.mention-token--custom) {
background: var(--web-hover);
border-color: var(--web-mention-custom-line);
}
.prompt-content :deep(.mention-token--storyboard-image) {
background: var(--web-hover);
border-color: var(--web-mention-storyboard-line);
}
.mention-panel {
position: absolute;
top: 50%;
left: 50%;
z-index: 8;
width: min(420px, calc(100% - 24px));
height: 360px;
box-sizing: border-box;
padding: 12px;
transform: translate(-50%, -50%);
overflow: hidden;
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 10px;
box-shadow: var(--web-shadow-mention);
}
.media .mention-panel {
height: 300px;
}
.drama-storyboard .mention-panel {
width: min(350px, calc(100% - 16px));
height: auto;
max-height: 280px;
padding: 10px;
}
.mention-close {
position: absolute;
top: 9px;
right: 9px;
z-index: 2;
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text-caption);
background: var(--web-surface);
border: 0;
border-radius: 6px;
cursor: pointer;
}
.mention-close:hover,
.mention-close:focus-visible {
color: var(--web-text);
background: var(--web-hover);
outline: none;
}
.mention-header {
padding-right: 34px;
display: flex;
flex-direction: column;
gap: 10px;
}
.mention-flat-header {
min-height: 34px;
padding: 0 38px 0 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--web-line-strong);
}
.mention-flat-header strong {
color: var(--web-text);
font-size: 12px;
}
.mention-flat-header small {
color: var(--web-text-tertiary);
font-size: 10px;
white-space: nowrap;
}
.mention-search {
box-sizing: border-box;
width: 100%;
height: 34px;
padding: 0 10px;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
outline: none;
}
.mention-search:focus {
border-color: var(--web-search-line);
box-shadow: 0 0 0 2px var(--web-focus-ring-search);
}
.mention-categories {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-editor);
scrollbar-width: thin;
}
.mention-categories button {
flex: 0 0 auto;
padding: 5px 9px;
color: var(--web-accent-muted);
background: transparent;
border: 0;
border-radius: 5px;
cursor: pointer;
}
.mention-categories button:hover,
.mention-categories button.active {
color: var(--web-text);
background: var(--web-hover);
}
.mention-list {
height: 250px;
margin-top: 8px;
overflow: auto;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-editor);
scrollbar-width: thin;
}
.media .mention-list {
height: 232px;
}
.drama-storyboard .mention-list {
height: auto;
max-height: 210px;
margin-top: 8px;
}
.mention-list button {
width: 100%;
height: 42px;
padding: 5px 7px;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
color: var(--web-text-secondary);
background: transparent;
border: 0;
border-radius: 6px;
text-align: left;
cursor: pointer;
}
.mention-list button:hover,
.mention-list button.active {
color: var(--web-text);
background: var(--web-hover);
}
.mention-list button > span {
position: relative;
width: 30px;
height: 30px;
display: grid;
place-items: center;
overflow: hidden;
background: var(--web-hover);
border-radius: 5px;
}
.mention-list img,
.mention-list :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.mention-list i {
color: var(--web-accent-dark);
font-style: normal;
}
.mention-list b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 500;
}
.mention-list small {
color: var(--web-text-tertiary);
font-size: 10px;
}
.mention-list > div {
padding: 14px;
color: var(--web-text-tertiary);
font-size: 11px;
text-align: center;
}
</style>
<style scoped lang="scss">
.prompt-content :deep(.mention-token) {
position: relative;
gap: 6px;
min-height: 26px;
padding: 3px 8px;
font-size: 13px;
line-height: 1.45;
}
.prompt-content :deep(.mention-token > img) {
width: 24px;
height: 24px;
flex: 0 0 24px;
object-fit: cover;
border-radius: 3px;
}
.prompt-content :deep(.mention-token > b) {
font-size: 11px;
line-height: 1;
}
.prompt-content :deep(.mention-preview) {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 40;
display: none;
width: 144px;
height: 144px;
padding: 4px;
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 7px;
box-shadow: var(--web-shadow-preview);
pointer-events: none;
}
.prompt-content :deep(.mention-token:hover .mention-preview) {
display: block;
}
.prompt-content :deep(.mention-preview img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.mention-floating-preview {
box-sizing: border-box;
width: 154px;
height: 154px;
position: fixed;
z-index: calc(var(--el-index-popper) + 1);
padding: 4px;
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 7px;
box-shadow: var(--web-shadow-preview);
pointer-events: none;
}
.mention-floating-preview img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
</style>
@@ -0,0 +1,191 @@
<!-- 创作下拉选择组件,统一模型和业务选项的选择样式与分组展示。 -->
<script setup lang="ts">
import { computed } from "vue";
export interface CreativeSelectOption {
label: string;
value: string;
group?: string;
disabled?: boolean;
}
type SelectValue = string | string[];
const props = withDefaults(
defineProps<{
modelValue: SelectValue;
options: CreativeSelectOption[];
placeholder?: string;
multiple?: boolean;
filterable?: boolean;
clearable?: boolean;
disabled?: boolean;
placement?: "top-start" | "bottom-start";
}>(),
{
placeholder: "请选择",
multiple: false,
filterable: false,
clearable: false,
disabled: false,
placement: "bottom-start",
},
);
const emit = defineEmits<{ "update:modelValue": [value: SelectValue] }>();
const groups = computed(() => {
const result = new Map<string, CreativeSelectOption[]>();
for (const option of props.options) {
const label = option.group || "";
if (!result.has(label)) result.set(label, []);
result.get(label)!.push(option);
}
return [...result.entries()].map(([label, options]) => ({ label, options }));
});
const grouped = computed(() => groups.value.some((group) => group.label));
</script>
<template>
<el-select
class="creative-select"
:model-value="modelValue"
:placeholder="placeholder"
:multiple="multiple"
:collapse-tags="multiple"
:collapse-tags-tooltip="multiple"
:filterable="filterable"
:clearable="clearable"
:disabled="disabled"
:placement="placement"
:teleported="true"
popper-class="creative-select-popper"
@update:model-value="emit('update:modelValue', $event as SelectValue)"
>
<template v-if="grouped">
<el-option-group
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<el-option
v-for="option in group.options"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</el-option-group>
</template>
<template v-else>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</template>
</el-select>
</template>
<style scoped lang="scss">
.creative-select {
width: 100%;
}
.creative-select:deep(.el-select__wrapper) {
min-height: 38px;
padding: 0 12px;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
box-shadow: none;
transition:
border-color 0.16s,
background-color 0.16s;
}
.creative-select:deep(.el-select__wrapper:hover) {
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.creative-select:deep(.el-select__wrapper.is-focused) {
border-color: var(--web-timeline-focus-line);
box-shadow: 0 0 0 2px var(--web-dialog-focus-ring);
}
.creative-select:deep(.el-select__selected-item) {
color: var(--web-text);
}
.creative-select:deep(.el-select__placeholder) {
color: var(--web-creative-muted);
}
.creative-select:deep(.el-select__caret) {
color: var(--web-control-muted);
}
.creative-select:deep(.el-tag) {
color: var(--web-accent-muted);
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.creative-select:deep(.el-tag__close) {
color: var(--web-text-secondary);
}
</style>
<style lang="scss">
.creative-select-popper.el-popper {
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 9px;
box-shadow: var(--web-shadow-select);
overflow: hidden;
}
.creative-select-popper.el-popper .el-popper__arrow::before {
background: var(--web-surface);
border-color: var(--web-line-strong);
}
.creative-select-popper .el-select-dropdown {
background: var(--web-surface);
}
.creative-select-popper .el-select-dropdown__wrap {
max-height: 260px;
}
.creative-select-popper .el-select-dropdown__list {
padding: 6px;
}
.creative-select-popper .el-select-dropdown__item {
height: 38px;
padding: 0 12px;
color: var(--web-text-secondary);
border-radius: 6px;
font-size: 13px;
line-height: 38px;
}
.creative-select-popper .el-select-dropdown__item.is-hovering {
color: var(--web-text);
background: var(--web-surface);
}
.creative-select-popper .el-select-dropdown__item.is-selected {
color: var(--web-accent-dark);
background: var(--web-hover);
font-weight: 600;
}
.creative-select-popper .el-select-dropdown__item.is-disabled {
color: var(--web-select-option);
background: transparent;
}
.creative-select-popper .el-select-group__title {
padding: 10px 12px 5px;
color: var(--web-select-group);
font-size: 10px;
letter-spacing: 1px;
}
.creative-select-popper .el-select-dropdown__empty {
color: var(--web-text-tertiary);
}
.creative-select-popper .el-scrollbar__thumb {
background: var(--web-mention-prop-bg);
}
.creative-select-popper .el-select-dropdown__list::before,
.creative-select-popper .el-select-dropdown__list::after {
display: none;
}
</style>
@@ -0,0 +1,266 @@
<!-- 创作页面外壳,统一页面标题、操作区、滚动容器和对话框基础样式。 -->
<script setup lang="ts">
withDefaults(defineProps<{ pageTitle?: string }>(), { pageTitle: "" });
</script>
<template>
<div class="web-page creative-shell">
<main class="creative-shell__content">
<header
v-if="$slots.heading"
class="creative-shell__heading"
>
<slot name="heading" />
</header>
<div class="creative-shell__scroll">
<slot />
</div>
</main>
</div>
</template>
<style scoped lang="scss">
.creative-shell {
--creative-accent: var(--web-primary);
--creative-accent-hover: var(--web-primary-hover);
--creative-surface: var(--web-surface);
--creative-surface-raised: var(--web-raised);
--creative-line: var(--web-line);
--creative-text: var(--web-text);
--creative-muted: var(--web-muted);
--el-color-primary: var(--web-primary);
min-height: 100dvh;
padding: 0 var(--page-gutter);
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--creative-text);
background: var(--web-bg);
}
.creative-shell__content {
width: 100%;
max-width: var(--page-content-max);
min-height: 0;
margin: var(--space-xl) auto 0;
display: flex;
flex: 1;
flex-direction: column;
}
.creative-shell__heading {
flex: 0 0 auto;
min-height: 44px;
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
}
.creative-shell__scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
padding-bottom: 56px;
overscroll-behavior: contain;
}
.creative-shell__heading :deep(h1) {
font-family: var(--font-display);
font-size: clamp(22px, 2vw, 30px);
font-weight: 600;
letter-spacing: 2px;
}
.creative-shell__heading :deep(p) {
margin-top: var(--space-xs);
color: var(--creative-muted);
font-size: 12px;
}
.creative-shell__heading :deep(.heading-actions) {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.creative-shell :deep(.creative-page-actions) {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.creative-shell :deep(.creative-page-actions .el-button) {
height: var(--control-height-lg);
min-height: var(--control-height-lg);
margin-left: 0;
padding: 0 15px;
border-radius: var(--radius-sm);
font-size: 14px;
}
.creative-shell :deep(.creative-page-actions .el-button svg) {
width: 16px;
height: 16px;
}
.creative-shell__heading :deep(.back-link) {
display: inline-flex;
align-items: center;
gap: 7px;
margin-bottom: 8px;
color: var(--web-text-secondary);
font-size: 13px;
}
.creative-shell__heading :deep(.back-link:hover) {
color: var(--creative-accent);
}
.creative-shell :deep(.el-button) {
min-height: var(--control-height-md);
color: var(--web-text-secondary);
background: var(--web-surface);
border-color: var(--web-line-strong);
border-radius: var(--radius-sm);
}
.creative-shell :deep(.el-button:hover:not(.is-disabled)) {
color: var(--web-text);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.creative-shell :deep(.el-button--primary) {
color: var(--web-text-strong);
background: var(--creative-accent);
border-color: var(--creative-accent);
font-weight: 600;
}
.creative-shell :deep(.el-button--primary:hover:not(.is-disabled)) {
color: var(--web-text-strong);
background: var(--creative-accent-hover);
border-color: var(--creative-accent-hover);
}
.creative-shell :deep(.el-button--danger) {
color: var(--web-creative-button-text);
background: var(--web-danger);
border-color: var(--web-danger);
font-weight: 600;
}
.creative-shell :deep(.el-button--danger:hover:not(.is-disabled)) {
color: var(--web-creative-button-text);
background: var(--web-creative-danger);
border-color: var(--web-creative-danger);
}
.creative-shell :deep(.el-button--danger.is-disabled) {
color: var(--web-creative-button-text);
background: var(--web-danger);
border-color: var(--web-danger);
opacity: 0.55;
}
@media (max-width: 767px) {
.creative-shell {
min-height: calc(100dvh - var(--mobile-bar-height));
padding: 0 var(--page-gutter-mobile) 40px;
}
.creative-shell__content {
margin-top: var(--space-lg);
}
.creative-shell__heading {
align-items: flex-start;
flex-direction: column;
}
.creative-shell__heading :deep(.heading-actions) {
width: 100%;
flex-wrap: wrap;
}
}
</style>
<style lang="scss">
.creative-dialog.el-dialog {
--creative-accent: var(--web-creative-accent);
--creative-accent-hover: var(--web-creative-accent-hover);
--el-color-primary: var(--web-creative-accent);
padding: 0;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-lg);
box-shadow: var(--web-shadow-creative-shell);
overflow: hidden;
}
.creative-dialog .el-dialog__header {
margin: 0;
padding: 19px 22px 16px;
border-bottom: 1px solid var(--web-line-strong);
}
.creative-dialog .el-dialog__title {
color: var(--web-text);
font-family: var(--font-ui);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.creative-dialog .el-dialog__headerbtn {
top: 10px;
right: 10px;
}
.creative-dialog .el-dialog__headerbtn .el-dialog__close {
color: var(--web-creative-muted);
}
.creative-dialog .el-dialog__headerbtn:hover .el-dialog__close {
color: var(--web-text-secondary);
}
.creative-dialog .el-dialog__body {
padding: 20px 22px 8px;
color: var(--web-text-secondary);
}
.creative-dialog .el-dialog__footer {
padding: 15px 22px 17px;
background: var(--web-surface-soft);
border-top: 1px solid var(--web-line-strong);
}
.creative-dialog .el-form-item__label {
padding-bottom: 7px;
color: var(--web-text-secondary);
font-size: 12px;
}
.creative-dialog .el-input__wrapper,
.creative-dialog .el-textarea__inner,
.creative-dialog .el-input-number .el-input__wrapper {
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
box-shadow: none;
}
.creative-dialog .el-input__wrapper:hover,
.creative-dialog .el-textarea__inner:hover {
border-color: var(--web-line-emphasis);
box-shadow: none;
}
.creative-dialog .el-input__wrapper.is-focus,
.creative-dialog .el-textarea__inner:focus {
border-color: var(--web-timeline-focus-line);
box-shadow: 0 0 0 2px var(--web-dialog-focus-ring);
}
.creative-dialog .el-input__inner,
.creative-dialog .el-textarea__inner {
color: var(--web-text);
}
.creative-dialog .el-input__count {
color: var(--web-dialog-description);
background: transparent;
}
.creative-dialog .el-button {
min-height: var(--control-height-md);
color: var(--web-text-secondary);
background: var(--web-surface);
border-color: var(--web-line-strong);
border-radius: var(--radius-sm);
}
.creative-dialog .el-button:hover:not(.is-disabled) {
color: var(--web-text);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.creative-dialog .el-button--primary {
color: var(--web-text-strong);
background: var(--creative-accent);
border-color: var(--creative-accent);
font-weight: 600;
}
.creative-dialog .el-button--primary:hover:not(.is-disabled) {
color: var(--web-text-strong);
background: var(--creative-accent-hover);
border-color: var(--creative-accent-hover);
}
</style>
@@ -0,0 +1,251 @@
<!-- 视频时长选择器,负责在模型允许范围内选择生成时长。 -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ChevronDown } from "@/icons";
const props = withDefaults(
defineProps<{ modelValue: number; min?: number; max?: number; compact?: boolean; placement?: string }>(),
{
min: 5,
max: 15,
compact: false,
placement: "top-start",
},
);
const emit = defineEmits<{ "update:modelValue": [value: number]; change: [value: number] }>();
const current = ref(props.modelValue);
const seconds = computed(() => Array.from({ length: props.max - props.min + 1 }, (_, index) => props.min + index));
watch(
() => props.modelValue,
(value) => {
current.value = value;
},
);
/** 更新本地时长并同步双向绑定值。 */
function update(value: number) {
current.value = value;
emit("update:modelValue", value);
}
/** 提交已确认的时长变更事件。 */
function commit(value: number) {
current.value = value;
emit("change", value);
}
/** 选择并立即提交指定时长。 */
function select(value: number) {
update(value);
commit(value);
}
/** 计算时长刻度在滑轨中的百分比位置。 */
function position(value: number) {
return `${((value - props.min) / Math.max(1, props.max - props.min)) * 100}%`;
}
</script>
<template>
<el-popover
:placement="placement"
trigger="click"
:width="260"
:show-arrow="false"
popper-class="duration-popper"
>
<template #reference>
<button
class="duration-trigger"
:class="{ compact }"
type="button"
@click.stop
>
<span>{{ current }}{{ compact ? "s" : " 秒" }}</span
><ChevronDown :size="15" />
</button>
</template>
<div class="duration-popover-content">
<div class="duration-range-head">
<span>{{ min }} 秒</span><strong>{{ current }} 秒</strong><span>{{ max }} 秒</span>
</div>
<div class="duration-slider-wrap">
<el-slider
:model-value="current"
:min="min"
:max="max"
:step="1"
:format-tooltip="(value) => `${value} 秒`"
@update:model-value="update"
@change="commit"
/>
<div class="duration-nodes">
<button
v-for="second in seconds"
:key="second"
type="button"
:class="{ active: current === second }"
:style="{ left: position(second) }"
:title="`选择 ${second} 秒`"
@click="select(second)"
>
<span>{{ second }} 秒</span>
</button>
</div>
</div>
</div>
</el-popover>
</template>
<style scoped>
.duration-trigger {
width: 100%;
height: 38px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
font-family: inherit;
cursor: pointer;
}
.duration-trigger:hover,
.duration-trigger:focus-visible {
background: var(--web-surface);
border-color: var(--web-timeline-focus-line);
outline: none;
}
.duration-trigger span {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration-trigger svg {
flex: 0 0 auto;
color: var(--web-control-muted);
}
.duration-trigger.compact {
width: 72px;
height: 34px;
color: var(--web-accent-dark);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.duration-trigger.compact:hover,
.duration-trigger.compact:focus-visible {
border-color: var(--web-project-card-hover-line);
}
:global(.duration-popper.el-popper) {
padding: 10px 14px 12px;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 9px;
box-shadow: var(--web-shadow-duration);
}
:global(.duration-range-head) {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-bottom: 2px;
color: var(--web-text-tertiary);
font-size: 10px;
}
:global(.duration-range-head strong) {
color: var(--web-accent-dark);
font-size: 12px;
text-align: center;
}
:global(.duration-range-head span:last-child) {
text-align: right;
}
:global(.duration-slider-wrap) {
position: relative;
height: 28px;
margin: 0 7px;
}
:global(.duration-slider-wrap .el-slider) {
position: relative;
z-index: 2;
height: 24px;
}
:global(.duration-slider-wrap .el-slider__runway) {
width: calc(100% - 8px);
height: 6px;
margin-right: 4px;
margin-left: 4px;
background: var(--web-hover);
border-radius: 0;
}
:global(.duration-slider-wrap .el-slider__bar) {
height: 6px;
background: var(--web-primary);
border-radius: 0;
}
:global(.duration-slider-wrap .el-slider__button-wrapper) {
top: -15px;
z-index: 5;
}
:global(.duration-slider-wrap .el-slider__button) {
width: 18px;
height: 18px;
background: var(--web-primary);
border: 3px solid var(--web-line-emphasis);
}
:global(.duration-nodes) {
position: absolute;
top: 0;
right: 4px;
left: 4px;
z-index: 3;
height: 24px;
pointer-events: none;
}
:global(.duration-nodes button) {
position: absolute;
top: 9px;
width: 6px;
height: 6px;
padding: 0;
transform: translateX(-50%);
background: var(--web-divider-soft);
border: 0;
border-radius: 50%;
pointer-events: auto;
cursor: pointer;
}
:global(.duration-nodes button.active) {
background: var(--web-primary);
}
:global(.duration-nodes button span) {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
padding: 4px 7px;
transform: translateX(-50%);
color: var(--web-text);
background: var(--web-hover);
border-radius: 5px;
font-size: 10px;
line-height: 1;
white-space: nowrap;
visibility: hidden;
opacity: 0;
pointer-events: none;
}
:global(.duration-nodes button span::after) {
position: absolute;
top: 100%;
left: 50%;
width: 6px;
height: 6px;
transform: translate(-50%, -50%) rotate(45deg);
content: "";
background: var(--web-hover);
}
:global(.duration-nodes button:hover span),
:global(.duration-nodes button:focus-visible span) {
visibility: visible;
opacity: 1;
}
</style>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import MediaLoadingState from "@/components/creative/MediaLoadingState.vue";
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
type: "image" | "video";
src: string;
compact?: boolean;
loadingLabel?: string;
errorLabel?: string;
}>(),
{ compact: false, loadingLabel: "媒体加载中", errorLabel: "媒体加载失败" },
);
const emit = defineEmits<{
load: [event: Event];
loadeddata: [event: Event];
error: [event: Event];
}>();
const loading = ref(true);
const failed = ref(false);
watch(
() => props.src,
() => {
loading.value = true;
failed.value = false;
},
{ immediate: true },
);
function finish(event: Event, type: "load" | "loadeddata") {
loading.value = false;
failed.value = false;
if (type === "load") emit("load", event);
else emit("loadeddata", event);
}
function fail(event: Event) {
loading.value = false;
failed.value = true;
emit("error", event);
}
</script>
<template>
<img
v-if="type === 'image'"
class="loading-media-element"
v-bind="$attrs"
:src="src"
@load="finish($event, 'load')"
@error="fail"
/>
<video
v-else
class="loading-media-element"
v-bind="$attrs"
:src="src"
@loadeddata="finish($event, 'loadeddata')"
@error="fail"
></video>
<MediaLoadingState
v-if="loading || failed"
:failed="failed"
:compact="compact"
:label="loadingLabel"
:error-label="errorLabel"
/>
</template>
<style scoped>
.loading-media-element {
display: block;
max-width: 100%;
max-height: 100%;
}
</style>
@@ -0,0 +1,71 @@
<!-- 媒体加载状态组件,统一展示加载中和加载失败反馈。 -->
<script setup lang="ts">
import { CircleAlert, LoaderCircle } from "@/icons";
withDefaults(defineProps<{ failed?: boolean; compact?: boolean; label?: string; errorLabel?: string }>(), {
failed: false,
compact: false,
label: "媒体加载中",
errorLabel: "媒体加载失败",
});
</script>
<template>
<div
class="media-loading-state"
:class="{ failed, compact }"
>
<CircleAlert
v-if="failed"
:size="24"
:stroke-width="1.7"
/>
<LoaderCircle
v-else
class="media-loading-spinner"
:size="26"
:stroke-width="1.8"
/>
<span>{{ failed ? errorLabel : label }}</span>
</div>
</template>
<style scoped>
.media-loading-state {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--web-media-loading-text);
background: var(--web-media-state-mask);
font-size: 12px;
pointer-events: none;
}
.media-loading-state.failed {
color: var(--web-media-error-text);
}
.media-loading-state.compact span {
display: none;
}
.media-loading-state.compact svg {
width: 19px;
height: 19px;
}
.media-loading-spinner {
animation: media-loading-spin 0.9s linear infinite;
}
@keyframes media-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,725 @@
<!-- 模型配置对话框,负责维护创作模型参数与各业务提示词预设。 -->
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { Delete, Edit, Plus, Setting } from "@element-plus/icons-vue";
import type { CreativeOptions, ModelConfig, PromptPreset } from "@/api/creative";
import CreativeSelect, { type CreativeSelectOption } from "@/components/creative/CreativeSelect.vue";
import { modelPriceLabel } from "@/utils/modelPricing";
/** 模型配置支持的业务用途。 */
type Purpose = "prompt_reverse" | "image_generation" | "video_generation";
/** 单个模型可保存的生成参数。 */
type Settings = { aspect_ratio?: string; resolution?: string; prompt?: string };
/** 单个用途提交给父组件的模型配置。 */
type Value = { model_id: string; settings: Settings };
const props = withDefaults(
defineProps<{
modelValue: boolean;
options: CreativeOptions;
configs: ModelConfig[];
saving?: boolean;
prompts?: PromptPreset[];
promptSelections?: Record<string, string>;
projectType?: "video_redraw" | "premium_drama" | "script_analysis";
analysisOnly?: boolean;
}>(),
{ saving: false, prompts: () => [], promptSelections: () => ({}), projectType: "video_redraw" },
);
const emit = defineEmits<{
"update:modelValue": [value: boolean];
save: [payload: Record<Purpose, Value>];
"prompt-select": [payload: { purpose: Purpose; type: string; promptId: string; content: string }];
"prompt-create": [payload: { name: string; type: string; content: string }];
"prompt-update": [payload: { id: string; name: string; type: string; content: string }];
"prompt-delete": [prompt: PromptPreset];
}>();
const fields = computed(() => {
const items = [
{
purpose: "prompt_reverse" as Purpose,
label: props.projectType === "video_redraw" ? "反推模型" : "文本模型",
type: "text",
},
];
if (!props.analysisOnly)
items.push(
{ purpose: "image_generation" as Purpose, label: "图片模型", type: "image" },
{ purpose: "video_generation" as Purpose, label: "视频模型", type: "video" },
);
return items;
});
const form = reactive<Record<Purpose, Value>>({
prompt_reverse: { model_id: "", settings: { prompt: "" } },
image_generation: { model_id: "", settings: { aspect_ratio: "16:9", resolution: "1k", prompt: "" } },
video_generation: { model_id: "", settings: { aspect_ratio: "16:9", resolution: "480p", prompt: "" } },
});
const imageResolutions = ["1k", "2k", "4k"];
const videoResolutions = ["480p", "720p", "1080p"];
const imageResolutionOptions: CreativeSelectOption[] = imageResolutions.map((value) => ({
value,
label: value.toUpperCase(),
}));
const videoResolutionOptions: CreativeSelectOption[] = videoResolutions.map((value) => ({
value,
label: value.toUpperCase(),
}));
watch(
() => [props.modelValue, props.configs, props.options.models] as const,
() => {
if (!props.modelValue) return;
for (const field of fields.value) {
const config = props.configs.find((item) => item.model_type === field.type || item.purpose === field.purpose);
form[field.purpose].model_id = config?.model_id || modelsFor(field)[0]?.value || "";
Object.assign(form[field.purpose].settings, config?.settings || {});
}
// 宽高比是图片接口的固定实现参数,当前统一使用 16:9。
form.image_generation.settings.aspect_ratio = "16:9";
form.video_generation.settings.aspect_ratio = "16:9";
// 视频分辨率按模型配置,缺省时保持原有的 480p 默认值。
if (!form.video_generation.settings.resolution) form.video_generation.settings.resolution = "480p";
},
{ immediate: true },
);
/** 获取指定业务用途可选择的模型,并转换为下拉框选项。 */
function modelsFor(field: { purpose: Purpose; type: string }): CreativeSelectOption[] {
return props.options.models
.filter(
(model) =>
model.model_type === field.type &&
(field.purpose !== "prompt_reverse" || props.projectType !== "video_redraw" || model.multimodal),
)
.map((model) => ({ label: `【${model.channel_name}】${model.name}`, value: model.id }));
}
/** 计算指定业务用途当前模型的价格展示文案。 */
function price(purpose: Purpose) {
const key = purpose === "prompt_reverse" ? "default" : form[purpose].settings.resolution;
const model = props.options.models.find((item) => item.id === form[purpose].model_id);
return modelPriceLabel(model, props.options.prices || [], String(key));
}
/** 按现有事件契约提交全部可见模型配置。 */
function submit() {
emit(
"save",
Object.fromEntries(
fields.value.map((field) => [field.purpose, JSON.parse(JSON.stringify(form[field.purpose]))]),
) as Record<Purpose, Value>,
);
}
const promptDialog = ref(false);
const promptType = ref("");
const promptLabel = ref("");
const promptPurpose = ref<Purpose>("image_generation");
const selectedPromptId = ref("");
const editing = ref(false);
const creating = ref(false);
const draft = reactive({ id: "", name: "", type: "", content: "" });
const promptTypes = [
{ value: "剧本反推", label: "剧本反推" },
{ value: "剧本分析", label: "剧本分析" },
{ value: "角色生成", label: "角色生成" },
{ value: "场景生成", label: "场景生成" },
{ value: "道具生成", label: "道具生成" },
{ value: "分镜图生成", label: "分镜图生成" },
{ value: "首尾帧生成", label: "首尾帧生成" },
{ value: "视频生成", label: "视频生成" },
];
const promptList = computed(() => props.prompts.filter((item) => item.type === promptType.value));
/** 打开指定用途的提示词面板,并载入当前选择或首个可用预设。 */
function openPrompt(purpose: Purpose, type: string, label: string) {
promptPurpose.value = purpose;
promptType.value = type;
promptLabel.value = label;
const id = props.promptSelections[type] || "";
selectedPromptId.value = id;
const selected = promptList.value.find((item) => item.id === id) || promptList.value[0];
// 打开面板时只预览当前或默认项,用户明确选择列表项后才持久化选择。
if (selected) Object.assign(draft, selected);
else Object.assign(draft, { id: "", name: "", type, content: form[purpose].settings.prompt || "" });
if (id && selected) form[purpose].settings.prompt = selected.content;
editing.value = false;
creating.value = false;
promptDialog.value = true;
}
/** 选择并应用提示词,同时通知父组件持久化选择。 */
function selectPrompt(prompt: PromptPreset) {
selectedPromptId.value = prompt.id;
Object.assign(draft, prompt);
if (prompt.id) form[promptPurpose.value].settings.prompt = prompt.content;
if (prompt.id)
emit("prompt-select", {
purpose: promptPurpose.value,
type: promptType.value,
promptId: prompt.id,
content: prompt.content,
});
}
/** 切换提示词启用状态,并保持原有选择事件结构。 */
function togglePrompt(prompt: PromptPreset, enabled: boolean) {
selectedPromptId.value = enabled ? prompt.id : "";
if (enabled) {
Object.assign(draft, prompt);
form[promptPurpose.value].settings.prompt = prompt.content;
}
emit("prompt-select", {
purpose: promptPurpose.value,
type: promptType.value,
promptId: enabled ? prompt.id : "",
content: enabled ? prompt.content : "",
});
}
/** 进入自定义提示词新建状态。 */
function startCreate() {
creating.value = true;
editing.value = false;
Object.assign(draft, { id: "", name: "", type: promptType.value, content: "" });
}
/** 在提示词允许编辑时进入编辑状态。 */
function startEdit() {
if (!draft.id || props.prompts.find((p) => p.id === draft.id)?.editable === false) return;
editing.value = true;
creating.value = false;
}
/** 取消编辑并恢复当前已选择的提示词内容。 */
function cancelEdit() {
creating.value = false;
editing.value = false;
const p = promptList.value.find((item) => item.id === selectedPromptId.value);
if (p) Object.assign(draft, p);
}
/** 校验并提交提示词的新建或更新事件。 */
function savePrompt() {
const name = draft.name.trim(),
content = draft.content.trim();
if (!name || !content) return;
if (creating.value) emit("prompt-create", { name, type: promptType.value, content });
else if (draft.id) emit("prompt-update", { id: draft.id, name, type: draft.type || promptType.value, content });
form[promptPurpose.value].settings.prompt = content;
creating.value = false;
editing.value = false;
}
/** 在提示词允许删除时通知父组件执行删除。 */
function deletePrompt() {
const p = promptList.value.find((item) => item.id === draft.id);
if (p?.editable !== false) emit("prompt-delete", p);
}
/** 根据业务用途和项目类型生成可配置的提示词入口。 */
const promptButtons = (purpose: Purpose) =>
purpose === "prompt_reverse"
? props.projectType === "video_redraw"
? [{ type: "剧本反推", label: "剧本反推提示词" }]
: props.projectType === "script_analysis"
? [{ type: "剧本分析", label: "模型分析提示词" }]
: []
: purpose === "image_generation"
? [
{ type: "角色生成", label: "角色生图提示词" },
{ type: "场景生成", label: "场景生图提示词" },
{ type: "道具生成", label: "道具生图提示词" },
]
: purpose === "video_generation"
? [{ type: "视频生成", label: "视频生成提示词" }]
: [];
</script>
<template>
<el-dialog
:model-value="modelValue"
title="模型配置"
width="min(860px, calc(100vw - 28px))"
class="web-dialog creative-dialog model-config-dialog"
@close="emit('update:modelValue', false)"
>
<div class="model-config-list">
<section
v-for="field in fields"
:key="field.purpose"
class="model-config-card"
>
<header class="model-config-header">
<strong>{{ field.label }}</strong
><span
v-if="price(field.purpose)"
class="price"
>{{ price(field.purpose) }}</span
>
<div
v-if="promptButtons(field.purpose).length"
class="prompt-buttons"
>
<button
v-for="item in promptButtons(field.purpose)"
:key="item.type"
type="button"
class="prompt-button"
@click="openPrompt(field.purpose, item.type, item.label)"
>
<el-icon> <Setting /> </el-icon>{{ item.label }}
</button>
</div>
</header>
<div class="model-config-row">
<div class="model-select">
<label>模型</label>
<CreativeSelect
v-model="form[field.purpose].model_id"
:options="modelsFor(field)"
filterable
placeholder="请选择模型"
/>
</div>
<div
v-if="field.type === 'image'"
class="resolution-select"
>
<label>分辨率</label>
<CreativeSelect
v-model="form.image_generation.settings.resolution"
:options="imageResolutionOptions"
placeholder="请选择分辨率"
/>
</div>
<div
v-if="field.type === 'video'"
class="resolution-select"
>
<label>分辨率</label>
<CreativeSelect
v-model="form.video_generation.settings.resolution"
:options="videoResolutionOptions"
placeholder="请选择分辨率"
/>
</div>
</div>
</section>
</div>
<template #footer
><el-button @click="emit('update:modelValue', false)">取消</el-button
><el-button
type="primary"
:loading="saving"
@click="submit"
>保存配置</el-button
></template
>
</el-dialog>
<el-dialog
v-model="promptDialog"
:title="promptLabel"
width="min(900px, calc(100vw - 28px))"
class="web-dialog creative-dialog prompt-preset-dialog"
>
<div class="prompt-layout">
<aside class="prompt-sidebar">
<div class="prompt-sidebar-head">
<span
><strong>提示词列表</strong><em>{{ promptList.length }}</em></span
><el-button
size="small"
:icon="Plus"
@click="startCreate"
>新建</el-button
>
</div>
<div class="prompt-list">
<div
v-for="item in promptList"
:key="item.id"
:class="['prompt-item-row', { active: item.id === selectedPromptId }]"
>
<button
type="button"
class="prompt-item"
@click="selectPrompt(item)"
>
<span>{{ item.name }}</span
><em>{{ item.scope === "user" ? "自定义" : "系统" }}</em></button
><el-switch
:model-value="item.id === selectedPromptId"
size="small"
@click.stop
@change="togglePrompt(item, Boolean($event))"
/>
</div>
<p
v-if="!promptList.length"
class="prompt-empty"
>
暂无提示词
</p>
</div>
</aside>
<section class="prompt-editor">
<template v-if="creating || editing"
><el-form label-position="top"
><el-form-item label="提示词名称"
><el-input
v-model="draft.name"
placeholder="请输入提示词名称" /></el-form-item
><el-form-item
v-if="editing"
label="适用类型"
><el-select v-model="draft.type"
><el-option
v-for="item in promptTypes"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
><el-form-item label="提示词内容"
><el-input
v-model="draft.content"
type="textarea"
:rows="14"
resize="none" /></el-form-item
></el-form>
<div class="prompt-actions">
<el-button @click="cancelEdit">取消</el-button
><el-button
type="primary"
@click="savePrompt"
>保存</el-button
>
</div> </template
><template v-else>
<header>
<span>内容预览</span>
<div>
<el-button
v-if="draft.id && props.prompts.find((p) => p.id === draft.id)?.scope === 'user'"
link
:icon="Edit"
@click="startEdit"
>编辑</el-button
><el-button
v-if="draft.id && props.prompts.find((p) => p.id === draft.id)?.scope === 'user'"
link
type="danger"
:icon="Delete"
@click="deletePrompt"
>删除</el-button
>
</div>
</header>
<el-input
v-model="draft.content"
type="textarea"
:rows="18"
readonly
resize="none"
/>
</template>
</section>
</div>
</el-dialog>
</template>
<style scoped lang="scss">
.model-config-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.model-config-card {
padding: 18px 20px;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
}
.model-config-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
color: var(--web-text);
}
.model-config-header strong {
font-size: 15px;
}
.price {
padding: 4px 8px;
color: var(--web-accent-dark);
background: var(--web-hover);
border: 1px solid var(--web-line-emphasis);
border-radius: 5px;
font-size: 12px;
}
.prompt-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-left: auto;
}
.prompt-button {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
font-size: 11px;
cursor: pointer;
}
.prompt-button:hover {
color: var(--web-accent-muted);
border-color: var(--web-switch-border);
}
.model-config-row {
display: flex;
align-items: end;
gap: 12px;
}
.model-select {
min-width: 0;
flex: 1;
}
.model-config-row label {
display: block;
margin: 0 0 6px;
color: var(--web-text-subtle);
font-size: 11px;
}
.resolution-select {
width: 150px;
flex: 0 0 150px;
}
.resolution-select :deep(.el-select) {
width: 100%;
}
.prompt-layout {
height: min(62vh, 620px);
display: flex;
min-height: 390px;
}
.prompt-sidebar {
width: 270px;
flex: 0 0 270px;
border-right: 1px solid var(--web-line-strong);
overflow: auto;
}
.prompt-sidebar-head {
min-height: 56px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text-secondary);
border-bottom: 1px solid var(--web-line-strong);
}
.prompt-sidebar-head > span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.prompt-sidebar-head strong {
color: var(--web-text);
font-size: 13px;
font-weight: 600;
}
.prompt-sidebar-head em {
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
font-size: 11px;
font-style: normal;
}
.prompt-list {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-item-row {
min-width: 0;
padding: 9px 10px;
display: flex;
align-items: center;
gap: 10px;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.prompt-item-row:hover {
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.prompt-item-row.active {
background: var(--web-surface);
border-color: var(--web-line-active);
}
.prompt-item {
min-width: 0;
flex: 1;
padding: 0;
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 5px;
color: var(--web-text-secondary);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.prompt-item span {
width: 100%;
color: var(--web-text);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-item em {
padding: 2px 5px;
color: var(--web-text-faint);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 4px;
font-size: 10px;
font-style: normal;
line-height: 1.2;
}
.prompt-item-row.active .prompt-item span {
color: var(--web-text);
}
.prompt-item-row.active .prompt-item em {
color: var(--web-accent-dark);
border-color: var(--web-line-emphasis);
}
.prompt-item-row :deep(.el-switch) {
flex: 0 0 auto;
}
.prompt-item-row :deep(.el-switch .el-switch__core) {
background: var(--web-switch-track);
border-color: var(--web-switch-border);
}
.prompt-item-row :deep(.el-switch.is-checked .el-switch__core) {
background: var(--web-switch-track-active);
border-color: var(--web-switch-track-active);
}
.prompt-item-row :deep(.el-switch .el-switch__action) {
background: var(--web-switch-thumb);
box-shadow: 0 0 0 1px var(--web-control-outline);
}
.prompt-editor {
min-width: 0;
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.prompt-editor > header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text-secondary);
font-size: 12px;
}
.prompt-editor > .el-textarea,
.prompt-editor :deep(.el-textarea) {
flex: 1;
}
.prompt-editor :deep(.el-textarea__inner) {
height: 100%;
background: var(--web-surface-soft);
border-color: var(--web-line-strong);
color: var(--web-text);
resize: none;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
scrollbar-width: thin;
}
.prompt-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.prompt-empty {
padding: 20px;
color: var(--web-empty-text);
font-size: 12px;
text-align: center;
}
@media (max-width: 640px) {
.model-config-row {
align-items: stretch;
flex-direction: column;
}
.resolution-select {
width: 100%;
flex-basis: auto;
}
.prompt-buttons {
width: 100%;
margin-left: 0;
}
.prompt-layout {
height: 72vh;
flex-direction: column;
}
.prompt-sidebar {
width: 100%;
max-height: 190px;
flex-basis: auto;
border-right: 0;
border-bottom: 1px solid var(--web-line-strong);
}
}
</style>
@@ -0,0 +1,120 @@
<!-- 输出历史对话框,负责展示、选择和恢复历史生成结果。 -->
<script setup lang="ts">
import type { GenerationOutput } from "@/api/creative";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import { formatDateTime } from "@/utils/datetime";
defineProps<{ modelValue: boolean; outputs: GenerationOutput[]; loading?: boolean; activatingId?: string }>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
activate: [output: GenerationOutput];
delete: [output: GenerationOutput];
}>();
</script>
<template>
<el-dialog
:model-value="modelValue"
title="分镜历史视频"
width="min(900px, calc(100vw - 28px))"
class="web-dialog creative-dialog"
@close="emit('update:modelValue', false)"
>
<div
v-loading="loading"
class="output-grid"
>
<article
v-for="output in outputs"
:key="output.id"
>
<LoadingMedia
type="video"
:src="output.public_url"
controls
preload="metadata"
/>
<div class="output-meta">
<span>{{ formatDateTime(output.created_at) }}</span
><strong>{{ Number(output.actual_points || output.estimated_points || 0).toFixed(2) }} 积分</strong>
</div>
<div class="history-actions">
<el-button
:type="output.active ? 'success' : 'default'"
:disabled="output.active"
:loading="activatingId === output.id"
@click="emit('activate', output)"
>{{ output.active ? "当前使用" : "设为当前结果" }}</el-button
><el-button
type="danger"
plain
:disabled="output.active"
@click="emit('delete', output)"
>删除</el-button
>
</div>
</article>
<div
v-if="!outputs.length && !loading"
class="output-empty"
>
当前分镜还没有已完成的视频结果。
</div>
</div>
</el-dialog>
</template>
<style scoped lang="scss">
.output-grid {
min-height: 180px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.output-grid article {
position: relative;
padding: 10px;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.output-grid video,
.output-grid :deep(.loading-media-element) {
width: 100%;
height: 250px;
object-fit: contain;
background: var(--web-surface-soft);
border-radius: 5px;
}
.output-meta {
margin: 9px 0;
display: flex;
justify-content: space-between;
color: var(--web-text-subtle);
font-size: 11px;
}
.output-meta strong {
color: var(--web-accent-dark);
}
.history-actions {
display: grid;
grid-template-columns: 1fr 70px;
gap: 8px;
}
.history-actions .el-button {
width: 100%;
}
.output-empty {
grid-column: 1/-1;
display: grid;
place-items: center;
color: var(--web-text-tertiary);
}
@media (max-width: 660px) {
.output-grid {
grid-template-columns: 1fr;
}
.output-grid video,
.output-grid :deep(.loading-media-element) {
height: 220px;
}
}
</style>
@@ -0,0 +1,100 @@
<!-- 图片生成页的大图查看器,负责图片切换、标记、作为参考图和下载。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { ArrowLeft, Download, Image, Pencil, Plus, Trash2, X } from "@/icons";
import { creativeApi } from "@/api/creative";
import { downloadMedia } from "@/utils/download";
import { useToastStore } from "@/stores/toast";
export interface ProductViewerImage {
id: string;
mediaId?: string;
url: string;
prompt?: string;
filename?: string;
}
const props = defineProps<{ modelValue: boolean; images: ProductViewerImage[]; initialId?: string; referenceBusy?: boolean }>();
const emit = defineEmits<{ "update:modelValue": [boolean]; reference: [File] }>();
const toast = useToastStore();
const index = ref(0);
const imageUrl = ref("");
const image = ref<HTMLImageElement>();
const canvas = ref<HTMLCanvasElement>();
const editing = ref(false);
const tool = ref<"brush" | "rect" | "arrow">("brush");
const color = ref("#ef5b50");
const size = ref(4);
const drawing = ref(false);
const start = ref({ x: 0, y: 0 });
const beforeShape = ref<ImageData>();
const history = ref<string[]>([""]);
const historyIndex = ref(0);
const hasMarks = ref(false);
let objectUrl = "";
const current = computed(() => props.images[index.value]);
function close() { emit("update:modelValue", false); }
function reset() {
const found = props.images.findIndex((item) => item.id === props.initialId);
index.value = found >= 0 ? found : 0;
editing.value = false; history.value = [""]; historyIndex.value = 0; hasMarks.value = false;
void loadImage();
}
async function loadImage() {
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = ""; imageUrl.value = "";
const source = current.value;
if (!source) return;
try {
if (source.mediaId) { const blob = await creativeApi.media(source.mediaId); objectUrl = URL.createObjectURL(blob); imageUrl.value = objectUrl; }
else imageUrl.value = source.url;
} catch { imageUrl.value = source.url; toast.error("图片读取失败"); }
}
function resizeCanvas() {
if (!image.value || !canvas.value) return;
canvas.value.width = image.value.clientWidth; canvas.value.height = image.value.clientHeight;
hasMarks.value = false;
}
function context() { const ctx = canvas.value?.getContext("2d"); if (!ctx) return null; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = color.value; ctx.lineWidth = size.value; return ctx; }
function point(event: PointerEvent) { const rect = canvas.value!.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }
function pointerDown(event: PointerEvent) { if (!editing.value || !canvas.value) return; start.value = point(event); drawing.value = true; beforeShape.value = context()?.getImageData(0, 0, canvas.value.width, canvas.value.height); canvas.value.setPointerCapture(event.pointerId); if (tool.value === "brush") { const ctx = context(); ctx?.beginPath(); ctx?.moveTo(start.value.x, start.value.y); } }
function pointerMove(event: PointerEvent) { if (!drawing.value) return; const ctx = context(); if (!ctx || !canvas.value) return; const end = point(event); if (tool.value === "brush") { ctx.lineTo(end.x, end.y); ctx.stroke(); return; } if (beforeShape.value) ctx.putImageData(beforeShape.value, 0, 0); if (tool.value === "rect") ctx.strokeRect(start.value.x, start.value.y, end.x - start.value.x, end.y - start.value.y); else drawArrow(ctx, start.value.x, start.value.y, end.x, end.y); }
function pointerUp(event: PointerEvent) { if (!drawing.value) return; pointerMove(event); drawing.value = false; beforeShape.value = undefined; commitHistory(); }
function drawArrow(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number) { const angle = Math.atan2(y2 - y1, x2 - x1); const head = Math.max(10, size.value * 3); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x2 - head * Math.cos(angle - Math.PI / 6), y2 - head * Math.sin(angle - Math.PI / 6)); ctx.moveTo(x2, y2); ctx.lineTo(x2 - head * Math.cos(angle + Math.PI / 6), y2 - head * Math.sin(angle + Math.PI / 6)); ctx.stroke(); }
function empty() { const data = canvas.value?.getContext("2d")?.getImageData(0, 0, canvas.value!.width, canvas.value!.height).data; return !data || !data.some((value) => value !== 0); }
function syncMarks() { hasMarks.value = !empty(); }
function commitHistory() { if (!canvas.value) return; history.value = history.value.slice(0, historyIndex.value + 1); history.value.push(canvas.value.toDataURL()); historyIndex.value = history.value.length - 1; syncMarks(); }
function restore(data: string) { const layer = canvas.value; const ctx = layer?.getContext("2d"); if (!layer || !ctx) return; ctx.clearRect(0, 0, layer.width, layer.height); if (!data) { syncMarks(); return; } const saved = new Image(); saved.onload = () => { ctx.drawImage(saved, 0, 0, layer.width, layer.height); syncMarks(); }; saved.src = data; }
function undo() { if (historyIndex.value > 0) { historyIndex.value--; restore(history.value[historyIndex.value]); } }
function redo() { if (historyIndex.value < history.value.length - 1) { historyIndex.value++; restore(history.value[historyIndex.value]); } }
function clearMarks() { if (!canvas.value || empty()) return; canvas.value.getContext("2d")?.clearRect(0, 0, canvas.value.width, canvas.value.height); commitHistory(); }
function move(delta: number) { index.value = (index.value + delta + props.images.length) % props.images.length; editing.value = false; history.value = [""]; historyIndex.value = 0; hasMarks.value = false; void loadImage(); }
function composeFile() { return new Promise<File>((resolve, reject) => { if (!image.value || !canvas.value) return reject(new Error("图片尚未加载完成")); const output = document.createElement("canvas"); output.width = image.value.naturalWidth; output.height = image.value.naturalHeight; const ctx = output.getContext("2d"); if (!ctx) return reject(new Error("图片编辑器初始化失败")); try { ctx.drawImage(image.value, 0, 0, output.width, output.height); ctx.drawImage(canvas.value, 0, 0, output.width, output.height); } catch { return reject(new Error("图片读取失败")); } output.toBlob((blob) => blob ? resolve(new File([blob], `reference-${current.value.id}.png`, { type: "image/png" })) : reject(new Error("图片合成失败")), "image/png"); }); }
async function makeReference() { if (!current.value || props.referenceBusy) return; try { emit("reference", await composeFile()); } catch (error) { toast.error(error instanceof Error ? error.message : "图片读取失败"); } }
async function download() { if (!current.value) return; try { await downloadMedia(current.value.mediaId, current.value.url, current.value.filename || `图片生成-${current.value.id}.png`); } catch { toast.error("下载失败"); } }
watch(() => props.modelValue, (value) => { if (value) { reset(); void nextTick(resizeCanvas); } });
watch(() => props.initialId, () => { if (props.modelValue) reset(); });
onBeforeUnmount(() => { if (objectUrl) URL.revokeObjectURL(objectUrl); });
</script>
<template>
<Teleport to="body">
<div v-if="modelValue && current" class="product-image-viewer" @click.self="close">
<header><strong>图片生成</strong><span>{{ index + 1 }} / {{ images.length }}</span><button type="button" @click="close"><X :size="22" /></button></header>
<button v-if="images.length > 1" class="nav prev" type="button" @click="move(-1)"><ArrowLeft :size="24" /></button>
<main><div class="image-stack"><img ref="image" :src="imageUrl" alt="大图预览" @load="resizeCanvas" /><canvas ref="canvas" :class="{ editing }" @pointerdown="pointerDown" @pointermove="pointerMove" @pointerup="pointerUp" @pointercancel="pointerUp" /></div></main>
<button v-if="images.length > 1" class="nav next" type="button" @click="move(1)"><ArrowLeft class="next-icon" :size="24" /></button>
<div v-if="editing" class="mark-toolbar"><button type="button" :class="{ active: tool === 'brush' }" @click="tool = 'brush'"><Pencil :size="17" /></button><button type="button" :class="{ active: tool === 'rect' }" @click="tool = 'rect'"><Plus :size="17" /></button><button type="button" :class="{ active: tool === 'arrow' }" @click="tool = 'arrow'"><ArrowLeft class="next-icon" :size="17" /></button><input v-model="color" type="color" /><input v-model.number="size" type="range" min="2" max="14" /><button type="button" :disabled="historyIndex === 0" @click="undo"><ArrowLeft :size="17" /></button><button type="button" :disabled="historyIndex >= history.length - 1" @click="redo"><ArrowLeft class="next-icon" :size="17" /></button><button type="button" @click="clearMarks"><Trash2 :size="17" /></button></div>
<footer><button type="button" :class="{ active: editing }" @click="editing = !editing"><Pencil :size="17" />标记</button><button type="button" :disabled="referenceBusy" @click="makeReference"><Image :size="17" />{{ referenceBusy ? '处理中' : '作为参考图' }}</button><button type="button" @click="download"><Download :size="17" />下载</button></footer>
</div>
</Teleport>
</template>
<style scoped>
.next-icon{transform:rotate(180deg)}
.product-image-viewer{position:fixed;inset:0;z-index:3000;display:grid;grid-template-rows:58px minmax(0,1fr) 78px;color:var(--web-text-primary);background:var(--web-media-overlay)}
header{display:flex;align-items:center;justify-content:space-between;padding:0 22px}header span{margin-left:10px;color:#e8eef4;font-size:12px}header button,.nav{display:inline-flex;align-items:center;justify-content:center;color:#e8eef4;background:rgba(25,31,36,.82);border:1px solid rgba(255,255,255,.45);border-radius:8px;cursor:pointer}header button{width:36px;height:36px}
main{display:flex;align-items:center;justify-content:center;overflow:hidden}.image-stack{position:relative;max-width:calc(100vw - 150px);max-height:calc(100vh - 190px)}.image-stack img{display:block;max-width:100%;max-height:calc(100vh - 190px);object-fit:contain;user-select:none}.image-stack canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}.image-stack canvas.editing{pointer-events:auto;cursor:crosshair}.nav{position:fixed;top:50%;width:44px;height:58px;transform:translateY(-50%)}.nav.prev{left:22px}.nav.next{right:22px}
.mark-toolbar,footer{position:fixed;left:50%;display:flex;align-items:center;gap:6px;padding:7px;background:rgba(248,251,253,.96);border:1px solid #a9c2d8;border-radius:12px;box-shadow:0 8px 22px rgba(0,0,0,.2);transform:translateX(-50%)}.mark-toolbar{bottom:76px}.mark-toolbar button,footer button{height:38px;padding:0 11px;gap:5px;color:#5b7183;background:#f8fbfd;border:1px solid #b6cbdc;border-radius:8px;font-size:15px}.mark-toolbar button.active,footer button.active{color:#fff;background:#5d8fbb;border-color:#5d8fbb}.mark-toolbar input[type=color]{width:30px;height:30px;padding:0;border:0;border-radius:50%;overflow:hidden}.mark-toolbar input[type=range]{width:88px;accent-color:#5d8fbb}.mark-toolbar button:disabled,footer button:disabled{color:#9aaab6;background:#f1f4f6;opacity:.72;cursor:not-allowed}footer{bottom:12px}button:disabled{cursor:not-allowed}
@media(max-width:700px){.nav{width:36px}.nav.prev{left:8px}.nav.next{right:8px}.image-stack{max-width:calc(100vw - 70px)}.mark-toolbar{max-width:calc(100vw - 20px);overflow:auto}.product-image-viewer footer button{padding:0 10px;font-size:15px}}
</style>
@@ -0,0 +1,358 @@
<!-- 项目配置对话框,负责新建和编辑创作项目的基础信息。 -->
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import type { CreativeOptions, ProjectDetail, ProjectPayload } from "@/api/creative";
import CreativeSelect, { type CreativeSelectOption } from "@/components/creative/CreativeSelect.vue";
const props = withDefaults(
defineProps<{
modelValue: boolean;
options: CreativeOptions;
project?: ProjectDetail | null;
saving?: boolean;
projectType?: "video_redraw" | "premium_drama";
}>(),
{ projectType: "video_redraw" },
);
const emit = defineEmits<{ "update:modelValue": [value: boolean]; save: [payload: ProjectPayload] }>();
const form = reactive<ProjectPayload>({
project_type: props.projectType,
name: "",
style_id: "",
era_type: "",
custom_era: null,
aspect_ratio: "9:16",
localization: "",
});
const errors = reactive({ name: false, style: false, era: false, localization: false });
const title = computed(() =>
props.project
? `编辑${props.projectType === "premium_drama" ? "短剧创作" : "转绘"}项目`
: `创建${props.projectType === "premium_drama" ? "短剧创作" : "转绘"}项目`,
);
const eras = [
{ value: "modern_city", label: "现代都市" },
{ value: "ancient_history", label: "古代历史" },
{ value: "ancient_fantasy", label: "古代玄幻" },
{ value: "ancient_xianxia", label: "古代仙侠" },
{ value: "future_scifi", label: "未来科幻" },
{ value: "other", label: "其他" },
];
const regions = [
{ value: "china", label: "中国" },
{ value: "uk_us", label: "英美" },
{ value: "korea", label: "韩国" },
{ value: "japan", label: "日本" },
{ value: "france", label: "法国" },
{ value: "russia", label: "俄罗斯" },
{ value: "vietnam", label: "越南" },
{ value: "thailand", label: "泰国" },
{ value: "india", label: "印度" },
];
const styleOptions = computed<CreativeSelectOption[]>(() =>
props.options.styles.map((item) => ({ label: item.name, value: item.id })),
);
watch(
() => [props.modelValue, props.project] as const,
() => {
if (!props.modelValue) return;
Object.assign(
form,
props.project
? {
project_type: props.project.project_type,
name: props.project.name,
style_id: props.project.style_id,
era_type: props.project.era_type,
custom_era: props.project.custom_era || null,
aspect_ratio: props.project.aspect_ratio,
localization: props.project.localization || "",
}
: {
project_type: props.projectType,
name: "",
style_id: "",
era_type: "",
custom_era: null,
aspect_ratio: "9:16",
localization: "",
},
);
Object.assign(errors, { name: false, style: false, era: false, localization: false });
},
{ immediate: true },
);
watch(
() => form.name,
(value) => {
if (value.trim()) errors.name = false;
},
);
watch(
() => form.style_id,
(value) => {
if (value) errors.style = false;
},
);
watch(
() => form.era_type,
(value) => {
if (value) errors.era = false;
},
);
watch(
() => form.localization,
(value) => {
if (value) errors.localization = false;
},
);
/** 校验项目必填项并按现有载荷结构提交保存事件。 */
function submit() {
errors.name = !form.name.trim();
errors.style = !form.style_id;
errors.era = !form.era_type;
errors.localization = props.projectType === "video_redraw" && !form.localization;
if (errors.name || errors.style || errors.era || errors.localization) return;
emit("save", {
...form,
project_type: props.projectType,
localization: props.projectType === "premium_drama" ? null : form.localization,
custom_era: form.era_type === "other" ? form.custom_era : null,
});
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="min(660px, calc(100vw - 28px))"
class="web-dialog creative-dialog"
@close="emit('update:modelValue', false)"
>
<el-form
class="project-dialog-form"
label-position="top"
@submit.prevent="submit"
>
<el-form-item
label="项目名称"
:class="{ 'field-invalid': errors.name }"
><el-input
v-model.trim="form.name"
maxlength="100"
show-word-limit
placeholder="例如:都市悬疑短剧本土化"
/>
<div
v-if="errors.name"
class="field-error"
>
请输入项目名称
</div></el-form-item
>
<div class="form-grid">
<el-form-item
label="视觉风格"
:class="{ 'field-invalid': errors.style }"
><CreativeSelect
v-model="form.style_id"
:options="styleOptions"
placeholder="选择视觉风格"
/>
<div
v-if="errors.style"
class="field-error"
>
请选择视觉风格
</div></el-form-item
>
<el-form-item label="画面比例"
><div class="ratio-switch">
<button
v-for="ratio in ['9:16', '16:9']"
:key="ratio"
type="button"
:class="{ active: form.aspect_ratio === ratio }"
@click="form.aspect_ratio = ratio"
>
<span class="ratio-icon"><i :class="ratio === '9:16' ? 'portrait' : 'landscape'"></i></span
><span class="ratio-label">{{ ratio }}</span>
</button>
</div></el-form-item
>
<el-form-item
:label="props.projectType === 'premium_drama' ? '故事时代' : '原片时代'"
:class="{ 'field-invalid': errors.era }"
><CreativeSelect
v-model="form.era_type"
:options="eras"
:placeholder="props.projectType === 'premium_drama' ? '选择故事时代' : '选择原片时代'"
/>
<div
v-if="errors.era"
class="field-error"
>
请选择故事时代
</div></el-form-item
>
<el-form-item
v-if="props.projectType === 'video_redraw'"
label="本土化"
:class="{ 'field-invalid': errors.localization }"
><CreativeSelect
v-model="form.localization"
:options="regions"
placeholder="选择本土化地区"
/>
<div
v-if="errors.localization"
class="field-error"
>
请选择本土化地区
</div></el-form-item
>
</div>
<el-form-item
v-if="form.era_type === 'other'"
label="自定义时代"
><el-input
v-model.trim="form.custom_era"
maxlength="100"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="emit('update:modelValue', false)">取消</el-button
><el-button
type="primary"
:loading="saving"
@click="submit"
>{{ props.project ? "保存项目" : "创建项目" }}</el-button
></template
>
</el-dialog>
</template>
<style scoped lang="scss">
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xs) var(--space-lg);
}
.project-dialog-form:deep(.el-form-item) {
margin-bottom: var(--space-lg);
}
.project-dialog-form:deep(.el-form-item__label) {
padding-bottom: var(--space-sm);
color: var(--web-text-secondary);
font-size: 12px;
}
.project-dialog-form:deep(.el-input__wrapper) {
min-height: var(--control-height-lg);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
box-shadow: none;
}
.project-dialog-form:deep(.el-input__wrapper:hover) {
border-color: var(--web-line-emphasis);
}
.project-dialog-form:deep(.el-input__wrapper.is-focus) {
border-color: var(--web-timeline-focus-line);
box-shadow: 0 0 0 2px var(--web-dialog-focus-ring);
}
.project-dialog-form:deep(.el-input__inner) {
color: var(--web-text);
}
.project-dialog-form:deep(.el-input__count) {
color: var(--web-dialog-description);
background: transparent;
}
.ratio-switch {
width: 100%;
height: var(--control-height-lg);
padding: 3px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
}
.ratio-switch button {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--web-dialog-option-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition:
color 0.16s,
background-color 0.16s,
border-color 0.16s;
}
.ratio-switch button:hover:not(.active) {
color: var(--web-text);
background: var(--web-surface);
}
.ratio-switch button.active {
color: var(--web-accent-dark);
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.ratio-icon {
width: 14px;
height: 14px;
display: grid;
place-items: center;
flex: 0 0 14px;
}
.ratio-label {
width: 28px;
text-align: left;
font-variant-numeric: tabular-nums;
}
.ratio-switch i {
display: block;
box-sizing: border-box;
border: 1px solid currentColor;
border-radius: 1px;
opacity: 0.85;
}
.ratio-switch i.portrait {
width: 7px;
height: 12px;
}
.ratio-switch i.landscape {
width: 13px;
height: 8px;
}
.field-error {
margin-top: 5px;
color: var(--web-accent-dark);
font-size: 12px;
line-height: 1.2;
}
.field-invalid:deep(.el-input__wrapper),
.field-invalid:deep(.creative-select .el-select__wrapper) {
border-color: var(--web-invalid-line) !important;
box-shadow: 0 0 0 1px var(--web-invalid-outline) !important;
}
.field-invalid:deep(.el-input__wrapper:hover),
.field-invalid:deep(.creative-select .el-select__wrapper:hover) {
border-color: var(--web-invalid-line-hover) !important;
}
@media (max-width: 560px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+294
View File
@@ -0,0 +1,294 @@
<!-- 分镜提示词面板,负责编辑、保存和重新推理分镜提示词。 -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { CircleClose, FullScreen, Refresh, VideoPlay } from "@element-plus/icons-vue";
import type { GenerationTask, ProjectAsset, Storyboard } from "@/api/creative";
import AssetPromptEditor from "@/components/creative/AssetPromptEditor.vue";
import { taskElapsedLabel } from "@/utils/taskTimeout";
const props = defineProps<{
storyboard?: Storyboard;
assets: ProjectAsset[];
task?: GenerationTask;
reinferTask?: GenerationTask;
now?: number;
generating?: boolean;
reinferring?: boolean;
historyCount?: number;
}>();
const emit = defineEmits<{
save: [payload: Partial<Storyboard>];
generate: [payload: Partial<Storyboard>];
reinfer: [];
stopReinfer: [task: GenerationTask];
stop: [];
history: [];
}>();
const promptContent = ref(""),
expanded = ref(false),
audioDurations = ref<Record<string, number>>({});
const active = computed(
() =>
props.task &&
[
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
].includes(props.task.status),
);
const stoppable = computed(
() => props.task && ["pending_submission", "submitted", "processing"].includes(props.task.status),
);
const reinferActive = computed(() =>
Boolean(
props.reinferTask &&
[
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
].includes(props.reinferTask.status),
),
);
const reinferStopping = computed(() => props.reinferTask?.status === "cancel_requested");
const reinferBusy = computed(() => Boolean(props.reinferring) || reinferActive.value);
const reinferLabel = computed(() =>
props.reinferring
? "正在启动"
: reinferStopping.value
? "停止中"
: props.reinferTask
? `停止反推 ${taskElapsedLabel(props.reinferTask, props.now || Date.now())}`
: "重新反推当前分镜",
);
const referencedAssets = computed(() =>
props.assets.filter((asset) => asset.name.trim() && promptContent.value.includes(asset.name.trim())),
);
const imageReferenceCount = computed(() => referencedAssets.value.filter((asset) => Boolean(asset.image_url)).length);
const audioReferences = computed(() => referencedAssets.value.filter((asset) => Boolean(asset.audio_url)));
const audioReferenceDuration = computed(
() =>
audioReferences.value.reduce(
(total, asset) => total + (Number(asset.audio_duration_ms) || audioDurations.value[asset.id] || 0),
0,
) / 1000,
);
const referenceLimitReason = computed(() => {
if (imageReferenceCount.value > 9) return "参考图数量超限";
if (audioReferences.value.length > 3) return "音频数量超限";
if (audioReferenceDuration.value > 15) return "音频时长超限";
if (audioReferences.value.length > 0 && imageReferenceCount.value === 0) return "禁止单独参考音频";
return "";
});
const videoLabel = computed(() =>
props.task?.status === "cancel_requested"
? "停止中"
: active.value
? `停止生成 ${taskElapsedLabel(props.task, props.now || Date.now())}`
: referenceLimitReason.value || ((props.historyCount || 0) >= 5 ? "历史已满" : "生成视频"),
);
watch(
() => props.storyboard,
(value) => {
promptContent.value = value?.prompt_content || "";
},
{ immediate: true },
);
watch(
() => props.assets,
(assets) => {
assets
.filter((asset) => asset.audio_url && asset.audio_duration_ms == null && !audioDurations.value[asset.id])
.forEach((asset) => {
const audio = new Audio();
audio.preload = "metadata";
audio.onloadedmetadata = () => {
if (Number.isFinite(audio.duration))
audioDurations.value = { ...audioDurations.value, [asset.id]: Math.round(audio.duration * 1000) };
};
audio.src = asset.audio_url || "";
});
},
{ immediate: true, deep: true },
);
/** 组装当前编辑状态对应的分镜更新载荷。 */
function payload(): Partial<Storyboard> {
const refs = props.assets
.filter((asset) => asset.name.trim() && promptContent.value.includes(asset.name.trim()))
.map((asset) => ({ id: asset.id, name: asset.name }));
return { prompt_content: promptContent.value, asset_refs: refs };
}
/** 提交当前分镜提示词修改。 */
function save() {
if (props.storyboard && promptContent.value !== (props.storyboard.prompt_content || "")) emit("save", payload());
}
/** 保存当前内容后触发重新推理。 */
function handleReinfer() {
if (props.reinferTask) emit("stopReinfer", props.reinferTask);
else emit("reinfer");
}
</script>
<template>
<aside class="prompt-panel">
<header><strong>分镜提示词</strong></header>
<div class="prompt-panel-body">
<template v-if="storyboard"
><div class="prompt-editor-wrap">
<AssetPromptEditor
v-model="promptContent"
:assets="assets"
placeholder="输入提示词,输入 @ 插入资产"
@blur="save"
/><button
class="expand-button"
type="button"
title="放大编辑提示词"
@click="expanded = true"
>
<el-icon><FullScreen /></el-icon>
</button>
</div>
<div class="prompt-actions">
<el-button
:type="reinferActive ? 'danger' : 'default'"
:icon="reinferActive ? CircleClose : Refresh"
:loading="Boolean(reinferring)"
:disabled="active || generating || reinferStopping || Boolean(reinferring)"
@click="handleReinfer"
>{{ reinferLabel }}</el-button
><el-button
v-if="active || (!reinferBusy && Boolean(promptContent.trim()))"
:type="active ? 'danger' : 'primary'"
:icon="active ? CircleClose : VideoPlay"
:loading="!active && generating"
:disabled="active ? !stoppable : (historyCount || 0) >= 5 || Boolean(referenceLimitReason)"
@click="active ? emit('stop') : emit('generate', payload())"
>{{ videoLabel }}</el-button
>
</div></template
>
<div
v-else
class="prompt-empty"
>
从下方时间线选择一个分镜。
</div>
</div>
<el-dialog
v-model="expanded"
title="提示词"
width="min(900px, calc(100vw - 28px))"
class="web-dialog creative-dialog prompt-dialog"
append-to-body
align-center
@closed="save"
><AssetPromptEditor
v-model="promptContent"
:assets="assets"
placeholder="输入提示词,输入 @ 插入资产"
expanded
@blur="save"
/></el-dialog>
</aside>
</template>
<style scoped lang="scss">
.prompt-panel {
min-width: 0;
display: flex;
flex-direction: column;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 11px;
overflow: hidden;
}
.prompt-panel > header {
height: 40px;
padding: 0 14px;
display: flex;
align-items: center;
flex: 0 0 40px;
border-bottom: 1px solid var(--web-line-strong);
}
.prompt-panel-body {
min-height: 0;
padding: 14px;
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.prompt-editor-wrap {
position: relative;
min-height: 0;
flex: 1;
overflow: hidden;
}
.expand-button {
position: absolute;
right: 9px;
bottom: 9px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--web-panel-control-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
cursor: pointer;
}
.expand-button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-emphasis);
}
.expand-button:focus-visible {
outline: 2px solid var(--web-primary);
outline-offset: 2px;
}
.prompt-actions {
display: grid;
grid-template-columns: 1fr 1.25fr;
gap: 8px;
}
.prompt-empty {
min-height: 0;
display: grid;
place-items: center;
flex: 1;
color: var(--web-text-tertiary);
font-size: 13px;
}
</style>
<style lang="scss">
.prompt-dialog.el-dialog {
height: min(720px, calc(100dvh - 40px));
display: flex;
flex-direction: column;
}
.prompt-dialog .el-dialog__header {
flex: 0 0 auto;
}
.prompt-dialog .el-dialog__body {
min-height: 0;
display: flex;
flex: 1;
overflow: hidden;
}
.prompt-dialog .asset-prompt-editor {
min-height: 0;
height: 100%;
flex: 1;
overflow: hidden;
}
.prompt-dialog .asset-prompt-editor.expanded .prompt-content {
min-height: 0;
overflow: auto;
}
</style>
@@ -0,0 +1,175 @@
<!-- 重绘资产面板,负责资产信息编辑、保存和空状态展示。 -->
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import { Box, Delete, OfficeBuilding, UserFilled } from "@element-plus/icons-vue";
import type { ProjectAsset } from "@/api/creative";
const props = defineProps<{ assets: ProjectAsset[]; busy?: boolean }>();
const emit = defineEmits<{
save: [assetId: string, payload: Partial<ProjectAsset>];
delete: [asset: ProjectAsset];
}>();
const names = reactive<Record<string, string>>({});
const groups = [
{ type: "character", label: "角色", icon: UserFilled },
{ type: "scene", label: "场景", icon: OfficeBuilding },
{ type: "prop", label: "道具", icon: Box },
];
const grouped = computed(() =>
groups.map((group) => ({ ...group, items: props.assets.filter((asset) => asset.asset_type === group.type) })),
);
watch(
() => props.assets,
(assets) => assets.forEach((asset) => (names[asset.id] = asset.name)),
{ immediate: true, deep: true },
);
watch(
() => props.busy,
(busy, wasBusy) => {
if (wasBusy && !busy) props.assets.forEach((asset) => (names[asset.id] = asset.name));
},
);
/** 合并当前编辑值并提交资产更新。 */
function save(asset: ProjectAsset) {
const name = (names[asset.id] || "").trim();
if (!name) {
names[asset.id] = asset.name;
return;
}
if (name !== asset.name) emit("save", asset.id, { name });
}
/** 触发资产删除确认,由工作台负责调用后端并刷新数据。 */
function remove(asset: ProjectAsset) {
emit("delete", asset);
}
</script>
<template>
<section class="redraw-column asset-column">
<header>
<strong>资产</strong><small>{{ assets.length }}</small>
</header>
<div
v-loading="busy"
class="asset-list"
>
<template
v-for="group in grouped"
:key="group.type"
>
<div
v-if="group.items.length"
class="asset-group"
>
<div class="asset-group-title">
<el-icon><component :is="group.icon" /></el-icon><span>{{ group.label }}</span
><small>{{ group.items.length }}</small>
</div>
<label
v-for="asset in group.items"
:key="asset.id"
class="asset-name"
>
<span>{{ group.label }}</span>
<div class="asset-edit-row">
<el-input
v-model="names[asset.id]"
maxlength="80"
@blur="save(asset)"
/>
<el-button
class="asset-delete-button"
text
:icon="Delete"
:disabled="busy"
title="删除资产"
@click="remove(asset)"
/>
</div>
</label>
</div>
</template>
<div
v-if="!assets.length"
class="column-empty"
>
反推完成后将在这里显示资产。
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.asset-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.asset-list {
min-height: 0;
padding: 12px;
flex: 1;
overflow: auto;
}
.column-empty {
width: 100%;
height: 100%;
min-height: 240px;
padding: 24px;
display: grid;
place-items: center;
color: var(--web-muted);
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.asset-group + .asset-group {
margin-top: 16px;
}
.asset-group-title {
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 7px;
color: var(--web-accent-dark);
font-size: 13px;
}
.asset-group-title small {
margin-left: auto;
color: var(--web-panel-hint);
}
.asset-name {
margin-bottom: 8px;
padding: 9px;
display: block;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
}
.asset-name > span {
margin-bottom: 6px;
display: block;
color: var(--web-text-tertiary);
font-size: 11px;
}
.asset-name :deep(.el-input__wrapper) {
background: var(--web-surface-soft);
box-shadow: 0 0 0 1px var(--web-line-strong) inset;
}
.asset-edit-row {
display: flex;
align-items: center;
gap: 6px;
}
.asset-edit-row .el-input {
min-width: 0;
flex: 1;
}
.asset-delete-button {
flex: 0 0 auto;
color: var(--web-danger);
}
</style>
@@ -0,0 +1,184 @@
/* 重绘项目对话框样式,负责表单分组、选项卡片和错误提示布局。 */
.redraw-project-form {
min-height: 0;
}
:global(.redraw-project-dialog.el-dialog) {
height: min(720px, calc(100vh - 32px));
display: flex;
flex-direction: column;
}
:global(.redraw-project-dialog.el-dialog .el-dialog__header),
:global(.redraw-project-dialog.el-dialog .el-dialog__footer) {
flex: 0 0 auto;
}
:global(.redraw-project-dialog.el-dialog .el-dialog__body) {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
}
.redraw-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xs) var(--space-lg);
}
.redraw-project-form :deep(.el-form-item) {
margin-bottom: var(--space-lg);
}
.redraw-project-form :deep(.el-form-item__label) {
padding-bottom: var(--space-sm);
color: var(--web-text-secondary);
font-size: 12px;
}
.redraw-project-form :deep(.el-input__wrapper),
.redraw-project-form :deep(.el-textarea__inner) {
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
box-shadow: none;
}
.redraw-project-form :deep(.el-input__wrapper) {
min-height: var(--control-height-lg);
}
.redraw-project-form :deep(.el-input-number) {
width: 100%;
}
.redraw-project-form :deep(.el-textarea__inner) {
padding: 10px 12px;
color: var(--web-text);
line-height: 1.6;
}
.redraw-project-form :deep(.el-input__wrapper:hover),
.redraw-project-form :deep(.el-textarea__inner:hover) {
border-color: var(--web-line-emphasis);
}
.field-error {
margin-top: 5px;
color: var(--web-accent-dark);
font-size: 12px;
line-height: 1.25;
}
.field-invalid :deep(.el-input__wrapper),
.field-invalid :deep(.el-textarea__inner) {
border-color: var(--web-invalid-line);
}
.cast-editor {
padding: var(--space-lg);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-md);
}
.cast-editor > header {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.cast-editor > header > div {
display: flex;
align-items: baseline;
gap: 9px;
}
.cast-editor > header strong {
color: var(--web-text);
font-size: 14px;
}
.cast-editor > header span {
color: var(--web-project-tooltip-label);
font-size: 11px;
}
.cast-editor button {
min-height: var(--control-height-md);
padding: 0 11px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--web-project-badge-text);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: var(--radius-sm);
cursor: pointer;
}
.cast-editor button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.cast-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 38px;
align-items: start;
gap: 10px;
}
.cast-row + .cast-row {
padding-top: 11px;
border-top: 1px solid var(--web-project-tooltip-line);
}
.cast-row :deep(.el-form-item) {
margin-bottom: 11px;
}
.cast-row .remove-cast {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
margin-top: 36px;
color: var(--web-accent-dark);
}
.cast-empty {
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 9px;
color: var(--web-accent-dark);
font-size: 12px;
}
@media (max-width: 620px) {
.redraw-form-grid,
.cast-row {
grid-template-columns: 1fr;
}
.cast-editor > header,
.cast-editor > header > div {
align-items: stretch;
flex-direction: column;
}
.cast-editor > header > div {
gap: 3px;
}
.cast-row .remove-cast {
width: 100%;
margin: 0 0 11px;
}
}
@@ -0,0 +1,283 @@
<!-- 重绘项目编辑对话框,负责项目元数据和主演信息录入。 -->
<script setup lang="ts">
import { reactive, watch } from "vue";
import { Plus, Trash2 } from "@/icons";
import type { ProjectCastMember, ProjectDetail, ProjectPayload } from "@/api/creative";
const props = defineProps<{
modelValue: boolean;
project?: ProjectDetail | null;
saving?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
save: [payload: ProjectPayload];
}>();
type RedrawProjectForm = {
name: string;
short_drama_type: string;
play_count: string;
audience_profile: string;
producer: string;
cast_members: ProjectCastMember[];
};
const form = reactive<RedrawProjectForm>({
name: "",
short_drama_type: "",
play_count: "",
audience_profile: "",
producer: "",
cast_members: [{ name: "", follower_count: undefined }],
});
const errors = reactive({
name: "",
short_drama_type: "",
audience_profile: "",
producer: "",
cast_members: [] as Array<{ name: string; follower_count: string }>,
});
watch(
() => [props.modelValue, props.project] as const,
() => {
if (!props.modelValue) return;
const project = props.project;
form.name = project?.name || "";
form.short_drama_type = project?.short_drama_type || "";
form.play_count = project?.play_count || "";
form.audience_profile = project?.audience_profile || "";
form.producer = project?.producer || "";
form.cast_members = project?.cast_members?.length
? project.cast_members.map((member) => ({ ...member }))
: [{ name: "", follower_count: undefined }];
resetErrors();
},
{ immediate: true },
);
function resetErrors() {
errors.name = "";
errors.short_drama_type = "";
errors.audience_profile = "";
errors.producer = "";
errors.cast_members = [];
}
function addCastMember() {
if (form.cast_members.length >= 50) return;
form.cast_members.push({ name: "", follower_count: undefined });
}
function removeCastMember(index: number) {
form.cast_members.splice(index, 1);
errors.cast_members.splice(index, 1);
}
function submit() {
resetErrors();
errors.name = form.name.trim() ? "" : "请输入短剧名";
errors.cast_members = form.cast_members.map((member) => {
const hasName = Boolean(member.name.trim());
const hasFollowerCount = Boolean(member.follower_count?.trim());
return {
name: !hasName && hasFollowerCount ? "请输入主演姓名" : "",
follower_count: "",
};
});
const invalid = errors.name || errors.cast_members.some((item) => item.name || item.follower_count);
if (invalid) return;
emit("save", {
project_type: "video_redraw",
name: form.name.trim(),
style_id: props.project?.style_id || "",
era_type: props.project?.era_type || "modern_city",
custom_era: props.project?.custom_era || null,
aspect_ratio: props.project?.aspect_ratio || "9:16",
localization: props.project?.localization || "china",
short_drama_type: form.short_drama_type.trim(),
play_count: form.play_count.trim(),
audience_profile: form.audience_profile.trim(),
producer: form.producer.trim(),
cast_members: form.cast_members
.filter((member) => member.name.trim())
.map((member) => ({
name: member.name.trim(),
follower_count: member.follower_count?.trim() || undefined,
})),
});
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="project ? '编辑反推项目' : '创建反推项目'"
width="min(680px, calc(100vw - 28px))"
align-center
class="web-dialog creative-dialog redraw-project-dialog"
@close="emit('update:modelValue', false)"
>
<el-form
class="redraw-project-form"
label-position="top"
>
<div class="redraw-form-grid">
<el-form-item
label="短剧名"
required
:class="{ 'field-invalid': errors.name }"
>
<el-input
v-model.trim="form.name"
maxlength="100"
placeholder="请输入短剧名"
/>
<div
v-if="errors.name"
class="field-error"
>
{{ errors.name }}
</div>
</el-form-item>
<el-form-item
label="短剧类型"
:class="{ 'field-invalid': errors.short_drama_type }"
>
<el-input
v-model.trim="form.short_drama_type"
maxlength="100"
placeholder="例如:都市情感、悬疑"
/>
<div
v-if="errors.short_drama_type"
class="field-error"
>
{{ errors.short_drama_type }}
</div>
</el-form-item>
<el-form-item label="播放量">
<el-input
v-model.trim="form.play_count"
maxlength="100"
placeholder="请输入播放量"
/>
</el-form-item>
<el-form-item
label="出品方"
:class="{ 'field-invalid': errors.producer }"
>
<el-input
v-model.trim="form.producer"
maxlength="200"
placeholder="请输入出品方"
/>
<div
v-if="errors.producer"
class="field-error"
>
{{ errors.producer }}
</div>
</el-form-item>
</div>
<el-form-item
label="受众群体画像"
:class="{ 'field-invalid': errors.audience_profile }"
>
<el-input
v-model.trim="form.audience_profile"
type="textarea"
:rows="3"
maxlength="2000"
show-word-limit
placeholder="请输入年龄、性别、地域、兴趣偏好等受众特征"
/>
<div
v-if="errors.audience_profile"
class="field-error"
>
{{ errors.audience_profile }}
</div>
</el-form-item>
<section class="cast-editor">
<header>
<div>
<strong>主演</strong>
<span>填写主演姓名和粉丝量</span>
</div>
<button
type="button"
:disabled="form.cast_members.length >= 50"
@click="addCastMember"
>
<Plus :size="16" />增加主演
</button>
</header>
<div
v-if="!form.cast_members.length"
class="cast-empty"
>
<span>{{ errors.cast_members[0]?.name || "暂无主演" }}</span>
<button
type="button"
@click="addCastMember"
>
<Plus :size="16" />添加主演
</button>
</div>
<div
v-for="(member, index) in form.cast_members"
:key="index"
class="cast-row"
>
<el-form-item
:label="`主演 ${index + 1}`"
:class="{ 'field-invalid': errors.cast_members[index]?.name }"
>
<el-input
v-model.trim="member.name"
maxlength="100"
placeholder="主演姓名"
/>
<div
v-if="errors.cast_members[index]?.name"
class="field-error"
>
{{ errors.cast_members[index].name }}
</div>
</el-form-item>
<el-form-item label="粉丝量">
<el-input
v-model.trim="member.follower_count"
maxlength="100"
placeholder="粉丝量"
/>
</el-form-item>
<button
type="button"
class="remove-cast"
@click="removeCastMember(index)"
>
<Trash2 :size="17" />
</button>
</div>
</section>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button
type="primary"
:loading="saving"
@click="submit"
>
{{ project ? "保存项目" : "创建项目" }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss" src="./RedrawProjectDialog.styles.scss"></style>
@@ -0,0 +1,226 @@
<!-- 重绘脚本面板,负责脚本反推任务状态、正文编辑、保存状态和复制操作。 -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { CircleClose, Document } from "@element-plus/icons-vue";
import { Copy } from "@/icons";
import type { GenerationTask } from "@/api/creative";
import { taskElapsedLabel } from "@/utils/taskTimeout";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{
content: string;
ready: boolean;
task?: GenerationTask;
starting?: boolean;
saving?: boolean;
saveState?: "idle" | "saved" | "error";
now?: number;
}>();
const emit = defineEmits<{
reverse: [];
stop: [task: GenerationTask];
"update:content": [content: string];
save: [];
}>();
const toast = useToastStore();
const editorVisible = ref(Boolean(props.content));
const active = computed(() =>
Boolean(
props.task &&
[
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
].includes(props.task.status),
),
);
const stopping = computed(() => props.task?.status === "cancel_requested");
const saveLabel = computed(() =>
props.saving ? "保存中" : props.saveState === "error" ? "保存失败" : props.saveState === "saved" ? "已保存" : "",
);
const buttonLabel = computed(() =>
props.starting
? "正在启动"
: stopping.value
? "停止中"
: active.value
? `停止反推 ${taskElapsedLabel(props.task!, props.now || Date.now())}`
: !props.ready
? "请先完成所有分镜反推"
: props.content.trim()
? "重新反推为剧本"
: "反推为剧本",
);
/** 根据当前任务状态触发生成、停止或重新生成操作。 */
function handleAction() {
if (active.value && props.task) emit("stop", props.task);
else emit("reverse");
}
/** 更新本地剧本文本并通知父级安排自动保存。 */
function updateContent(value: string) {
emit("update:content", value);
}
watch(
() => props.content,
(content) => {
if (content) editorVisible.value = true;
},
);
/** 将反推脚本复制到剪贴板并展示结果提示。 */
async function copyScript() {
if (!props.content.trim()) return;
try {
await navigator.clipboard.writeText(props.content);
toast.success("剧本已复制");
} catch {
toast.error("复制失败,请检查浏览器剪贴板权限");
}
}
</script>
<template>
<section class="redraw-column script-column">
<header>
<strong>剧本</strong><small v-if="editorVisible">{{ content.length }} 字</small>
<small
v-if="saveLabel"
class="script-save-state"
:class="{ 'is-error': saveState === 'error' }"
>{{ saveLabel }}</small
>
<button
v-if="content"
type="button"
class="script-copy-button"
title="复制剧本"
@click="copyScript"
>
<Copy :size="15" />
<span>复制</span>
</button>
</header>
<div class="script-body">
<el-input
v-if="editorVisible"
:model-value="content"
type="textarea"
resize="none"
:disabled="active"
@update:model-value="updateContent"
@blur="$emit('save')"
/>
<div
v-else
class="script-empty"
>
<el-icon><Document /></el-icon>
<strong>{{ active ? "正在反推完整剧本" : "尚未反推剧本" }}</strong>
<span>{{ ready ? "全部分镜提示词已就绪,可以开始反推。" : "请先完成所有分镜反推。" }}</span>
</div>
<el-button
class="script-action-button"
:class="{ 'is-unready': !active && !ready }"
:type="active ? 'danger' : 'primary'"
:icon="active ? CircleClose : Document"
:loading="starting"
:disabled="starting || stopping || (!active && !ready)"
@click="handleAction"
>{{ buttonLabel }}</el-button
>
</div>
</section>
</template>
<style scoped lang="scss">
.script-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.script-copy-button {
min-height: 28px;
margin-left: auto;
padding: 0 9px;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--web-accent-muted);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition:
color 0.16s,
background-color 0.16s,
border-color 0.16s;
}
.script-save-state {
color: var(--web-text-disabled);
}
.script-save-state.is-error {
color: var(--el-color-danger);
}
.script-copy-button:hover {
color: var(--web-text);
background: var(--web-hover);
border-color: var(--web-scrollbar-global-thumb);
}
.script-body {
min-height: 0;
padding: 12px;
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
}
.script-body :deep(.el-textarea),
.script-body :deep(.el-textarea__inner) {
min-height: 0;
height: 100%;
flex: 1;
}
.script-body :deep(.el-textarea__inner) {
padding: 12px;
color: var(--web-text-secondary);
line-height: 1.75;
background: var(--web-surface-soft);
box-shadow: 0 0 0 1px var(--web-line-strong) inset;
}
.script-empty {
min-height: 260px;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 9px;
color: var(--web-script-hint);
text-align: center;
}
.script-empty .el-icon {
color: var(--web-accent-dark);
font-size: 30px;
}
.script-empty strong {
color: var(--web-text-secondary);
font-size: 14px;
}
.script-empty span {
max-width: 240px;
font-size: 12px;
line-height: 1.6;
}
.script-action-button.is-unready,
.script-action-button.is-unready:hover {
color: var(--web-text-disabled);
background: var(--web-surface-soft);
border-color: var(--web-line-strong);
opacity: 1;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,126 @@
<!-- 重绘源视频面板,负责源视频选择、上传状态和预览展示。 -->
<script setup lang="ts">
import { Upload, VideoCamera } from "@element-plus/icons-vue";
import type { RedrawSource } from "@/api/creative";
import VideoPlayer from "@/components/creative/VideoPlayer.vue";
defineProps<{ source?: RedrawSource; uploading?: boolean; dragging?: boolean }>();
const emit = defineEmits<{ upload: [file: File] }>();
/** 读取用户选择的源视频并通知父组件上传。 */
function choose(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (file) emit("upload", file);
}
</script>
<template>
<section
class="redraw-column source-column"
:class="{ dragging }"
>
<header><strong>原视频上传</strong></header>
<div class="source-body">
<VideoPlayer
v-if="source?.source_video_url"
:src="source.source_video_url"
:media-id="source.source_video_asset_id"
aspect-ratio="9:16"
transparent-controls
/>
<label
v-else
class="source-empty"
>
<input
type="file"
accept="video/mp4,video/webm,video/quicktime,video/x-m4v,.mp4,.webm,.mov,.m4v"
:disabled="uploading"
@change="choose"
/>
<el-icon><VideoCamera /></el-icon>
<strong>{{ uploading ? "正在上传" : "上传原视频" }}</strong>
<span>支持 MP4、WebM、MOV、M4V</span>
<span>最大 50MB,时长不超过 3 分钟</span>
</label>
<label
v-if="source?.source_video_url"
class="replace-video"
>
<input
type="file"
accept="video/mp4,video/webm,video/quicktime,video/x-m4v,.mp4,.webm,.mov,.m4v"
:disabled="uploading"
@change="choose"
/>
<el-button
:icon="Upload"
:loading="uploading"
>重新上传</el-button
>
</label>
</div>
</section>
</template>
<style scoped lang="scss">
.source-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.source-column.dragging {
border-color: var(--web-upload-hover-line);
background: var(--web-surface);
}
.source-body {
min-height: 0;
padding: 12px;
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
}
.source-body :deep(.video-player) {
min-height: 0;
flex: 1;
}
.source-empty {
min-height: 320px;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: var(--web-text-tertiary);
background: var(--web-surface-soft);
border: 1px dashed var(--web-line-strong);
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.source-empty input,
.replace-video input {
display: none;
}
.source-empty .el-icon {
color: var(--web-accent-dark);
font-size: 36px;
}
.source-empty strong {
color: var(--web-text);
font-size: 16px;
}
.source-empty span {
font-size: 12px;
}
.replace-video {
display: block;
}
.replace-video .el-button {
width: 100%;
}
</style>
@@ -0,0 +1,198 @@
<!-- 重绘文本面板,负责剧集文本编辑、保存和重新推理控制。 -->
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { CircleClose, FullScreen, Refresh } from "@element-plus/icons-vue";
import type { GenerationTask, ProjectAsset, Storyboard } from "@/api/creative";
import AssetPromptEditor from "@/components/creative/AssetPromptEditor.vue";
import { taskElapsedLabel } from "@/utils/taskTimeout";
const props = defineProps<{
storyboards: Storyboard[];
assets: ProjectAsset[];
selectedId: string;
reinferTask?: GenerationTask;
now?: number;
reinferring?: boolean;
}>();
const emit = defineEmits<{
save: [payload: Partial<Storyboard>];
reinfer: [];
stopReinfer: [task: GenerationTask];
}>();
const content = ref("");
const expanded = ref(false);
const selected = computed(() => props.storyboards.find((item) => item.id === props.selectedId));
const active = computed(() =>
Boolean(
props.reinferTask &&
[
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
].includes(props.reinferTask.status),
),
);
const actionLabel = computed(() =>
props.reinferring
? "正在启动"
: props.reinferTask?.status === "cancel_requested"
? "停止中"
: active.value
? `停止反推 ${taskElapsedLabel(props.reinferTask!, props.now || Date.now())}`
: "重新反推",
);
watch(selected, (storyboard) => (content.value = storyboard?.prompt_content || ""), { immediate: true });
/** 提交当前文本修改。 */
function save() {
if (selected.value && content.value !== (selected.value.prompt_content || "")) {
const refs = props.assets
.filter((asset) => asset.name.trim() && content.value.includes(`@${asset.name.trim()}`))
.map((asset) => ({ id: asset.id, name: asset.name }));
emit("save", { prompt_content: content.value, asset_refs: refs });
}
}
/** 根据任务状态触发或取消重新推理。 */
function toggleReinfer() {
if (props.reinferTask) emit("stopReinfer", props.reinferTask);
else emit("reinfer");
}
</script>
<template>
<section class="redraw-column text-column">
<header>
<strong>分镜提示词</strong><small>{{ storyboards.length }} 个分镜</small>
</header>
<div
v-if="selected"
class="text-editor"
>
<div class="selected-heading">
<strong>分镜 {{ selected.sequence_no }}</strong
><span>{{ selected.title || "未命名分镜" }}</span>
</div>
<div class="prompt-editor-wrap">
<AssetPromptEditor
v-model="content"
:assets="assets"
variant="redraw"
placeholder="输入提示词,输入 @ 插入资产"
@blur="save"
/>
<button
class="expand-button"
type="button"
title="放大编辑提示词"
@click="expanded = true"
>
<el-icon><FullScreen /></el-icon>
</button>
</div>
<el-button
:type="active ? 'danger' : 'default'"
:icon="active ? CircleClose : Refresh"
:loading="reinferring"
:disabled="reinferTask?.status === 'cancel_requested'"
@click="toggleReinfer"
>{{ actionLabel }}</el-button
>
</div>
<div
v-else
class="column-empty"
>
完成视频抽帧后,将在这里显示分镜提示词。
</div>
<el-dialog
v-model="expanded"
title="分镜提示词"
width="min(900px, calc(100vw - 28px))"
class="web-dialog creative-dialog prompt-dialog"
append-to-body
@closed="save"
>
<AssetPromptEditor
v-model="content"
:assets="assets"
variant="redraw"
placeholder="输入提示词,输入 @ 插入资产"
expanded
@blur="save"
/>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.text-column {
min-width: 0;
display: flex;
flex-direction: column;
}
.text-editor {
min-height: 0;
padding: 12px;
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
}
.selected-heading {
display: flex;
align-items: baseline;
gap: 8px;
}
.selected-heading strong {
color: var(--web-accent-dark);
font-size: 13px;
}
.selected-heading span {
min-width: 0;
overflow: hidden;
color: var(--web-text-tertiary);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-editor-wrap {
position: relative;
min-height: 0;
flex: 1;
overflow: hidden;
}
.prompt-editor-wrap :deep(.asset-prompt-editor) {
height: 100%;
}
.expand-button {
position: absolute;
right: 9px;
bottom: 9px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--web-panel-control-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
cursor: pointer;
}
.expand-button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-emphasis);
}
.column-empty {
min-height: 240px;
display: grid;
place-items: center;
flex: 1;
color: var(--web-panel-hint);
font-size: 13px;
text-align: center;
}
</style>
@@ -0,0 +1,773 @@
<!-- 剧本分析结果组件,负责按业务分类展示结构化分析内容。 -->
<script setup lang="ts">
import { computed, ref } from "vue";
import { BookOpenText, CircleX, Clapperboard, ListChecks, LoaderCircle, Tags, Target, Users, Zap } from "@/icons";
import type { ScriptAnalysisCharacter, ScriptAnalysisResult } from "@/api/creative";
const props = defineProps<{
result?: ScriptAnalysisResult;
characters?: ScriptAnalysisCharacter[];
status?: string;
message?: string;
}>();
const active = ref("tags");
const categories = computed(() => [
{ key: "tags", label: "标签", title: "标签", icon: Tags },
{ key: "selling", label: "看点", title: "看点卖点", icon: Target },
{ key: "story", label: "剧情", title: "剧情结构", icon: BookOpenText },
{ key: "characters", label: "角色", title: "角色库", icon: Users },
{ key: "opening", label: "开篇", title: "黄金三秒", icon: Zap },
{ key: "hooks", label: "钩子", title: "单集钩子", icon: Clapperboard },
...(props.result?.custom_analysis?.length || props.result?.unmatched_objectives?.length
? [{ key: "custom", label: "其他", title: "其他分析", icon: ListChecks }]
: []),
]);
const hasResult = computed(() => props.status === "succeeded" && Boolean(Object.keys(props.result || {}).length));
const analysisCharacters = computed(() => props.characters || []);
</script>
<template>
<article class="analysis-result">
<header class="analysis-result__header">
<strong>分析结果</strong>
<span
v-if="status === 'queued' || status === 'running'"
class="analysis-state is-running"
>
{{ message || "正在分析剧本" }}
</span>
<span
v-else-if="status === 'failed'"
class="analysis-state is-failed"
>
分析失败
</span>
<span
v-else-if="hasResult"
class="analysis-state"
>已完成</span
>
</header>
<template v-if="hasResult">
<nav class="analysis-categories">
<button
v-for="category in categories"
:key="category.key"
type="button"
:class="{ active: active === category.key }"
:title="category.title"
@click="active = category.key"
>
<component
:is="category.icon"
:size="16"
/>
<span>{{ category.label }}</span>
</button>
</nav>
<div class="analysis-content">
<section
v-if="active === 'tags'"
class="result-stack tag-section-grid"
>
<div class="result-block">
<h3>短剧标签</h3>
<div class="tag-list">
<span
v-for="tag in result?.tags?.drama_tags || []"
:key="tag"
>{{ tag }}</span
>
<p
v-if="!result?.tags?.drama_tags?.length"
class="empty-copy"
>
暂无标签
</p>
</div>
</div>
<div class="result-block">
<h3>适用场景标签</h3>
<div class="tag-list scene-tags">
<span
v-for="tag in result?.tags?.applicable_scene_tags || []"
:key="tag"
>{{ tag }}</span
>
<p
v-if="!result?.tags?.applicable_scene_tags?.length"
class="empty-copy"
>
暂无适用场景标签
</p>
</div>
</div>
</section>
<section
v-else-if="active === 'selling'"
class="result-stack selling-layout"
>
<div class="summary-card">
<span>一句话总结</span>
<p>{{ result?.selling_point?.summary || "暂无总结" }}</p>
</div>
<div class="highlight-list content-card-grid">
<article
v-for="(item, index) in result?.selling_point?.highlights || []"
:key="`${item.type}-${index}`"
>
<span>{{ item.type }}</span>
<p>{{ item.content }}</p>
</article>
<p
v-if="!result?.selling_point?.highlights?.length"
class="empty-copy"
>
暂无核心看点
</p>
</div>
</section>
<section
v-else-if="active === 'story'"
class="result-stack"
>
<div class="result-block prose-block">
<h3>短剧简介</h3>
<p>{{ result?.story_structure?.synopsis || "暂无简介" }}</p>
</div>
<div class="result-block">
<h3>故事大纲</h3>
<ol class="outline-list">
<li
v-for="(item, index) in result?.story_structure?.story_outline || []"
:key="index"
>
{{ item }}
</li>
</ol>
</div>
<div class="result-block">
<h3>情绪大纲</h3>
<div class="emotion-list content-card-grid">
<article
v-for="(item, index) in result?.story_structure?.emotion_outline || []"
:key="index"
>
<header>
<strong>{{ item.stage }}</strong
><span>{{ item.emotion }}</span>
</header>
<p>{{ item.description }}</p>
</article>
</div>
</div>
<div class="result-block">
<h3>分集大纲</h3>
<div class="episode-list content-card-grid">
<article
v-for="item in result?.story_structure?.episode_outlines || []"
:key="item.episode_no"
>
<header>
<strong>第 {{ item.episode_no }} 集</strong><span>{{ item.title }}</span>
</header>
<p>{{ item.outline }}</p>
<small v-if="item.emotion">情绪:{{ item.emotion }}</small>
</article>
</div>
</div>
</section>
<section
v-else-if="active === 'characters'"
class="result-stack"
>
<div class="result-block">
<div class="character-list content-card-grid">
<article
v-for="character in analysisCharacters"
:key="character.id"
>
<header>
<strong>{{ character.name }}</strong
><span>{{ character.faction }}</span>
</header>
<p>{{ character.biography }}</p>
<dl>
<template v-if="character.motivation"
><dt>核心动机</dt>
<dd>{{ character.motivation }}</dd></template
>
<template v-if="character.relationship"
><dt>人物关系</dt>
<dd>{{ character.relationship }}</dd></template
>
</dl>
</article>
<p
v-if="!analysisCharacters.length"
class="empty-copy"
>
暂无人物小传
</p>
</div>
</div>
</section>
<section
v-else-if="active === 'opening'"
class="result-stack"
>
<div class="opening-grid">
<article>
<span>开篇第一个画面</span>
<p>{{ result?.opening_hook?.first_scene || "暂无" }}</p>
</article>
<article>
<span>第一句台词</span>
<p>{{ result?.opening_hook?.first_line || "暂无" }}</p>
</article>
</div>
<div class="result-block">
<h3>抓眼方式</h3>
<div class="tag-list">
<span
v-for="method in result?.opening_hook?.attention_methods || []"
:key="method"
>{{ method }}</span
>
</div>
</div>
<div class="result-block prose-block">
<h3>分析</h3>
<p>{{ result?.opening_hook?.analysis || "暂无分析" }}</p>
</div>
<div
v-if="result?.opening_hook?.optimization"
class="result-block prose-block"
>
<h3>优化建议</h3>
<p>{{ result.opening_hook.optimization }}</p>
</div>
</section>
<section
v-else-if="active === 'hooks'"
class="result-stack"
>
<div class="result-block">
<h3>前十集逐集钩子</h3>
<div class="hook-list">
<article
v-for="item in result?.episode_hooks || []"
:key="item.episode_no"
>
<div class="hook-number">{{ item.episode_no }}</div>
<div>
<header>
<strong>第 {{ item.episode_no }} 集</strong><span>{{ item.hook_type }}</span>
</header>
<p>{{ item.hook }}</p>
<small>{{ item.basis }}</small>
</div>
</article>
</div>
</div>
<div class="result-block paywall-block">
<h3>第十集付费卡点候选</h3>
<div class="paywall-list content-card-grid">
<article
v-for="(item, index) in result?.episode_10_paywall_hooks || []"
:key="index"
>
<header>
<strong>候选 {{ index + 1 }}</strong
><span>{{ item.hook_type }}</span>
</header>
<p>{{ item.content }}</p>
<small>{{ item.appeal }}</small>
</article>
</div>
</div>
</section>
<section
v-else
class="result-stack"
>
<div class="result-block">
<h3>补充分析</h3>
<div class="content-card-grid custom-analysis-list">
<article
v-for="(item, index) in result?.custom_analysis || []"
:key="`${item.objective}-${index}`"
>
<header>
<strong>{{ item.objective }}</strong>
</header>
<p>{{ item.conclusion }}</p>
<small v-if="item.evidence?.length">原文依据:{{ item.evidence.join(";") }}</small>
</article>
<p
v-if="!result?.custom_analysis?.length"
class="empty-copy"
>
暂无补充分析
</p>
</div>
</div>
<div
v-if="result?.unmatched_objectives?.length"
class="result-block"
>
<h3>无法完成的目标</h3>
<div class="content-card-grid custom-analysis-list">
<article
v-for="(item, index) in result.unmatched_objectives"
:key="`${item.objective}-${index}`"
>
<header>
<strong>{{ item.objective }}</strong>
</header>
<p>{{ item.reason }}</p>
</article>
</div>
</div>
</section>
</div>
</template>
<div
v-else
class="analysis-empty"
>
<LoaderCircle
v-if="status === 'queued' || status === 'running'"
class="analysis-loading-icon"
:size="38"
/>
<CircleX
v-else-if="status === 'failed'"
class="analysis-failed-icon"
:size="38"
stroke-width="1.3"
/>
<BookOpenText
v-else
:size="38"
stroke-width="1.3"
/>
<strong>{{
status === "queued" || status === "running" ? "正在分析剧本" : status === "failed" ? "分析失败" : "尚未开始分析"
}}</strong>
<p
v-if="status === 'failed'"
class="analysis-error-message"
>
{{ message || "剧本分析失败,请重试" }}
</p>
</div>
</article>
</template>
<style scoped lang="scss">
.analysis-result {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 12px;
overflow: hidden;
}
.analysis-result__header {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--creative-line);
}
.analysis-result__header strong {
font-size: 14px;
}
.analysis-state {
color: var(--web-success);
font-size: 12px;
}
.analysis-state.is-running {
color: var(--web-warning);
}
.analysis-state.is-failed {
color: var(--web-danger);
}
.analysis-categories {
padding: 10px;
display: flex;
gap: 8px;
background: var(--creative-surface);
border-bottom: 1px solid var(--creative-line);
}
.analysis-categories button {
min-width: 0;
min-height: 44px;
padding: 8px 4px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1 1 0;
gap: 4px;
color: var(--creative-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 9px;
cursor: pointer;
font-size: 13px;
transition:
color 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.analysis-categories button:hover {
color: var(--creative-text);
background: var(--creative-surface-raised);
}
.analysis-categories button.active {
color: var(--creative-text);
background: var(--web-hover);
border-color: var(--creative-accent);
}
.analysis-categories button.active svg {
color: var(--creative-accent-hover);
}
.analysis-categories button:focus {
outline-color: var(--creative-accent);
}
.analysis-categories button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analysis-content {
min-height: 0;
padding: 18px;
flex: 1;
background: color-mix(in srgb, var(--creative-surface-raised) 46%, var(--creative-surface));
overflow: auto;
}
.result-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.tag-section-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
align-items: stretch;
}
.tag-section-grid .result-block {
min-height: 150px;
}
.result-block,
.summary-card,
.opening-grid article,
.highlight-list article,
.emotion-list article,
.episode-list article,
.character-list article,
.hook-list > article,
.paywall-list article,
.outline-list li {
min-width: 0;
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 10px;
box-shadow: none;
}
.result-block,
.summary-card,
.opening-grid article {
padding: 16px;
}
.result-block h3,
.summary-card > span,
.opening-grid article > span,
.highlight-list article > span {
display: block;
margin: 0 0 14px;
padding-left: 11px;
position: relative;
color: var(--creative-text);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.result-block h3::before,
.summary-card > span::before,
.opening-grid article > span::before,
.highlight-list article > span::before {
width: 3px;
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
content: "";
background: var(--creative-accent);
border-radius: 3px;
}
.result-block p,
.summary-card p,
.opening-grid p {
margin: 0;
color: var(--creative-text);
line-height: 1.75;
white-space: pre-wrap;
}
.tag-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 9px;
}
.tag-list span {
padding: 6px 11px;
color: var(--creative-text);
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 999px;
font-size: 12px;
}
.summary-card p {
font-size: 16px;
font-weight: 500;
line-height: 1.8;
}
.highlight-list,
.emotion-list,
.episode-list,
.character-list,
.hook-list,
.paywall-list,
.custom-analysis-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.content-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
align-items: stretch;
}
.highlight-list article,
.emotion-list article,
.episode-list article,
.character-list article,
.paywall-list article {
padding: 16px;
}
.custom-analysis-list article {
padding: 16px;
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 10px;
}
.custom-analysis-list p {
margin: 7px 0 0;
line-height: 1.65;
}
.custom-analysis-list small {
display: block;
margin-top: 7px;
color: var(--creative-muted);
line-height: 1.55;
}
article header span {
color: var(--creative-accent-hover);
font-size: 12px;
}
.highlight-list p,
.emotion-list p,
.episode-list p,
.character-list p,
.paywall-list p {
margin: 7px 0 0;
line-height: 1.65;
}
article header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.outline-list {
margin: 0;
padding: 0;
list-style: none;
counter-reset: story-outline;
display: flex;
flex-direction: column;
gap: 10px;
line-height: 1.65;
}
.outline-list li {
min-height: 38px;
padding: 8px 10px 8px 42px;
position: relative;
counter-increment: story-outline;
}
.outline-list li::before {
width: 24px;
height: 24px;
display: grid;
place-items: center;
position: absolute;
top: 7px;
left: 9px;
content: counter(story-outline);
color: var(--creative-accent-hover);
background: var(--web-hover);
border-radius: 7px;
font-size: 12px;
font-weight: 600;
}
.episode-list small,
.hook-list small,
.paywall-list small {
display: block;
margin-top: 7px;
color: var(--creative-muted);
line-height: 1.55;
}
.character-list dl {
margin: 10px 0 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 10px;
font-size: 12px;
}
.character-list dt {
color: var(--creative-muted);
}
.character-list dd {
margin: 0;
line-height: 1.55;
}
.character-list article > header strong {
font-size: 15px;
}
.character-list article > header span,
.episode-list article > header span,
.emotion-list article > header span,
.paywall-list article > header span {
padding: 3px 7px;
background: var(--web-hover);
border-radius: 5px;
}
.opening-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.opening-grid article {
min-height: 128px;
}
.opening-grid article p {
font-size: 15px;
}
.hook-list > article {
padding: 16px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 11px;
}
.hook-number {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: var(--creative-text);
background: var(--web-hover);
border-radius: 7px;
font-size: 13px;
}
.hook-list p {
margin: 7px 0 0;
line-height: 1.65;
}
.analysis-empty {
min-height: 300px;
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
flex-direction: column;
gap: 10px;
color: var(--creative-muted);
text-align: center;
}
.analysis-loading-icon {
color: var(--creative-accent);
animation: analysis-loading-spin 0.9s linear infinite;
}
.analysis-failed-icon {
color: var(--web-danger);
}
@keyframes analysis-loading-spin {
to {
transform: rotate(360deg);
}
}
.analysis-empty strong {
color: var(--creative-text);
font-size: 15px;
}
.analysis-empty p,
.empty-copy {
margin: 0;
color: var(--creative-muted);
font-size: 12px;
line-height: 1.6;
}
.analysis-empty .analysis-error-message {
width: min(100%, 720px);
color: var(--web-danger);
line-height: 1.7;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.analysis-categories {
padding: 8px 4px;
gap: 2px;
}
.analysis-categories button {
padding: 8px 2px;
font-size: 12px;
}
.analysis-categories button svg {
display: none;
}
.opening-grid {
grid-template-columns: 1fr;
}
.analysis-content {
padding: 12px;
}
.tag-section-grid,
.content-card-grid {
grid-template-columns: 1fr;
}
.result-block,
.summary-card,
.opening-grid article {
padding: 14px;
}
}
</style>
@@ -0,0 +1,553 @@
<!-- 剧本导入对话框,负责从已有项目或本地文件导入剧本内容。 -->
<script setup lang="ts">
import { computed, h, ref } from "vue";
import { ElMessageBox } from "element-plus";
import { FileInput, FileText, FolderInput, Search, TriangleAlert, Upload } from "@/icons";
import { creativeApi, type ScriptAnalysis, type ScriptAnalysisImportProject } from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { formatDateTime } from "@/utils/datetime";
const props = defineProps<{
scriptId: string;
disabled: boolean;
beforeImport: () => Promise<boolean>;
}>();
const emit = defineEmits<{
imported: [script: ScriptAnalysis];
}>();
const toast = useToastStore();
const menuVisible = ref(false);
const projectDialogOpen = ref(false);
const localDialogOpen = ref(false);
const projects = ref<ScriptAnalysisImportProject[]>([]);
const loadingProjects = ref(false);
const processing = ref(false);
const selectedProjectId = ref("");
const projectKeyword = ref("");
const selectedFile = ref<File | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const selectedProject = computed(() => projects.value.find((project) => project.id === selectedProjectId.value));
const filteredProjects = computed(() => {
const keyword = projectKeyword.value.trim().toLocaleLowerCase();
if (!keyword) return projects.value;
return projects.value.filter((project) => project.name.toLocaleLowerCase().includes(keyword));
});
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
async function confirmImport(sourceName: string) {
try {
const message = h("div", { class: "script-import-confirm__content" }, [
h("span", { class: "script-import-confirm__icon" }, [h(TriangleAlert, { size: 22 })]),
h("div", { class: "script-import-confirm__copy" }, [
h("strong", `确认导入“${sourceName}”?`),
h("p", "导入成功后将替换当前剧本原文,并清空已有分析结果。该操作不可恢复。"),
]),
]);
await ElMessageBox.confirm(message, "确认导入剧本", {
customClass: "script-import-confirm",
confirmButtonText: "确认导入",
cancelButtonText: "取消",
});
} catch (error) {
if (error !== "cancel" && error !== "close") throw error;
return false;
}
return true;
}
async function selectMethod(method: "project" | "local") {
menuVisible.value = false;
if (method === "local") {
selectedFile.value = null;
localDialogOpen.value = true;
return;
}
selectedProjectId.value = "";
projectKeyword.value = "";
projectDialogOpen.value = true;
loadingProjects.value = true;
try {
projects.value = await creativeApi.scriptAnalysisImportProjects();
} catch (error) {
showError(error, "加载剧本反推项目失败");
} finally {
loadingProjects.value = false;
}
}
async function importProject() {
if (!selectedProject.value || processing.value) return;
if (!(await confirmImport(selectedProject.value.name))) return;
if (!(await props.beforeImport())) return;
processing.value = true;
try {
const script = await creativeApi.importScriptAnalysisProject(props.scriptId, selectedProjectId.value);
emit("imported", script);
projectDialogOpen.value = false;
toast.success("已导入项目中反推完成的剧本");
} catch (error) {
showError(error, "导入剧本失败");
} finally {
processing.value = false;
}
}
function chooseLocalFile() {
fileInput.value?.click();
}
function selectFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf("."));
if (extension !== ".txt" && extension !== ".doc") {
toast.warning("仅支持 TXT 和 DOC 文件");
return;
}
if (file.size > 3 * 1024 * 1024) {
toast.warning("剧本文件不能超过 3MB");
return;
}
selectedFile.value = file;
}
async function importFile() {
const file = selectedFile.value;
if (!file || processing.value) return;
if (!(await confirmImport(file.name))) return;
if (!(await props.beforeImport())) return;
processing.value = true;
try {
const script = await creativeApi.importScriptAnalysisFile(props.scriptId, file);
emit("imported", script);
localDialogOpen.value = false;
toast.success("本地剧本导入成功");
} catch (error) {
showError(error, "导入剧本失败");
} finally {
processing.value = false;
}
}
function formatFileSize(size: number) {
if (size < 1024) return `${size} B`;
return `${(size / 1024).toFixed(size < 1024 * 100 ? 1 : 0)} KB`;
}
</script>
<template>
<el-popover
v-model:visible="menuVisible"
placement="bottom-start"
:width="248"
:show-arrow="false"
popper-class="script-import-popover"
trigger="click"
>
<template #reference>
<el-button
:loading="processing"
:disabled="disabled"
>
<FileInput :size="16" />导入剧本
</el-button>
</template>
<div class="import-menu">
<button
type="button"
class="import-menu__item"
:disabled="processing"
@click="selectMethod('project')"
>
<FolderInput :size="19" />
<span><strong>从剧本反推项目导入</strong><small>导入所有已完成的剧集</small></span>
</button>
<button
type="button"
class="import-menu__item"
:disabled="processing"
@click="selectMethod('local')"
>
<Upload :size="19" />
<span><strong>从本地导入</strong><small>支持 TXT、DOC 文件</small></span>
</button>
</div>
</el-popover>
<el-dialog
v-model="projectDialogOpen"
align-center
title="从剧本反推项目导入"
width="min(920px, calc(100vw - 28px))"
class="web-dialog creative-dialog script-import-dialog project-import-dialog"
>
<div class="project-import">
<el-input
v-model="projectKeyword"
clearable
placeholder="搜索项目名称"
class="project-import__search"
>
<template #prefix><Search :size="17" /></template>
</el-input>
<div
v-loading="loadingProjects"
class="project-import__list"
>
<button
v-for="project in filteredProjects"
:key="project.id"
type="button"
class="project-import__item"
:class="{ active: selectedProjectId === project.id }"
:disabled="processing"
@click="selectedProjectId = project.id"
>
<span class="project-import__file"><FileText :size="19" /></span>
<span class="project-import__content">
<strong>{{ project.name }}</strong>
<small>
已完成 {{ project.episode_count }} 集 · {{ project.char_count }} 字 ·
{{ formatDateTime(project.updated_at) }}
</small>
</span>
</button>
<div
v-if="!loadingProjects && !filteredProjects.length"
class="project-import__empty"
>
<Search
v-if="projectKeyword.trim()"
:size="30"
/>
<FileText
v-else
:size="30"
/>
<strong>{{ projectKeyword.trim() ? "未找到匹配项目" : "暂无可导入的项目" }}</strong>
<span>{{ projectKeyword.trim() ? "请尝试其他项目名称" : "仅展示至少有一集已完成剧本反推的项目" }}</span>
</div>
</div>
</div>
<template #footer>
<el-button @click="projectDialogOpen = false">取消</el-button>
<el-button
type="primary"
:loading="processing"
:disabled="!selectedProjectId"
@click="importProject"
>
导入所选项目
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="localDialogOpen"
align-center
title="从本地导入"
width="min(480px, calc(100vw - 28px))"
class="web-dialog creative-dialog script-import-dialog local-import-dialog"
>
<input
ref="fileInput"
class="script-import-file"
type="file"
accept=".txt,.doc"
@change="selectFile"
/>
<div
v-loading="processing"
class="local-import"
>
<span class="local-import__icon"><Upload :size="26" /></span>
<strong>{{ selectedFile ? "已选择剧本文件" : "选择本地剧本文件" }}</strong>
<div
v-if="selectedFile"
class="local-import__selected"
>
<FileText :size="18" />
<span
><strong>{{ selectedFile.name }}</strong
><small>{{ formatFileSize(selectedFile.size) }}</small></span
>
</div>
<span v-else>支持 TXT、DOC 格式,文件最大 3MB</span>
<el-button
:disabled="processing"
@click="chooseLocalFile"
>
{{ selectedFile ? "重新选择" : "选择文件" }}
</el-button>
</div>
<template #footer>
<el-button @click="localDialogOpen = false">取消</el-button>
<el-button
type="primary"
:loading="processing"
:disabled="!selectedFile"
@click="importFile"
>
导入文件
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.script-import-file {
display: none;
}
.import-menu {
display: flex;
flex-direction: column;
gap: 6px;
}
.import-menu__item {
width: 100%;
min-height: 60px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 11px;
color: var(--web-text);
text-align: left;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.import-menu__item:hover {
background: var(--web-hover);
}
.import-menu__item > svg {
flex: 0 0 auto;
color: var(--web-primary);
}
.import-menu__item span,
.project-import__content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.import-menu__item small,
.project-import__content small,
.project-import__empty span,
.local-import > span:not(.local-import__icon) {
color: var(--web-muted);
font-size: 12px;
line-height: 1.6;
}
.import-menu__item:disabled,
.project-import__item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.project-import {
display: flex;
flex-direction: column;
gap: 14px;
}
.project-import__search {
width: min(360px, 100%);
}
.project-import__list {
min-height: 400px;
height: min(560px, calc(80vh - 190px));
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
gap: 8px;
overflow-y: auto;
}
.project-import__item {
width: 100%;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
color: var(--web-text);
text-align: left;
background: var(--web-raised);
border: 1px solid var(--web-line);
border-radius: 10px;
cursor: pointer;
}
.project-import__item:hover,
.project-import__item.active {
background: var(--web-hover);
border-color: var(--web-primary);
}
.project-import__file,
.local-import__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
color: var(--web-primary);
background: color-mix(in srgb, var(--web-primary) 12%, transparent);
border-radius: 9px;
}
.project-import__file {
width: 36px;
height: 36px;
}
.project-import__content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-import__empty {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--web-muted);
text-align: center;
grid-column: 1 / -1;
}
.project-import__empty strong {
color: var(--web-text);
}
.local-import {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
text-align: center;
}
.local-import__icon {
width: 52px;
height: 52px;
margin-bottom: 4px;
}
.local-import__selected {
width: min(340px, 100%);
padding: 11px 13px;
display: flex;
align-items: center;
gap: 10px;
color: var(--web-primary);
text-align: left;
background: var(--web-raised);
border: 1px solid var(--web-line);
border-radius: 9px;
}
.local-import__selected > span {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.local-import__selected strong {
overflow: hidden;
color: var(--web-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.local-import__selected small {
color: var(--web-muted);
font-size: 12px;
}
:global(.script-import-popover) {
padding: 6px !important;
}
:global(.project-import-dialog .el-dialog__footer),
:global(.local-import-dialog .el-dialog__footer) {
display: flex;
justify-content: flex-end;
gap: 12px;
}
:global(.project-import-dialog .el-dialog__footer .el-button),
:global(.local-import-dialog .el-dialog__footer .el-button) {
margin-left: 0;
}
:global(.script-import-confirm.el-message-box) {
width: min(440px, calc(100vw - 28px));
padding: 0;
background: var(--web-raised);
border: 1px solid var(--web-line);
border-radius: 14px;
box-shadow: 0 22px 60px color-mix(in srgb, var(--web-text) 18%, transparent);
overflow: hidden;
}
:global(.script-import-confirm .el-message-box__header) {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--web-line);
}
:global(.script-import-confirm .el-message-box__title) {
color: var(--web-text);
font-size: 17px;
font-weight: 600;
}
:global(.script-import-confirm .el-message-box__content) {
padding: 20px;
}
:global(.script-import-confirm__content) {
display: flex;
align-items: flex-start;
gap: 12px;
}
:global(.script-import-confirm__icon) {
width: 42px;
height: 42px;
display: grid;
place-items: center;
flex: 0 0 42px;
color: var(--web-warning);
background: color-mix(in srgb, var(--web-warning) 13%, transparent);
border-radius: 10px;
}
:global(.script-import-confirm__copy) {
min-width: 0;
}
:global(.script-import-confirm__copy strong) {
display: block;
overflow-wrap: anywhere;
color: var(--web-text);
font-size: 14px;
}
:global(.script-import-confirm__copy p) {
margin: 6px 0 0;
color: var(--web-muted);
font-size: 13px;
line-height: 1.65;
}
:global(.script-import-confirm .el-message-box__btns) {
padding: 0 20px 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
:global(.script-import-confirm .el-message-box__btns .el-button) {
margin-left: 0;
}
@media (max-width: 600px) {
.project-import__list {
min-height: 360px;
height: min(500px, calc(80vh - 180px));
grid-template-columns: 1fr;
}
.project-import__item {
align-items: flex-start;
}
.project-import__content small {
white-space: normal;
}
.project-import__empty {
min-height: 360px;
}
}
</style>
@@ -0,0 +1,418 @@
<!-- 分镜时间线组件,负责分镜选择、任务状态展示及前后插入和删除操作。 -->
<script setup lang="ts">
import { ref } from "vue";
import { CirclePlus, Delete, Loading } from "@element-plus/icons-vue";
import type { GenerationTask, Storyboard } from "@/api/creative";
const props = defineProps<{ storyboards: Storyboard[]; selectedId?: string; tasks: GenerationTask[] }>();
const emit = defineEmits<{
select: [storyboard: Storyboard];
insert: [storyboard: Storyboard, position: "before" | "after"];
delete: [storyboard: Storyboard];
}>();
const track = ref<HTMLElement>();
const activeStatuses = new Set([
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
]);
/** 获取指定分镜和任务类型的最近一次生成任务。 */
function latestTask(item: Storyboard, taskType: string) {
return props.tasks
.filter(
(task) =>
task.task_type === taskType &&
(task.storyboard_id === item.id ||
(taskType === "prompt_reverse" && !task.storyboard_id && task.sequence_no === item.sequence_no)),
)
.sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0];
}
/** 按任务优先级计算分镜当前的展示状态。 */
function statusFor(item: Storyboard) {
const reinfer = latestTask(item, "prompt_reverse");
const latest = [reinfer]
.filter((task): task is GenerationTask => Boolean(task))
.sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0];
if (latest && activeStatuses.has(latest.status)) {
if (latest.status === "cancel_requested") return "停止中";
return latest.storyboard_id ? "重新反推中" : "反推中";
}
if (latest?.status === "failed") return "反推失败";
if (latest?.status === "cancelled") return "反推已取消";
if (item.prompt_content || item.script_content) return "反推完成";
if (item.status === "failed") return "反推失败";
if (item.status === "generating") return "反推中";
if (item.status === "queued") return "等待反推";
return "";
}
/** 将分镜任务状态转换为样式类名。 */
function statusClass(item: Storyboard) {
const status = statusFor(item);
if (status.includes("失败")) return "failed";
if (status.includes("中")) return "running";
if (status.includes("完成")) return "completed";
return "cancelled";
}
/** 判断分镜是否存在进行中的生成任务。 */
function isLoading(item: Storyboard) {
const reinfer = latestTask(item, "prompt_reverse");
return Boolean(reinfer && activeStatuses.has(reinfer.status));
}
/** 将垂直滚轮位移转换为时间线横向滚动。 */
function wheel(event: WheelEvent) {
if (!track.value || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
event.preventDefault();
track.value.scrollLeft += event.deltaY;
}
</script>
<template>
<section class="timeline-panel">
<header>
<div>
<strong>分镜时间线</strong><span>{{ storyboards.length }} 个镜头</span>
</div>
</header>
<div
v-if="storyboards.length"
ref="track"
class="timeline-track"
@wheel="wheel"
>
<div
v-for="item in storyboards"
:key="item.id"
class="timeline-card-shell"
>
<button
class="timeline-insert timeline-insert-before"
type="button"
title="在前面插入分镜"
@click.stop="emit('insert', item, 'before')"
>
<el-icon>
<CirclePlus />
</el-icon>
</button>
<button
class="timeline-insert timeline-insert-after"
type="button"
title="在后面插入分镜"
@click.stop="emit('insert', item, 'after')"
>
<el-icon>
<CirclePlus />
</el-icon>
</button>
<article
class="timeline-card"
:class="{ active: item.id === selectedId }"
@click="emit('select', item)"
>
<div class="timeline-header">
<strong :class="{ completed: item.prompt_content || item.script_content }"
>分镜 {{ item.sequence_no }}</strong
>
</div>
<div
class="timeline-status-center"
:class="statusClass(item)"
>
<el-icon
v-if="isLoading(item)"
class="is-loading"
><Loading /></el-icon
><span>{{ statusFor(item) || "未反推" }}</span>
</div>
<button
class="timeline-delete"
type="button"
title="删除分镜"
@click.stop="emit('delete', item)"
>
<el-icon>
<Delete />
</el-icon>
</button>
</article>
</div>
</div>
<div
v-else
class="timeline-empty"
>
<strong>尚未生成分镜</strong><span>开始反推后,分镜会按原片顺序出现在这里。</span>
</div>
</section>
</template>
<style scoped lang="scss">
.timeline-panel {
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.timeline-panel > header {
padding: 9px 14px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--web-line-strong);
}
.timeline-panel header div {
display: flex;
align-items: center;
gap: 10px;
}
.timeline-panel header span {
color: var(--web-text-note);
font-size: 11px;
}
.timeline-track {
padding: 10px;
display: flex;
gap: 0;
overflow-x: auto;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.timeline-card-shell {
position: relative;
width: 192px;
min-width: 192px;
height: 86px;
padding: 0 6px;
box-sizing: border-box;
}
.timeline-track::-webkit-scrollbar {
height: 9px;
}
.timeline-track::-webkit-scrollbar-track {
background: var(--web-surface);
border-radius: 8px;
}
.timeline-track::-webkit-scrollbar-thumb {
background: var(--web-danger-soft);
border: 2px solid var(--web-line-strong);
border-radius: 8px;
}
.timeline-card {
position: relative;
width: 180px;
min-width: 180px;
height: 86px;
padding: 0;
text-align: left;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
cursor: pointer;
outline: none;
transition:
background-color 0.18s,
border-color 0.18s,
outline-color 0.18s;
}
.timeline-card:hover,
.timeline-card.active {
background: var(--web-surface);
border-color: var(--web-line-active);
}
.timeline-card:focus-visible {
background: var(--web-surface);
border-color: var(--web-project-card-hover-line);
outline: 2px solid var(--web-card-focus-ring);
outline-offset: 2px;
}
.timeline-card.active {
box-shadow: 0 0 0 1px var(--web-line-active) inset;
}
.timeline-header {
position: relative;
min-height: 28px;
padding: 8px 8px 0;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text);
font:
13px Arial,
sans-serif;
}
.timeline-header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-header strong.completed {
color: var(--web-complete);
}
.timeline-status-center {
position: absolute;
inset: 34px 30px 7px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--web-text-secondary);
font-size: 11px;
text-align: center;
}
.timeline-status-center.running {
color: var(--web-accent-dark);
}
.timeline-status-center.failed {
color: var(--web-accent-dark);
}
.timeline-status-center.completed {
color: var(--web-complete);
}
.timeline-status-center.cancelled {
color: var(--web-text-secondary);
}
.timeline-insert,
.timeline-delete {
position: absolute;
z-index: 2;
display: grid;
place-items: center;
padding: 0;
line-height: 1;
box-sizing: border-box;
opacity: 0;
pointer-events: none;
color: var(--web-accent-dark);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition:
opacity 0.15s,
background-color 0.15s,
border-color 0.15s,
color 0.15s;
}
.timeline-insert :deep(.el-icon),
.timeline-delete :deep(.el-icon) {
width: 1em;
height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.timeline-insert {
top: 50%;
width: 20px;
height: 20px;
transform: translateY(-50%);
}
.timeline-insert-before {
left: -4px;
}
.timeline-insert-after {
right: -4px;
}
.timeline-delete {
top: auto;
bottom: 8px;
right: 5px;
width: 22px;
height: 22px;
color: var(--web-accent-dark);
border-radius: 5px;
}
.timeline-card-shell:hover .timeline-insert,
.timeline-insert:focus-visible {
opacity: 1;
pointer-events: auto;
}
.timeline-delete {
opacity: 1;
pointer-events: auto;
}
.timeline-insert:hover,
.timeline-delete:hover {
background: var(--web-hover);
border-color: var(--web-timeline-action-line);
}
.timeline-insert:focus-visible,
.timeline-delete:focus-visible {
opacity: 1;
pointer-events: auto;
background: var(--web-hover);
border-color: var(--web-timeline-focus-line);
outline: 2px solid var(--web-timeline-focus-ring);
outline-offset: 2px;
}
@media (hover: none) {
.timeline-insert,
.timeline-delete {
opacity: 1;
pointer-events: auto;
}
}
.timeline-empty {
height: 110px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.timeline-empty span {
margin-top: 5px;
color: var(--web-text-tertiary);
font-size: 12px;
}
@media (max-width: 680px) {
.timeline-card-shell {
width: 172px;
min-width: 172px;
padding: 0 6px;
}
.timeline-card {
width: 160px;
min-width: 160px;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<!-- 任务状态组件,负责展示生成任务的状态点、文案和错误详情。 -->
<script setup lang="ts">
import { computed } from "vue";
import type { GenerationTask } from "@/api/creative";
const props = defineProps<{ status?: string; task?: GenerationTask; cancellable?: boolean }>();
const emit = defineEmits<{ cancel: [task: GenerationTask] }>();
const labels: Record<string, string> = {
pending_submission: "排队中",
submitting: "排队中",
submitted: "已提交",
processing: "处理中",
result_ready: "结果入库中",
downloading: "结果入库中",
succeeded: "已完成",
failed: "失败",
cancel_requested: "取消处理中",
cancelled: "已取消",
draft: "待上传",
uploaded: "待分析",
analyzing: "分析中",
review: "待确认",
generating: "生成中",
completed: "已完成",
};
const value = computed(() => props.task?.status || props.status || "idle");
const label = computed(() => labels[value.value] || (value.value === "idle" ? "未生成" : value.value));
const tone = computed(
() =>
({
succeeded: "success",
completed: "success",
review: "success",
failed: "danger",
cancelled: "muted",
})[value.value] || (["idle", "draft", "uploaded"].includes(value.value) ? "muted" : "active"),
);
const canCancel = computed(
() =>
props.cancellable &&
props.task &&
["pending_submission", "submitted", "processing", "cancel_requested"].includes(value.value),
);
</script>
<template>
<span
class="task-state"
:class="`task-state--${tone}`"
><i></i>{{ label }}</span
>
<button
v-if="canCancel"
class="task-cancel"
type="button"
:disabled="value === 'cancel_requested'"
@click="emit('cancel', task!)"
>
取消
</button>
</template>
<style scoped lang="scss">
.task-state {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--web-task-idle-text);
font-size: 12px;
white-space: nowrap;
}
.task-state i {
width: 6px;
height: 6px;
background: var(--web-task-idle-dot);
border-radius: 50%;
}
.task-state--active i {
background: var(--creative-accent, var(--web-creative-accent));
box-shadow: 0 0 0 3px var(--web-task-running-ring);
}
.task-state--success {
color: var(--web-task-success-text);
}
.task-state--success i {
background: var(--web-task-success-dot);
}
.task-state--danger {
color: var(--web-task-failed-text);
}
.task-state--danger i {
background: var(--web-task-failed-dot);
}
.task-state--muted {
color: var(--web-empty-text);
}
.task-cancel {
padding: 0;
color: var(--web-task-cancelled);
background: transparent;
border: 0;
font-size: 12px;
cursor: pointer;
}
.task-cancel:hover {
color: var(--creative-accent, var(--web-creative-accent));
}
.task-cancel:disabled {
color: var(--web-task-detail);
cursor: default;
}
</style>
@@ -0,0 +1,775 @@
<!-- 视频对比面板,负责源视频上传、结果预览、双播放器同步和历史结果操作。 -->
<script setup lang="ts">
import { ref, toRefs } from "vue";
import {
UploadFilled,
VideoCamera,
ArrowLeft,
ArrowRight,
ArrowUp,
Download,
Loading,
Clock,
CircleClose,
Check,
Delete,
} from "@element-plus/icons-vue";
import type { GenerationOutput, ProjectDetail, Storyboard } from "@/api/creative";
import LoadingMedia from "./LoadingMedia.vue";
import VideoPlayer from "./VideoPlayer.vue";
import { downloadMedia } from "@/utils/download";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{
sourceUrl?: string;
sourceMediaId?: string;
resultUrl?: string;
resultMediaId?: string;
storyboard?: Storyboard;
project?: ProjectDetail;
uploading?: boolean;
dragging?: boolean;
resultLoading?: boolean;
resultError?: string;
resultErrorTitle?: string;
historyCount?: number;
historyOutputs?: GenerationOutput[];
historyLoading?: boolean;
activatingHistory?: string;
}>();
const { sourceUrl, resultUrl, storyboard, uploading, dragging, resultLoading, resultError, historyCount } =
toRefs(props);
const historyVisible = ref(false);
const resultPlaying = ref(false);
const toast = useToastStore();
const emit = defineEmits<{
upload: [file: File];
history: [];
"history-preview": [output: GenerationOutput];
"history-activate": [output: GenerationOutput];
"history-delete": [output: GenerationOutput];
}>();
const sourcePlayer = ref<InstanceType<typeof VideoPlayer>>();
const resultPlayer = ref<InstanceType<typeof VideoPlayer>>();
const eraNames: Record<string, string> = {
modern_city: "现代都市",
ancient_history: "古代历史",
ancient_fantasy: "古代玄幻",
ancient_xianxia: "古代仙侠",
future_scifi: "未来科幻",
other: "其他",
};
const localizationNames: Record<string, string> = {
china: "中国",
uk_us: "英美",
korea: "韩国",
japan: "日本",
france: "法国",
russia: "俄罗斯",
vietnam: "越南",
thailand: "泰国",
india: "印度",
};
/** 获取项目年代的中文展示名称,并兼容自定义年代。 */
function eraLabel() {
const project = props.project;
if (!project) return "";
return project.era_type === "other" && project.custom_era?.trim()
? project.custom_era.trim()
: eraNames[project.era_type] || project.era_type;
}
/** 获取项目本土化地区的中文展示名称。 */
function localizationLabel() {
const value = props.project?.localization || "";
return localizationNames[value] || value;
}
/** 从文件输入框读取视频并沿用原上传事件通知父组件。 */
function choose(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (file) emit("upload", file);
}
/** 下载当前转绘结果,并将下载失败转换为用户提示。 */
async function downloadResult() {
if (!props.resultUrl) return;
try {
await downloadMedia(
props.resultMediaId,
props.resultUrl,
`分镜${props.storyboard?.sequence_no || ""}-转绘结果.mp4`,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频下载失败");
}
}
/** 关闭历史弹层并预览指定生成结果。 */
function previewHistory(output: GenerationOutput) {
historyVisible.value = false;
emit("history-preview", output);
}
/** 将当前分镜起始毫秒换算为源视频秒偏移。 */
function sourceOffset() {
return (props.storyboard?.start_ms || 0) / 1000;
}
/** 结果视频播放时,将源视频定位到对应分镜时间并同步播放。 */
function syncSourcePlay(time: number) {
resultPlaying.value = true;
sourcePlayer.value?.setCurrentTime(sourceOffset() + time);
sourcePlayer.value?.play();
}
/** 结果视频暂停时同步暂停源视频。 */
function syncSourcePause() {
resultPlaying.value = false;
sourcePlayer.value?.pause();
}
/** 结果视频跳转时同步更新源视频播放位置。 */
function syncSourceSeek(time: number) {
sourcePlayer.value?.setCurrentTime(sourceOffset() + time);
}
/** 结果视频结束时重置播放状态并暂停源视频。 */
function syncSourceEnded() {
resultPlaying.value = false;
sourcePlayer.value?.pause();
}
/** 在结果视频仍播放时恢复拖动后的源视频同步位置。 */
function restoreSourceAfterSeek() {
if (!resultPlaying.value) return;
sourcePlayer.value?.setCurrentTime(sourceOffset() + (resultPlayer.value?.currentTime() || 0));
}
/** 源视频取消静音时自动静音结果视频,避免双路声音叠加。 */
function sourceVolumeChanged(muted: boolean) {
if (!muted) resultPlayer.value?.setMuted(true);
}
/** 结果视频取消静音时自动静音源视频,避免双路声音叠加。 */
function resultVolumeChanged(muted: boolean) {
if (!muted) sourcePlayer.value?.setMuted(true);
}
/** 源视频进入全屏前暂停结果视频。 */
function pauseResultForSourceFullscreen() {
resultPlayer.value?.pause();
}
/** 结果视频进入全屏前暂停源视频。 */
function pauseSourceForResultFullscreen() {
sourcePlayer.value?.pause();
}
</script>
<style scoped>
.compare-panel > header {
display: grid !important;
grid-template-columns: 1fr auto 1fr;
}
.compare-panel > header > strong {
justify-self: start;
}
.compare-panel > header > i {
justify-self: end;
}
.project-meta {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--web-text-meta);
font-size: 11px;
white-space: nowrap;
}
.project-meta span {
position: relative;
}
.project-meta span + span:before {
content: "";
position: absolute;
left: -7px;
top: 50%;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--web-divider-soft);
transform: translate(-50%, -50%);
}
@media (max-width: 1000px) {
.project-meta {
gap: 8px;
font-size: 10px;
}
.project-meta span + span:before {
left: -4px;
}
}
</style>
<template>
<section class="compare-panel">
<header>
<strong>镜头查看</strong>
<div
v-if="project"
class="project-meta"
>
<span>{{ eraLabel() }}</span
><span>{{ project.style_name }}</span
><span>{{ localizationLabel() }}</span
><span>{{ project.aspect_ratio }}</span>
</div>
<i></i>
</header>
<div class="compare-grid">
<article
class="video-pane"
:class="{ dragging }"
>
<div class="pane-label">
<span>原片</span>
<div
v-if="sourceUrl"
class="frame-actions"
>
<button
type="button"
title="上一帧"
@click="sourcePlayer?.previousFrame()"
>
<el-icon>
<ArrowLeft />
</el-icon></button
><button
type="button"
title="下一帧"
@click="sourcePlayer?.nextFrame()"
>
<el-icon>
<ArrowRight />
</el-icon></button
><button
type="button"
title="导出静帧"
@click="sourcePlayer?.exportFrame()"
>
<el-icon>
<ArrowUp />
</el-icon>
</button>
</div>
</div>
<div
v-loading="uploading"
class="video-stage"
>
<VideoPlayer
v-if="sourceUrl"
ref="sourcePlayer"
:src="sourceUrl"
:media-id="sourceMediaId"
:start-time="storyboard ? storyboard.start_ms / 1000 : 0"
:aspect-ratio="project?.aspect_ratio"
@seek-end="restoreSourceAfterSeek"
@volume="sourceVolumeChanged"
@fullscreen-open="pauseResultForSourceFullscreen"
/><label
v-else
class="upload-video"
><input
type="file"
accept="video/mp4,video/webm,video/quicktime,video/x-m4v,.mp4,.webm,.mov,.m4v"
@change="choose"
/><el-icon> <UploadFilled /> </el-icon><strong>上传原视频</strong
><small>点击右上角“上传视频”,或将文件拖拽到页面</small
><small>支持 MP4、WebM、MOV、M4V,最大 50MB,时长不超过 3 分钟</small></label
>
</div>
</article>
<article class="video-pane">
<div class="pane-label">
<span>转绘结果</span>
<div class="result-tools">
<el-popover
v-model:visible="historyVisible"
trigger="click"
placement="bottom-end"
:width="project?.aspect_ratio === '9:16' ? 330 : 450"
:show-after="0"
:hide-after="0"
popper-class="creative-history-popover"
@show="emit('history')"
>
<template #reference
><button
type="button"
class="history-button"
>
<el-icon> <Clock /> </el-icon>历史记录{{ historyCount ? ` (${historyCount})` : "" }}
</button></template
>
<div
v-loading="historyLoading"
class="history-list"
:class="{ portrait: project?.aspect_ratio === '9:16' }"
>
<div
v-for="output in historyOutputs"
:key="output.id"
class="history-item"
:class="{ active: output.active }"
@click="previewHistory(output)"
>
<LoadingMedia
type="video"
compact
:src="output.public_url"
muted
playsinline
preload="metadata"
disablepictureinpicture
/>
<span
v-if="!output.active"
class="history-item-actions"
@click.stop
>
<button
type="button"
title="设为主视频"
:disabled="activatingHistory === output.id"
@click="emit('history-activate', output)"
>
<el-icon><Check /></el-icon>
</button>
<button
type="button"
title="删除历史视频"
@click="emit('history-delete', output)"
>
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
<span
v-if="!historyOutputs?.length && !historyLoading"
class="history-empty"
>暂无历史视频</span
>
</div>
</el-popover>
<div
v-if="resultUrl"
class="frame-actions"
>
<button
type="button"
title="上一帧"
@click="resultPlayer?.previousFrame()"
>
<el-icon>
<ArrowLeft />
</el-icon></button
><button
type="button"
title="下一帧"
@click="resultPlayer?.nextFrame()"
>
<el-icon>
<ArrowRight />
</el-icon></button
><button
type="button"
title="导出静帧"
@click="resultPlayer?.exportFrame()"
>
<el-icon>
<ArrowUp />
</el-icon></button
><button
type="button"
title="下载转绘视频"
@click="downloadResult"
>
<el-icon>
<Download />
</el-icon>
</button>
</div>
</div>
</div>
<div class="video-stage">
<VideoPlayer
v-if="resultUrl && !resultError"
ref="resultPlayer"
:src="resultUrl"
:media-id="resultMediaId"
:aspect-ratio="project?.aspect_ratio"
@play="syncSourcePlay"
@pause="syncSourcePause"
@seek="syncSourceSeek"
@ended="syncSourceEnded"
@volume="resultVolumeChanged"
@fullscreen-open="pauseSourceForResultFullscreen"
/>
<div
v-else-if="resultError"
class="result-empty result-error"
>
<el-icon><CircleClose /></el-icon><strong>{{ resultErrorTitle || "生成失败" }}</strong>
<p>{{ resultError }}</p>
</div>
<div
v-else
class="result-empty"
>
<el-icon>
<Loading
v-if="resultLoading"
class="spin"
/>
<VideoCamera v-else /> </el-icon
><strong>{{ resultLoading ? "正在生成当前分镜" : "尚未生成当前分镜" }}</strong>
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.compare-panel {
min-width: 0;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 11px;
overflow: hidden;
}
.compare-panel > header {
height: 40px;
padding: 0 15px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--web-line-strong);
color: var(--web-text-secondary);
}
.compare-grid {
padding: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.video-pane {
min-width: 0;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
overflow: hidden;
}
.video-pane.dragging {
border-color: var(--web-primary);
background: var(--web-surface);
}
.pane-label {
height: 38px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--web-text-secondary);
border-bottom: 1px solid var(--web-line-strong);
font-size: 12px;
}
.result-tools {
display: flex;
align-items: center;
gap: 5px;
}
.history-button {
height: 24px;
padding: 0 7px;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
font-size: 10px;
cursor: pointer;
}
.history-button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-hover);
}
.history-button:focus,
.history-button:focus-visible {
outline: none;
box-shadow: none;
}
.frame-actions {
display: flex;
gap: 4px;
}
.frame-actions button {
width: 24px;
height: 24px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
cursor: pointer;
}
.frame-actions button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-hover);
}
.video-stage,
.video-stage :deep(.video-player),
.upload-video,
.result-empty {
width: 100%;
height: clamp(260px, 30vw, 450px);
}
.video-stage :deep(.el-loading-mask) {
background: var(--web-loading-mask) !important;
}
.video-stage :deep(.el-loading-spinner .circular) {
stroke: var(--web-primary-hover);
}
.upload-video,
.result-empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: var(--web-text-secondary);
cursor: pointer;
}
.upload-video input {
display: none;
}
.upload-video small {
margin-top: 7px;
color: var(--web-text-tertiary);
font-size: 10px;
line-height: 1.45;
text-align: center;
}
.upload-video small + small {
margin-top: 3px;
color: var(--web-text-detail);
}
.result-error {
padding: 24px;
text-align: center;
}
.result-empty.result-error .el-icon {
color: var(--web-accent-dark);
}
.result-error p {
max-width: 90%;
margin: 8px 0 0;
color: var(--web-accent-dark);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.upload-video .el-icon,
.result-empty .el-icon {
margin-bottom: 13px;
color: var(--web-icon-muted);
font-size: 34px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 780px) {
.compare-grid {
grid-template-columns: 1fr;
}
.video-stage,
.video-stage :deep(.video-player),
.upload-video,
.result-empty {
height: 300px;
}
}
</style>
<style>
.creative-history-popover {
padding: 8px !important;
background: var(--web-surface-soft) !important;
border: 1px solid var(--web-line-emphasis) !important;
}
.creative-history-popover .el-popper__arrow::before {
background: var(--web-surface-soft) !important;
border-color: var(--web-line-emphasis) !important;
}
.history-list {
position: relative;
max-height: 420px;
padding-right: 4px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
scrollbar-color: var(--web-scrollbar-compact) transparent;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.history-list::-webkit-scrollbar {
width: 6px;
}
.history-list::-webkit-scrollbar-track {
background: transparent;
}
.history-list::-webkit-scrollbar-thumb {
background: var(--web-divider-soft);
border-radius: 6px;
}
.history-list::-webkit-scrollbar-thumb:hover {
background: var(--web-scrollbar-thumb-hover);
}
.creative-history-popover .history-list .el-loading-mask {
background: var(--web-loading-mask) !important;
}
.creative-history-popover .history-list .el-loading-spinner .circular {
stroke: var(--web-primary-hover);
}
.history-item {
position: relative;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
padding: 5px;
overflow: hidden;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
cursor: pointer;
}
.history-list.portrait .history-item {
aspect-ratio: 9 / 16;
}
.history-item:hover,
.history-item.active {
border-color: var(--web-line-active);
background: var(--web-surface);
}
.history-item video,
.history-item :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
background: var(--web-surface-soft);
border-radius: 4px;
pointer-events: none;
}
.history-item-actions {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--web-history-overlay);
opacity: 0;
transition: opacity 0.12s ease;
}
.history-item:hover .history-item-actions,
.history-item:focus-visible .history-item-actions {
opacity: 1;
}
.history-item-actions button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-hover);
border: 1px solid var(--web-line-emphasis);
border-radius: 6px;
cursor: pointer;
}
.history-item-actions button:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-action-hover);
}
.history-item-actions button:last-child:hover {
color: var(--web-accent-dark);
border-color: var(--web-line-danger-hover);
}
.history-item-actions button:disabled {
opacity: 0.55;
cursor: wait;
}
.history-empty {
padding: 18px 8px;
color: var(--web-text-tertiary);
text-align: center;
font-size: 12px;
}
@media (max-width: 620px) {
.creative-history-popover {
max-width: calc(100vw - 28px) !important;
}
.history-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
+538
View File
@@ -0,0 +1,538 @@
<!-- 创作视频播放器,负责鉴权媒体加载、播放控制和全屏预览。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { FullScreen } from "@element-plus/icons-vue";
import { Speaker, SpeakerOff } from "@/icons";
import { creativeApi } from "@/api/creative";
import MediaLoadingState from "@/components/creative/MediaLoadingState.vue";
import { useToastStore } from "@/stores/toast";
const props = withDefaults(
defineProps<{
src: string;
mediaId?: string;
startTime?: number;
aspectRatio?: string;
seekable?: boolean;
transparentControls?: boolean;
}>(),
{ startTime: 0, aspectRatio: "16:9", seekable: true, transparentControls: false },
);
const toast = useToastStore();
const emit = defineEmits<{
play: [time: number];
pause: [time: number];
seek: [time: number];
"seek-end": [time: number];
volume: [muted: boolean];
ended: [];
"fullscreen-open": [];
}>();
const video = ref<HTMLVideoElement>();
const dialogVideo = ref<HTMLVideoElement>();
const playing = ref(false);
const dialogPlaying = ref(false);
const muted = ref(true);
const progress = ref(0);
const duration = ref(0);
const fullscreen = ref(false);
const dialogProgress = ref(0);
const dialogDuration = ref(0);
const playableSrc = ref("");
const loading = ref(true);
const failed = ref(false);
const dialogLoading = ref(true);
const dialogFailed = ref(false);
let mediaObjectUrl = "";
let loadSequence = 0;
const dialogClass = computed(() => ({ portrait: props.aspectRatio === "9:16" }));
watch(
[() => props.src, () => props.mediaId],
async () => {
const sequence = ++loadSequence;
const previousObjectUrl = mediaObjectUrl;
progress.value = 0;
duration.value = 0;
loading.value = true;
failed.value = false;
dialogLoading.value = true;
dialogFailed.value = false;
if (props.mediaId) {
try {
const blob = await creativeApi.media(props.mediaId);
if (sequence !== loadSequence) return;
mediaObjectUrl = URL.createObjectURL(blob);
playableSrc.value = mediaObjectUrl;
} catch {
if (sequence !== loadSequence) return;
mediaObjectUrl = "";
playableSrc.value = props.src;
toast.error("媒体读取失败");
}
} else {
mediaObjectUrl = "";
playableSrc.value = props.src;
}
await nextTick();
if (sequence !== loadSequence) return;
video.value?.load();
dialogVideo.value?.load();
if (previousObjectUrl && previousObjectUrl !== mediaObjectUrl) {
URL.revokeObjectURL(previousObjectUrl);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
loadSequence++;
if (mediaObjectUrl) URL.revokeObjectURL(mediaObjectUrl);
});
/** 根据当前播放状态切换视频播放和暂停。 */
function toggle() {
if (!video.value) return;
if (video.value.paused) {
void video.value.play();
} else {
video.value.pause();
}
}
/** 同步播放状态并通知父组件。 */
function onPlay() {
playing.value = true;
emit("play", video.value?.currentTime || 0);
}
/** 同步暂停状态并通知父组件。 */
function onPause() {
playing.value = false;
emit("pause", video.value?.currentTime || 0);
}
/** 视频播放结束时复位状态并通知父组件。 */
function onEnded() {
playing.value = false;
emit("ended");
}
/** 媒体元数据加载完成后同步时长和当前时间。 */
function onLoadedMetadata() {
duration.value = video.value?.duration || 0;
if (video.value) video.value.currentTime = props.startTime;
progress.value = props.startTime;
}
/** 媒体数据可用时清除加载状态。 */
function onLoadedData() {
loading.value = false;
failed.value = false;
}
/** 媒体加载失败时记录错误状态并通知父组件。 */
function onMediaError() {
if (!playableSrc.value) return;
loading.value = false;
failed.value = true;
}
/** 根据主播放器进度控件更新播放位置。 */
function seek(event: Event) {
if (!video.value) return;
const value = Number((event.target as HTMLInputElement).value);
video.value.currentTime = value;
progress.value = value;
emit("seek", value);
}
/** 结束拖动并通知父组件恢复时间同步。 */
function finishSeek() {
emit("seek-end", video.value?.currentTime || 0);
}
/** 根据全屏预览进度控件更新播放位置。 */
function dialogSeek(event: Event) {
if (!dialogVideo.value) return;
const value = Number((event.target as HTMLInputElement).value);
dialogVideo.value.currentTime = value;
dialogProgress.value = value;
}
/** 按给定秒数偏移逐帧调整当前播放位置。 */
function frame(delta: number) {
if (!video.value) return;
video.value.pause();
video.value.currentTime = Math.max(0, video.value.currentTime + delta);
progress.value = video.value.currentTime;
emit("seek", video.value.currentTime);
}
/** 切换全屏预览视频的播放状态。 */
function toggleDialog() {
if (!dialogVideo.value) return;
dialogVideo.value.paused ? void dialogVideo.value.play() : dialogVideo.value.pause();
}
/** 切换全屏预览视频的静音状态。 */
function toggleDialogMute() {
muted.value = !muted.value;
if (dialogVideo.value) dialogVideo.value.muted = muted.value;
emit("volume", muted.value);
}
/** 切换主播放器静音状态并通知父组件。 */
function toggleMute() {
muted.value = !muted.value;
if (video.value) video.value.muted = muted.value;
if (dialogVideo.value) dialogVideo.value.muted = muted.value;
emit("volume", muted.value);
}
/** 打开全屏预览,并保持当前播放位置和静音状态。 */
function openFullscreen() {
video.value?.pause();
playing.value = false;
fullscreen.value = true;
dialogLoading.value = true;
dialogFailed.value = false;
emit("fullscreen-open");
void nextTick(() => {
if (!dialogVideo.value || !video.value) return;
dialogVideo.value.currentTime = video.value.currentTime;
dialogVideo.value.muted = muted.value;
dialogProgress.value = dialogVideo.value.currentTime;
});
}
/** 关闭全屏预览并暂停预览播放器。 */
function closeFullscreen() {
dialogVideo.value?.pause();
dialogPlaying.value = false;
}
/** 将秒数格式化为播放器时间文案。 */
function formatTime(value: number) {
if (!Number.isFinite(value)) return "00:00";
return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(Math.floor(value) % 60).padStart(2, "0")}`;
}
/** 将当前视频帧导出为 PNG 图片。 */
function exportFrame() {
const currentVideo = video.value;
if (
!currentVideo ||
currentVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA ||
!currentVideo.videoWidth ||
!currentVideo.videoHeight
) {
toast.warning("视频尚未加载完成");
return;
}
const canvas = document.createElement("canvas");
canvas.width = currentVideo.videoWidth;
canvas.height = currentVideo.videoHeight;
const context = canvas.getContext("2d");
if (!context) {
toast.error("当前浏览器无法导出静帧");
return;
}
try {
context.drawImage(currentVideo, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
toast.error("静帧导出失败");
return;
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = "frame.png";
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}, "image/png");
} catch {
toast.error("静帧导出失败");
}
}
defineExpose({
openFullscreen,
previousFrame: () => frame(-1 / 25),
nextFrame: () => frame(1 / 25),
exportFrame,
pause: () => video.value?.pause(),
play: () => video.value?.play(),
currentTime: () => video.value?.currentTime || 0,
setCurrentTime: (time: number) => {
if (video.value) {
video.value.currentTime = time;
progress.value = time;
}
},
setMuted: (value: boolean) => {
muted.value = value;
if (video.value) video.value.muted = value;
if (dialogVideo.value) dialogVideo.value.muted = value;
},
isMuted: () => muted.value,
});
</script>
<template>
<div class="video-player">
<video
ref="video"
:src="playableSrc || undefined"
preload="metadata"
playsinline
disablepictureinpicture
disableremoteplayback
controlslist="nodownload noplaybackrate noremoteplayback"
:muted="muted"
@timeupdate="progress = video?.currentTime || 0"
@loadedmetadata="onLoadedMetadata"
@loadeddata="onLoadedData"
@error="onMediaError"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@click="toggle"
/>
<MediaLoadingState
v-if="loading || failed"
:failed="failed"
label="视频加载中"
error-label="视频加载失败"
/>
<div class="video-controls" :class="{ 'video-controls-transparent': props.transparentControls }">
<input
type="range"
:disabled="!props.seekable"
min="0"
:max="duration"
step="0.01"
:value="progress"
@input="seek"
@change="finishSeek"
/><span class="video-time">{{ formatTime(progress) }} / {{ formatTime(duration) }}</span
><button
type="button"
class="sound-button"
:title="muted ? '取消静音' : '静音'"
@click="toggleMute"
>
<SpeakerOff
v-if="muted"
:size="17"
:stroke-width="1.8"
/><Speaker
v-else
:size="17"
:stroke-width="1.8"
/></button
><el-icon
title="全屏预览"
@click="openFullscreen"
><FullScreen
/></el-icon>
</div>
<el-dialog
v-model="fullscreen"
title="视频预览"
:width="props.aspectRatio === '9:16' ? 'min(440px, calc(100vw - 28px))' : 'min(900px, calc(100vw - 28px))'"
class="video-preview-dialog"
append-to-body
align-center
@closed="closeFullscreen"
>
<div
class="dialog-player"
:class="dialogClass"
>
<video
ref="dialogVideo"
:src="playableSrc || undefined"
preload="metadata"
playsinline
disablepictureinpicture
disableremoteplayback
controlslist="nodownload noplaybackrate noremoteplayback"
:muted="muted"
@timeupdate="dialogProgress = dialogVideo?.currentTime || 0"
@loadedmetadata="
dialogDuration = dialogVideo?.duration || 0;
dialogProgress = video?.currentTime || 0;
"
@loadeddata="
dialogLoading = false;
dialogFailed = false;
"
@error="
dialogLoading = false;
dialogFailed = true;
"
@play="dialogPlaying = true"
@pause="dialogPlaying = false"
@click="toggleDialog"
/>
<MediaLoadingState
v-if="dialogLoading || dialogFailed"
:failed="dialogFailed"
label="视频加载中"
error-label="视频加载失败"
/>
<div class="dialog-controls">
<input
type="range"
min="0"
:max="dialogDuration"
step="0.01"
:value="dialogProgress"
@input="dialogSeek"
/><span>{{ formatTime(dialogProgress) }} / {{ formatTime(dialogDuration) }}</span
><button
type="button"
@click="toggleDialogMute"
>
<SpeakerOff
v-if="muted"
:size="17"
/><Speaker
v-else
:size="17"
/>
</button>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.video-player {
position: relative;
width: 100%;
height: 100%;
background: var(--web-surface-soft);
}
.video-player > video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.video-player video::-webkit-media-controls-picture-in-picture-button,
.dialog-player video::-webkit-media-controls-picture-in-picture-button {
display: none !important;
}
.video-controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 38px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 9px;
background: var(--web-video-controls);
color: var(--web-text);
}
.video-controls-transparent {
background: transparent;
color: var(--web-text-secondary);
}
.video-controls-transparent .video-time {
color: var(--web-text-secondary);
}
.video-controls-transparent .sound-button,
.video-controls-transparent .el-icon {
color: var(--web-text-secondary);
}
.video-controls-transparent .sound-button:hover,
.video-controls-transparent .el-icon:hover {
color: var(--web-text-secondary);
}
.video-controls input {
min-width: 0;
flex: 1;
}
.video-controls input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.video-time {
flex: 0 0 auto;
color: var(--web-text-secondary);
font:
10px Arial,
sans-serif;
white-space: nowrap;
}
.video-controls .el-icon {
cursor: pointer;
}
.sound-button,
.dialog-controls button {
width: 22px;
height: 22px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-media-control-text);
border: 0;
background: none;
cursor: pointer;
}
.sound-button:hover,
.dialog-controls button:hover {
color: var(--web-media-control-text);
}
.dialog-player {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: var(--web-video-bg);
}
.dialog-player.portrait {
width: min(100%, 400px);
margin: 0 auto;
aspect-ratio: 9/16;
}
.dialog-player > video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background: var(--web-video-bg);
}
.dialog-controls {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 42px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 9px;
background: var(--web-video-dialog-controls);
color: var(--web-text);
}
.dialog-controls input {
min-width: 0;
flex: 1;
}
.dialog-controls span {
font:
11px Arial,
sans-serif;
white-space: nowrap;
color: var(--web-media-control-text);
}
</style>
<style>
.video-preview-dialog.el-dialog {
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
}
.video-preview-dialog.el-dialog .el-dialog__header {
border-bottom: 1px solid var(--web-line-strong);
}
.video-preview-dialog.el-dialog .el-dialog__title,
.video-preview-dialog.el-dialog .el-dialog__headerbtn .el-dialog__close {
color: var(--web-text);
}
.video-preview-dialog.el-dialog .el-dialog__body {
padding: 14px;
background: var(--web-surface-soft);
}
</style>
@@ -0,0 +1,213 @@
<!-- 可缩放大图画布,统一处理预览图片的滚轮缩放与切图复位。 -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
const props = defineProps<{
src: string;
alt: string;
}>();
const minScale = 1;
const maxScale = 5;
const scaleStep = 0.2;
const viewport = ref<HTMLElement>();
const scale = ref(minScale);
const offsetX = ref(0);
const offsetY = ref(0);
const fittedWidth = ref(0);
const fittedHeight = ref(0);
const aspectRatio = ref("16 / 9");
const dragging = ref(false);
let naturalWidth = 0;
let naturalHeight = 0;
let resizeObserver: ResizeObserver | undefined;
let dragPointerId: number | undefined;
let dragStartX = 0;
let dragStartY = 0;
let dragOffsetX = 0;
let dragOffsetY = 0;
/** 将缩放状态恢复到图片居中的原始尺寸。 */
function resetZoom() {
scale.value = minScale;
offsetX.value = 0;
offsetY.value = 0;
dragging.value = false;
dragPointerId = undefined;
}
/** 按画布与图片原始尺寸计算完整居中的显示尺寸,避免固有尺寸超出画布后被裁切。 */
function updateFittedSize() {
const container = viewport.value;
if (!container || naturalWidth <= 0 || naturalHeight <= 0) return;
const fitRatio = Math.min(container.clientWidth / naturalWidth, container.clientHeight / naturalHeight, 1);
fittedWidth.value = naturalWidth * fitRatio;
fittedHeight.value = naturalHeight * fitRatio;
}
/** 图片加载完成后记录原始尺寸,并将初始视图适配为完整显示。 */
function fitImage(event: Event) {
const image = event.target as HTMLImageElement;
naturalWidth = image.naturalWidth;
naturalHeight = image.naturalHeight;
if (naturalWidth > 0 && naturalHeight > 0) {
aspectRatio.value = `${naturalWidth} / ${naturalHeight}`;
}
updateFittedSize();
resetZoom();
}
/** 按当前倍率限制平移范围,确保放大图片不会被完全拖出画布。 */
function clampOffset(x: number, y: number, nextScale = scale.value) {
const container = viewport.value;
if (!container || fittedWidth.value <= 0 || fittedHeight.value <= 0) return { x: 0, y: 0 };
const maxX = Math.max(0, (fittedWidth.value * nextScale - container.clientWidth) / 2);
const maxY = Math.max(0, (fittedHeight.value * nextScale - container.clientHeight) / 2);
return {
x: Math.min(maxX, Math.max(-maxX, x)),
y: Math.min(maxY, Math.max(-maxY, y)),
};
}
/** 以滚轮指针所在位置为中心缩放图片,并限制可用倍率范围。 */
function zoomByWheel(event: WheelEvent) {
const container = viewport.value;
if (!container) return;
const previousScale = scale.value;
const direction = event.deltaY < 0 ? 1 : -1;
const nextScale = Math.min(maxScale, Math.max(minScale, Number((previousScale + direction * scaleStep).toFixed(2))));
if (nextScale === previousScale) return;
if (nextScale === minScale) {
resetZoom();
return;
}
const bounds = container.getBoundingClientRect();
const pointerX = event.clientX - bounds.left - bounds.width / 2;
const pointerY = event.clientY - bounds.top - bounds.height / 2;
const ratio = nextScale / previousScale;
const nextOffset = clampOffset(
pointerX - (pointerX - offsetX.value) * ratio,
pointerY - (pointerY - offsetY.value) * ratio,
nextScale,
);
scale.value = nextScale;
offsetX.value = nextOffset.x;
offsetY.value = nextOffset.y;
}
/** 在图片放大后开始记录鼠标拖拽位置。 */
function startDrag(event: PointerEvent) {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (scale.value <= minScale) return;
const target = event.currentTarget as HTMLElement;
target.setPointerCapture(event.pointerId);
dragPointerId = event.pointerId;
dragStartX = event.clientX;
dragStartY = event.clientY;
dragOffsetX = offsetX.value;
dragOffsetY = offsetY.value;
dragging.value = true;
}
/** 根据鼠标位移平移放大图片,并约束图片可见边界。 */
function moveDrag(event: PointerEvent) {
if (!dragging.value || event.pointerId !== dragPointerId) return;
const nextOffset = clampOffset(dragOffsetX + event.clientX - dragStartX, dragOffsetY + event.clientY - dragStartY);
offsetX.value = nextOffset.x;
offsetY.value = nextOffset.y;
}
/** 结束当前图片拖拽并释放指针捕获。 */
function stopDrag(event: PointerEvent) {
if (event.pointerId !== dragPointerId) return;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
dragging.value = false;
dragPointerId = undefined;
}
watch(
() => props.src,
() => {
naturalWidth = 0;
naturalHeight = 0;
fittedWidth.value = 0;
fittedHeight.value = 0;
aspectRatio.value = "16 / 9";
resetZoom();
},
);
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
updateFittedSize();
resetZoom();
});
if (viewport.value) resizeObserver.observe(viewport.value);
});
onBeforeUnmount(() => resizeObserver?.disconnect());
</script>
<template>
<div
ref="viewport"
class="zoomable-image"
:class="{ 'is-zoomed': scale > minScale, 'is-dragging': dragging }"
:style="{ '--zoomable-aspect-ratio': aspectRatio }"
@wheel.prevent="zoomByWheel"
@pointerdown="startDrag"
@pointermove="moveDrag"
@pointerup="stopDrag"
@pointercancel="stopDrag"
>
<LoadingMedia
type="image"
class="zoomable-image__media"
:src="src"
:alt="alt"
:style="{
width: fittedWidth > 0 ? `${fittedWidth}px` : undefined,
height: fittedHeight > 0 ? `${fittedHeight}px` : undefined,
transform: `translate(${offsetX}px, ${offsetY}px) scale(${scale})`,
}"
@load="fitImage"
/>
</div>
</template>
<style scoped>
.zoomable-image {
width: 100%;
height: auto;
aspect-ratio: var(--zoomable-aspect-ratio, 16 / 9);
max-height: calc(100dvh - 120px);
min-width: 0;
min-height: 0;
position: relative;
display: grid;
place-items: center;
overflow: hidden;
cursor: zoom-in;
touch-action: none;
user-select: none;
}
.zoomable-image :deep(.zoomable-image__media) {
max-width: none;
max-height: none;
display: block;
object-fit: contain;
pointer-events: none;
transition: transform 0.16s ease-out;
}
.zoomable-image.is-zoomed {
cursor: grab;
}
.zoomable-image.is-dragging {
cursor: grabbing;
}
.zoomable-image.is-dragging :deep(.zoomable-image__media) {
transition: none;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
import { ref } from "vue";
import { ElMessageBox } from "element-plus";
import { creativeApi, type PromptPreset } from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
const promptTypes = [
"剧本反推",
"剧本分析",
"剧本解析",
"角色、场景、道具解析",
"角色生成",
"场景生成",
"道具生成",
"分镜图生成",
"首尾帧生成",
"视频生成",
];
export const premiumDramaPromptTypes = ["角色生成", "场景生成", "道具生成", "视频生成"] as const;
export function useModelPrompts() {
const toast = useToastStore();
const prompts = ref<PromptPreset[]>([]);
const promptSelections = ref<Record<string, string>>({});
async function loadPrompts(types: readonly string[] = promptTypes) {
try {
const results = await Promise.all(types.map((type) => creativeApi.promptPresets(type)));
const merged = new Map<string, PromptPreset>();
const selected: Record<string, string> = {};
results.forEach((result, index) => {
(result.prompts || []).forEach((prompt) => merged.set(prompt.id, prompt));
if (result.selected_id) selected[types[index]] = result.selected_id;
});
prompts.value = [...merged.values()];
promptSelections.value = selected;
} catch (error) {
prompts.value = [];
promptSelections.value = {};
showError(error, "加载提示词失败");
}
}
async function selectPrompt(payload: { type: string; promptId: string }) {
try {
await creativeApi.selectPrompt(payload.type, payload.promptId);
promptSelections.value = { ...promptSelections.value, [payload.type]: payload.promptId };
} catch (error) {
showError(error, "启用提示词失败");
}
}
async function createPrompt(payload: { name: string; type: string; content: string }) {
try {
const saved = await creativeApi.createCustomPrompt(payload);
prompts.value = [...prompts.value, saved];
await creativeApi.selectPrompt(payload.type, saved.id);
promptSelections.value = { ...promptSelections.value, [payload.type]: saved.id };
} catch (error) {
showError(error, "保存自定义提示词失败");
}
}
async function updatePrompt(payload: { id: string; name: string; type: string; content: string }) {
try {
const saved = await creativeApi.updateCustomPrompt(payload.id, payload);
prompts.value = prompts.value.map((item) => (item.id === saved.id ? saved : item));
} catch (error) {
showError(error, "保存提示词失败");
}
}
async function deletePrompt(prompt: PromptPreset) {
try {
await ElMessageBox.confirm("删除后不可恢复,确定删除该提示词吗?", "删除提示词", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteCustomPrompt(prompt.id);
prompts.value = prompts.value.filter((item) => item.id !== prompt.id);
if (promptSelections.value[prompt.type] === prompt.id) {
promptSelections.value = { ...promptSelections.value, [prompt.type]: "" };
}
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除提示词失败");
}
}
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
return { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt };
}
+152
View File
@@ -0,0 +1,152 @@
// 用户端语义图标映射,统一以 Element Plus 图标库提供现有界面所需图标。
import { defineComponent, h, type Component, type PropType } from "vue";
import {
Aim,
ArrowDown,
Back,
Camera as ElementCamera,
CircleClose,
Close,
CollectionTag,
CopyDocument,
Delete,
Document,
DocumentAdd,
DocumentChecked,
Download as ElementDownload,
EditPen,
Film as ElementFilm,
Finished,
FolderAdd,
Headset,
Key as ElementKey,
Lightning,
Loading,
Lock as ElementLock,
MagicStick,
Menu as ElementMenu,
MoreFilled,
Mute,
Picture,
Plus as ElementPlus,
QuestionFilled,
Reading,
Search as ElementSearch,
SwitchButton,
Unlock as ElementUnlock,
Upload as ElementUpload,
UploadFilled,
User,
UserFilled,
VideoCamera,
VideoPlay,
Wallet as ElementWallet,
WarnTriangleFilled,
Warning,
} from "@element-plus/icons-vue";
// createSemanticIcon 保留原组件的尺寸参数约定,并把其他样式和事件透传给图标组件。
function createSemanticIcon(name: string, icon: Component) {
return defineComponent({
name,
inheritAttrs: false,
props: {
size: { type: [Number, String] as PropType<number | string>, default: undefined },
strokeWidth: { type: [Number, String] as PropType<number | string>, default: undefined },
},
setup(props, { attrs }) {
return () => {
const dimension =
props.size === undefined ? undefined : typeof props.size === "number" ? `${props.size}px` : props.size;
return h(icon, {
...attrs,
style: [attrs.style, dimension ? { width: dimension, height: dimension } : undefined],
});
};
},
});
}
export const ArrowLeft = createSemanticIcon("ArrowLeft", Back);
export const BookOpen = createSemanticIcon("BookOpen", Reading);
export const BookOpenText = createSemanticIcon("BookOpenText", Reading);
export const Camera = createSemanticIcon("Camera", ElementCamera);
export const ChevronDown = createSemanticIcon("ChevronDown", ArrowDown);
export const CircleAlert = createSemanticIcon("CircleAlert", Warning);
export const CircleHelp = createSemanticIcon("CircleHelp", QuestionFilled);
export const CircleX = createSemanticIcon("CircleX", CircleClose);
export const Clapperboard = createSemanticIcon("Clapperboard", VideoCamera);
export const Copy = createSemanticIcon("Copy", CopyDocument);
export const Download = createSemanticIcon("Download", ElementDownload);
export const FileInput = createSemanticIcon("FileInput", DocumentAdd);
export const FilePlus2 = createSemanticIcon("FilePlus2", DocumentAdd);
export const FileSearch = createSemanticIcon("FileSearch", Document);
export const FileText = createSemanticIcon("FileText", Document);
export const FileUp = createSemanticIcon("FileUp", UploadFilled);
export const Film = createSemanticIcon("Film", ElementFilm);
export const FolderInput = createSemanticIcon("FolderInput", FolderAdd);
export const Image = createSemanticIcon("Image", Picture);
export const Key = createSemanticIcon("Key", ElementKey);
export const ListChecks = createSemanticIcon("ListChecks", Finished);
export const LoaderCircle = createSemanticIcon("LoaderCircle", Loading);
export const Lock = createSemanticIcon("Lock", ElementLock);
export const LogOut = createSemanticIcon("LogOut", SwitchButton);
export const Menu = createSemanticIcon("Menu", ElementMenu);
export const MoreHorizontal = createSemanticIcon("MoreHorizontal", MoreFilled);
export const Pencil = createSemanticIcon("Pencil", EditPen);
export const Play = createSemanticIcon("Play", VideoPlay);
export const Plus = createSemanticIcon("Plus", ElementPlus);
export const Save = createSemanticIcon("Save", DocumentChecked);
export const Search = createSemanticIcon("Search", ElementSearch);
export const Sparkles = createSemanticIcon("Sparkles", MagicStick);
export const Tags = createSemanticIcon("Tags", CollectionTag);
export const Target = createSemanticIcon("Target", Aim);
export const Trash2 = createSemanticIcon("Trash2", Delete);
export const TriangleAlert = createSemanticIcon("TriangleAlert", WarnTriangleFilled);
export const Unlock = createSemanticIcon("Unlock", ElementUnlock);
export const Upload = createSemanticIcon("Upload", ElementUpload);
export const UserRound = createSemanticIcon("UserRound", User);
export const Users = createSemanticIcon("Users", UserFilled);
export const Volume2 = createSemanticIcon("Volume2", Headset);
export const VolumeX = createSemanticIcon("VolumeX", Mute);
export const Wallet = createSemanticIcon("Wallet", ElementWallet);
const SpeakerGlyph = defineComponent({
name: "SpeakerGlyph",
setup: () => () =>
h(
"svg",
{
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.8",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
[
h("path", { d: "M4 9v6h4l5 4V5L8 9H4" }),
h("path", { d: "M16 9.5a4 4 0 0 1 0 5" }),
h("path", { d: "M18.5 7a7.5 7.5 0 0 1 0 10" }),
],
),
});
const SpeakerOffGlyph = defineComponent({
name: "SpeakerOffGlyph",
setup: () => () =>
h(
"svg",
{
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.8",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
[h("path", { d: "M4 9v6h4l5 4V5L8 9H4" }), h("path", { d: "m17 9 4 4" }), h("path", { d: "m21 9-4 4" })],
),
});
export const Speaker = createSemanticIcon("Speaker", SpeakerGlyph);
export const SpeakerOff = createSemanticIcon("SpeakerOff", SpeakerOffGlyph);
export const X = createSemanticIcon("X", Close);
export const Zap = createSemanticIcon("Zap", Lightning);
+160
View File
@@ -0,0 +1,160 @@
<!-- 用户端主布局,负责侧栏、移动端导航和账户积分刷新。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { Menu } from "@/icons";
import { RouterView, useRoute, useRouter } from "vue-router";
import WebSidebar from "@/components/WebSidebar.vue";
import { useAuthStore } from "@/stores/auth";
import { POINT_BALANCE_CHANGED_EVENT } from "@/utils/pointBalance";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const viewportWidth = ref(window.innerWidth);
const drawerOpen = ref(false);
const mobile = computed(() => viewportWidth.value < 768);
const collapsed = computed(() => viewportWidth.value >= 768 && viewportWidth.value < 1200);
const fullscreen = computed(() => route.meta.fullscreen === true);
function updateViewport() {
viewportWidth.value = window.innerWidth;
if (!mobile.value) drawerOpen.value = false;
}
function navigate(path: string) {
drawerOpen.value = false;
void router.push(path);
}
function refreshPointBalance() {
void auth.loadAccount();
}
onMounted(() => {
window.addEventListener("resize", updateViewport, { passive: true });
window.addEventListener(POINT_BALANCE_CHANGED_EVENT, refreshPointBalance);
void auth.loadAccount();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateViewport);
window.removeEventListener(POINT_BALANCE_CHANGED_EVENT, refreshPointBalance);
});
</script>
<template>
<div
class="web-layout"
:class="{ 'is-collapsed': collapsed, 'is-fullscreen': fullscreen }"
>
<aside
v-if="!mobile && !fullscreen"
class="web-layout__sidebar"
>
<WebSidebar
:active-path="route.path"
:collapsed="collapsed"
@navigate="navigate"
/>
</aside>
<el-drawer
v-if="!fullscreen"
v-model="drawerOpen"
direction="ltr"
size="min(280px, 88vw)"
:with-header="false"
class="web-drawer"
>
<WebSidebar
:active-path="route.path"
@navigate="navigate"
/>
</el-drawer>
<main class="web-layout__main">
<header
v-if="mobile && !fullscreen"
class="web-layout__mobile-bar"
>
<button
type="button"
class="web-layout__menu"
title="打开菜单"
@click="drawerOpen = true"
>
<Menu :size="21" />
</button>
<strong>{{ String(route.meta.title || "剧游AI") }}</strong>
</header>
<RouterView />
</main>
</div>
</template>
<style scoped lang="scss">
.web-layout {
min-height: 100dvh;
display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
color: var(--web-text);
background: var(--web-bg);
}
.web-layout.is-collapsed {
grid-template-columns: var(--sidebar-width-collapsed) minmax(0, 1fr);
}
.web-layout.is-fullscreen {
grid-template-columns: minmax(0, 1fr);
}
.web-layout__sidebar {
position: sticky;
top: 0;
height: 100dvh;
z-index: 20;
}
.web-layout__main {
min-width: 0;
min-height: 100dvh;
overflow: hidden;
}
.web-layout__mobile-bar {
position: sticky;
top: 0;
z-index: 19;
height: var(--mobile-bar-height);
padding: 0 var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--web-sidebar);
border-bottom: 1px solid var(--web-line);
}
.web-layout__mobile-bar strong {
font-size: 15px;
font-weight: 600;
}
.web-layout__menu {
width: var(--control-height-lg);
height: var(--control-height-lg);
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color 0.18s;
}
.web-layout__menu:hover:not(:disabled) {
background: var(--web-hover);
}
@media (max-width: 767px) {
.web-layout {
display: block;
}
}
</style>
<style lang="scss">
.web-drawer .el-drawer__body {
padding: 0;
background: var(--web-sidebar);
}
</style>
+20
View File
@@ -0,0 +1,20 @@
// 剧游AI用户端入口,负责挂载 Vue 应用、注册路由与状态管理
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue";
import router from "./router";
import "./assets/styles/main.scss";
import "element-plus/dist/index.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
app.mount("#app");
+101
View File
@@ -0,0 +1,101 @@
import { createRouter, createWebHistory } from "vue-router";
import { creativeApi } from "@/api/creative";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { public: true, title: "登录" },
},
{
path: "/",
component: () => import("@/layouts/WebLayout.vue"),
children: [
{ path: "", redirect: "/drama-projects" },
{
path: "drama-projects",
name: "drama-projects",
component: () => import("@/views/RedrawProjectsView.vue"),
meta: { title: "短剧创作", projectType: "premium_drama" },
},
{
path: "drama-projects/:projectId",
name: "drama-project",
component: () => import("@/views/DramaProjectView.vue"),
meta: { title: "短剧项目" },
},
{
path: "drama-projects/:projectId/episodes/:episodeId",
name: "drama-workbench",
component: () => import("@/views/DramaWorkbenchView.vue"),
meta: { title: "短剧工作台", fullscreen: true },
},
{
path: "redraw-projects",
name: "redraw-projects",
component: () => import("@/views/RedrawProjectsView.vue"),
meta: { title: "剧本反推" },
},
{
path: "redraw-projects/:projectId",
name: "redraw-project",
component: () => import("@/views/RedrawProjectView.vue"),
meta: { title: "剧本反推项目" },
},
{
path: "redraw-projects/:projectId/episodes/:episodeId",
name: "redraw-workbench",
component: () => import("@/views/RedrawWorkbenchView.vue"),
meta: { title: "剧本反推", fullscreen: true },
beforeEnter: async (to) => {
try {
const project = await creativeApi.project(String(to.params.projectId));
if (project.project_type === "video_redraw") return true;
return { name: "drama-project", params: { projectId: project.id } };
} catch {
return { name: "redraw-projects" };
}
},
},
{
path: "script-analysis",
name: "script-analysis",
component: () => import("@/views/ScriptAnalysisView.vue"),
meta: { title: "剧本分析" },
},
{
path: "script-analysis/:scriptId",
name: "script-analysis-workbench",
component: () => import("@/views/ScriptAnalysisWorkbenchView.vue"),
meta: { title: "剧本处理", fullscreen: true },
},
{
path: "product-images",
name: "product-images",
component: () => import("@/views/ProductImageWorkbenchView.vue"),
meta: { title: "图片生成" },
},
{
path: "account",
name: "account",
component: () => import("@/views/AccountView.vue"),
meta: { title: "用户中心" },
},
],
},
{ path: "/:pathMatch(.*)*", redirect: "/drama-projects" },
],
});
router.beforeEach((to) => {
const authenticated = Boolean(localStorage.getItem("web_access_token"));
if (!to.meta.public && !authenticated) return { name: "login", query: { redirect: to.fullPath } };
if (to.name === "login" && authenticated) return "/drama-projects";
document.title = "剧游AI";
return true;
});
export default router;
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from "pinia";
import http, { clearAuth, saveTokens } from "@/api/http";
let accountRequest: Promise<Record<string, any>> | null = null;
export const useAuthStore = defineStore("web-auth", {
state: () => ({
profile: JSON.parse(localStorage.getItem("web_profile") || "null") as {
id: string;
uid: string;
username?: string;
account: string;
avatar_url?: string;
} | null,
account: null as Record<string, any> | null,
authenticated: Boolean(localStorage.getItem("web_access_token")),
loading: false,
}),
actions: {
async login(account: string, password: string) {
this.loading = true;
try {
const { data } = await http.post("/auth/login", { account, password });
saveTokens(data.data);
this.profile = data.data.user;
this.authenticated = true;
await this.loadAccount();
} finally {
this.loading = false;
}
},
async loadAccount() {
if (!this.authenticated) return;
if (!accountRequest)
accountRequest = http
.get("/account")
.then(({ data }) => data.data as Record<string, any>)
.finally(() => (accountRequest = null));
this.account = await accountRequest;
},
updateCachedProfile(data: Record<string, any>) {
if (!this.profile) return;
this.profile = { ...this.profile, ...data };
localStorage.setItem("web_profile", JSON.stringify(this.profile));
},
async logout() {
const refresh = localStorage.getItem("web_refresh_token");
try {
await http.post("/auth/logout", { refresh_token: refresh });
} finally {
clearAuth();
this.profile = null;
this.account = null;
this.authenticated = false;
}
},
},
});
+15
View File
@@ -0,0 +1,15 @@
// 剧游AI用户端计数器示例 Store,演示 Pinia 状态管理用法
import { ref, computed } from "vue";
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubleCount, increment };
});
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from "pinia";
export type ToastType = "success" | "warning" | "error" | "info";
export interface ToastItem {
id: number;
type: ToastType;
title: string;
message: string;
copyText?: string;
duration: number;
}
let nextId = 1;
export const useToastStore = defineStore("toast", {
state: () => ({ visible: [] as ToastItem[], queued: [] as ToastItem[] }),
actions: {
show(type: ToastType, message: string, copyText?: string, title?: string) {
const item = {
id: nextId++,
type,
title: title || ({ success: "操作成功", warning: "请注意", error: "请求失败", info: "提示" } as const)[type],
message,
copyText,
duration: 5000,
};
if (this.visible.length >= 3) this.visible.shift();
this.visible.push(item);
},
success(message: string) {
this.show("success", message);
},
warning(message: string) {
this.show("warning", message);
},
info(message: string) {
this.show("info", message);
},
error(message: string, copyText?: string, title?: string) {
this.show("error", message, copyText, title);
},
remove(id: number) {
this.visible = this.visible.filter((item) => item.id !== id);
const next = this.queued.shift();
if (next) this.visible.push(next);
},
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineStore } from "pinia";
export const useUiStore = defineStore("web-ui", {
state: () => ({
loginOpen: false,
loginRedirect: "/",
}),
actions: {
openLogin(redirect = "/") {
this.loginRedirect = redirect;
this.loginOpen = true;
},
},
});
+11
View File
@@ -0,0 +1,11 @@
const BEIJING_TIME_ZONE = "Asia/Shanghai";
export function formatDateTime(value: string | Date | null | undefined): string {
if (!value) return "";
return new Date(value).toLocaleString("zh-CN", { timeZone: BEIJING_TIME_ZONE, hour12: false });
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return "";
return new Date(value).toLocaleDateString("zh-CN", { timeZone: BEIJING_TIME_ZONE });
}
+35
View File
@@ -0,0 +1,35 @@
import { creativeApi } from "@/api/creative";
function saveUrl(url: string, filename: string) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
export async function downloadFile(url: string, filename: string) {
saveUrl(url, filename);
}
// downloadBlob 将 Blob 数据以指定文件名保存到本地。
export async function downloadBlob(blob: Blob, filename: string) {
const objectUrl = URL.createObjectURL(blob);
try {
saveUrl(objectUrl, filename);
} finally {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
}
export async function downloadMedia(mediaId: string | undefined, fallbackUrl: string, filename: string) {
if (!mediaId) return downloadFile(fallbackUrl, filename);
const blob = await creativeApi.media(mediaId);
const objectUrl = URL.createObjectURL(blob);
try {
saveUrl(objectUrl, filename);
} finally {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
}
+26
View File
@@ -0,0 +1,26 @@
export function firstVideoFrameURL(url?: string) {
if (!url) return "";
return `${url.split("#", 1)[0]}#t=0.001`;
}
export async function readAudioDurationMs(file: File): Promise<number | undefined> {
const url = URL.createObjectURL(file);
try {
return await new Promise((resolve) => {
const audio = document.createElement("audio");
audio.preload = "metadata";
audio.onloadedmetadata = () =>
resolve(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined);
audio.onerror = () => resolve(undefined);
audio.src = url;
});
} finally {
URL.revokeObjectURL(url);
}
}
export function formatMediaDuration(durationMs?: number) {
const seconds = Number(durationMs || 0) / 1000;
if (!Number.isFinite(seconds) || seconds <= 0) return "";
return `${seconds.toFixed(1).replace(/\.0$/, "")} 秒`;
}
+33
View File
@@ -0,0 +1,33 @@
import type { CreativeModel, CreativePrice } from "@/api/creative";
function priceValue(price: CreativePrice | undefined) {
if (!price) return "价格未配置";
const amount = Number(price.price);
return Number.isFinite(amount) ? `${Number(amount.toFixed(4))} 积分/${price.unit || "次"}` : "价格未配置";
}
export function isTokenBilling(model: CreativeModel | undefined) {
return model?.model_type === "text" && model.text_billing_mode === "per_token";
}
export function modelPriceLabel(model: CreativeModel | undefined, prices: CreativePrice[], key = "default") {
if (!model) return "价格未配置";
if (isTokenBilling(model)) {
const input = prices.find((item) => item.model_id === model.id && item.price_key.toLowerCase() === "input");
const output = prices.find((item) => item.model_id === model.id && item.price_key.toLowerCase() === "output");
return `输入 ${priceValue(input)} · 输出 ${priceValue(output)}`;
}
return priceValue(
prices.find((item) => item.model_id === model.id && item.price_key.toLowerCase() === key.toLowerCase()),
);
}
export function hasModelPrice(model: CreativeModel | undefined, prices: CreativePrice[], key = "default") {
if (!model) return false;
if (isTokenBilling(model)) {
return ["input", "output"].every((priceKey) =>
prices.some((item) => item.model_id === model.id && item.price_key.toLowerCase() === priceKey),
);
}
return prices.some((item) => item.model_id === model.id && item.price_key.toLowerCase() === key.toLowerCase());
}
+5
View File
@@ -0,0 +1,5 @@
export const POINT_BALANCE_CHANGED_EVENT = "web-point-balance-changed";
export function notifyPointBalanceChanged() {
window.dispatchEvent(new Event(POINT_BALANCE_CHANGED_EVENT));
}
+12
View File
@@ -0,0 +1,12 @@
import { creativeApi, type CreativeOptions, type ModelConfig } from "@/api/creative";
export async function ensureScriptAnalysisTextModel(
options: CreativeOptions,
configs: ModelConfig[],
): Promise<ModelConfig[]> {
if (configs.some((config) => config.model_type === "text" && Boolean(config.model_id))) return configs;
const defaultModel = options.models.find((model) => model.model_type === "text");
if (!defaultModel) return configs;
await creativeApi.saveUserModelConfig("script_analysis", "text", defaultModel.id);
return creativeApi.userModelConfigs("script_analysis");
}
+32
View File
@@ -0,0 +1,32 @@
export const TASK_RESULT_TIMEOUT_MS = 2 * 60 * 1000;
type TimedTask = {
created_at: string;
phase_started_at?: string;
last_result_at?: string;
};
function timestamp(value?: string) {
const parsed = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) ? parsed : 0;
}
export function taskActivityAt(task?: TimedTask) {
if (!task) return 0;
return timestamp(task.phase_started_at) || timestamp(task.last_result_at) || timestamp(task.created_at);
}
export function taskTimedOut(task: TimedTask | undefined, now: number) {
const activityAt = taskActivityAt(task);
return activityAt > 0 && now - activityAt >= TASK_RESULT_TIMEOUT_MS;
}
export function taskElapsedLabel(task: TimedTask | undefined, now: number) {
const activityAt = taskActivityAt(task);
const elapsed = activityAt > 0 ? Math.max(0, Math.floor((now - activityAt) / 1000)) : 0;
const minutes = Math.floor(elapsed / 60)
.toString()
.padStart(2, "0");
const seconds = (elapsed % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
+377
View File
@@ -0,0 +1,377 @@
// 用户中心页面样式,负责账户、额度、兑换、趋势和流水区域的响应式布局。
.account-view {
--account-content-max: 1200px;
min-height: 100dvh;
padding: var(--space-xl) var(--page-gutter);
background: var(--web-bg);
user-select: text;
}
.account-view ::selection {
color: var(--web-text-strong);
background: var(--web-brand-soft);
}
.account-view button,
.account-view :deep(.el-button) {
user-select: none;
}
.account-dashboard {
max-width: var(--account-content-max);
margin-inline: auto;
display: grid;
grid-template-areas:
"chart chart"
"summary records";
grid-template-columns: minmax(340px, 0.82fr) minmax(0, 1.48fr);
align-items: stretch;
gap: var(--space-lg);
}
.account-card header p {
margin-top: var(--space-xs);
color: var(--web-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
}
.summary-grid {
grid-area: summary;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-lg);
}
.profile-card {
order: 1;
}
.balance-card {
order: 2;
}
.redeem-card {
order: 3;
}
.account-card {
min-width: 0;
padding: var(--space-xl);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
}
.redeem-card {
grid-column: auto;
}
.account-card > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.account-card h2 {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
letter-spacing: -0.01em;
}
.account-card header > span {
color: var(--web-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
}
.card-state,
.section-state {
margin-top: var(--space-lg);
}
.profile-main {
margin-top: var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-lg);
}
.avatar-control {
width: 56px;
flex: 0 0 56px;
display: flex;
align-items: center;
}
.avatar-button {
width: 56px;
height: 56px;
padding: 0;
position: relative;
flex: 0 0 56px;
display: grid;
place-items: center;
overflow: hidden;
color: var(--web-text);
background: var(--web-raised);
border: 1px solid var(--web-line);
border-radius: var(--radius-sm);
cursor: pointer;
}
.avatar-button:hover:not(:disabled) {
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.avatar-button:disabled {
color: var(--web-text-disabled);
cursor: not-allowed;
}
.avatar-button img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-button i {
position: absolute;
right: 3px;
bottom: 3px;
width: 19px;
height: 19px;
display: grid;
place-items: center;
color: var(--web-text-strong);
background: var(--web-primary);
border-radius: 6px;
font-style: normal;
}
.avatar-input {
display: none;
}
.profile-copy {
min-width: 0;
flex: 1;
}
.profile-copy p {
margin-top: var(--space-xs);
color: var(--web-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
overflow-wrap: anywhere;
}
.username-display,
.username-edit {
display: flex;
align-items: center;
gap: 8px;
}
.username-display strong {
min-width: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
line-height: 1.35;
overflow-wrap: anywhere;
}
.username-display button {
width: 40px;
height: 40px;
display: grid;
place-items: center;
color: var(--web-muted);
background: transparent;
border: 0;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s;
}
.username-display button:hover:not(:disabled) {
color: var(--web-text);
background: var(--web-hover);
}
.username-edit {
flex-wrap: wrap;
}
.username-field {
width: min(220px, 100%);
min-width: 160px;
flex: 1 1 180px;
}
.username-field small {
margin-top: var(--space-xs);
display: block;
color: var(--web-muted);
font-size: var(--font-size-xs);
}
.username-field small.error {
color: var(--web-danger);
}
.metric-grid {
margin-top: var(--space-lg);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid var(--web-line);
}
.metric-grid > div {
min-width: 0;
padding: var(--space-md);
display: flex;
flex-direction: column;
}
.metric-grid > div + div {
border-left: 1px solid var(--web-line);
}
.metric-grid span {
color: var(--web-muted);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
}
.metric-grid strong {
margin-top: var(--space-xs);
font-size: var(--font-size-metric);
font-weight: var(--font-weight-semibold);
line-height: 1.2;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.redeem-form {
margin-top: var(--space-lg);
display: flex;
align-items: stretch;
flex-direction: column;
gap: var(--space-md);
}
.redeem-field {
width: 100%;
min-width: 0;
}
.redeem-field small {
margin-top: var(--space-xs);
display: block;
color: var(--web-muted);
font-size: 11px;
}
.redeem-form :deep(.el-button) {
width: 100%;
flex: 0 0 auto;
margin-left: 0;
}
.redeem-form :deep(.el-button--primary.is-disabled),
.redeem-form :deep(.el-button--primary.is-disabled:hover) {
color: var(--web-accent-muted);
background: var(--web-brand-soft);
border-color: var(--web-brand-soft);
cursor: not-allowed;
}
.account-view :deep(.el-button),
.account-view :deep(.el-input__inner) {
font-size: var(--font-size-md);
font-weight: var(--font-weight-regular);
}
.chart-card {
grid-area: chart;
}
.chart-card :deep(.consumption-chart) {
margin-top: var(--space-md);
height: 176px;
}
.records-card {
grid-area: records;
padding-bottom: var(--space-md);
}
.records-summary {
margin-top: 14px;
}
.empty-state {
padding: 48px 16px;
color: var(--web-muted);
line-height: 1.6;
text-align: center;
}
.dialog-record-list {
min-height: 0;
height: 100%;
overflow: hidden;
}
:global(.records-dialog.el-dialog) {
height: min(560px, calc(100dvh - 48px));
display: flex;
flex-direction: column;
}
:global(.records-dialog.el-dialog .el-dialog__body) {
min-height: 0;
padding-block: 0;
flex: 1;
overflow: hidden;
}
:global(.records-dialog.el-dialog .el-dialog__footer) {
flex: 0 0 auto;
}
.avatar-preview {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--space-lg);
}
.avatar-preview img {
width: 180px;
height: 180px;
object-fit: cover;
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
}
.avatar-preview p {
color: var(--web-muted);
font-size: 12px;
text-align: center;
}
@media (max-width: 960px) {
.account-dashboard {
grid-template-areas:
"chart"
"summary"
"records";
grid-template-columns: minmax(0, 1fr);
}
.redeem-card {
grid-column: auto;
}
.summary-grid {
height: auto;
justify-content: flex-start;
}
.chart-card :deep(.consumption-chart) {
margin-top: var(--space-sm);
height: 190px;
}
}
@media (min-width: 961px) and (max-height: 900px) {
.account-view {
padding-block: var(--space-lg);
}
.account-dashboard {
gap: var(--space-md);
}
.chart-card :deep(.consumption-chart) {
height: 150px;
}
}
@media (max-width: 767px) {
.account-view {
padding: var(--space-lg) var(--page-gutter-mobile) 40px;
}
.account-card {
padding: var(--space-xl);
}
.profile-main {
align-items: flex-start;
flex-wrap: wrap;
}
.password-button {
width: 100%;
}
.metric-grid {
grid-template-columns: 1fr;
}
.metric-grid > div {
padding-inline: 0;
}
.metric-grid > div + div {
border-top: 1px solid var(--web-line);
border-left: 0;
}
.redeem-form {
align-items: stretch;
flex-direction: column;
}
.redeem-form :deep(.el-button) {
width: 100%;
}
}
+669
View File
@@ -0,0 +1,669 @@
<!-- 用户账户页,负责资料维护、密码修改、兑换码和积分记录展示。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { Camera, Key, Lock, Pencil, Wallet } from "@/icons";
import AccountSectionState from "@/components/account/AccountSectionState.vue";
import PointRecordList from "@/components/account/PointRecordList.vue";
import type { PointRecord, PointRecordPage } from "@/components/account/types";
import ConsumptionChart from "@/components/ConsumptionChart.vue";
import http, { apiError } from "@/api/http";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { formatDate } from "@/utils/datetime";
/** AccountData 描述用户中心依赖的账户资料、积分额度和头像限制。 */
interface AccountData {
uid?: string;
username?: string;
account?: string;
avatar_url?: string;
point_balance?: string | number;
today_consumption?: string | number;
daily_limit?: string | number;
avatar_max_size_bytes?: number;
}
/** UsageItem 描述一天的积分消耗汇总。 */
interface UsageItem {
day: string;
consumption: string | number;
}
const auth = useAuthStore();
const toast = useToastStore();
const initializing = ref(true);
const accountLoading = ref(false);
const usageLoading = ref(false);
const recordsLoading = ref(false);
const dialogRecordsLoading = ref(false);
const accountReady = ref(false);
const usageReady = ref(false);
const recordsReady = ref(false);
const accountError = ref("");
const usageError = ref("");
const recordsError = ref("");
const dialogRecordsError = ref("");
const avatarUploading = ref(false);
const profileSaving = ref(false);
const passwordSaving = ref(false);
const editingUsername = ref(false);
const usernameError = ref("");
const avatarDialogVisible = ref(false);
const passwordDialogVisible = ref(false);
const recordsDialogVisible = ref(false);
const avatarInput = ref<HTMLInputElement>();
const passwordFormRef = ref<FormInstance>();
const pendingAvatarFile = ref<File>();
const avatarPreviewUrl = ref("");
const redeeming = ref(false);
const code = ref("");
const usage = ref<UsageItem[]>([]);
const recentRecords = ref<PointRecord[]>([]);
const dialogRecords = ref<PointRecord[]>([]);
const recordsTotal = ref(0);
const recordsPage = ref(1);
const pageSize = 10;
const profileForm = reactive({ username: "" });
const passwordForm = reactive({ originalPassword: "", newPassword: "", confirmPassword: "" });
const passwordRules: FormRules = {
originalPassword: [{ required: true, message: "请输入原密码", trigger: "blur" }],
newPassword: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 8, max: 128, message: "密码长度必须为 8~128 位", trigger: "blur" },
],
confirmPassword: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_rule, value, callback) =>
value === passwordForm.newPassword ? callback() : callback(new Error("两次输入的新密码不一致")),
trigger: "blur",
},
],
};
const account = computed(() => auth.account as AccountData | null);
const labels = computed(() => usage.value.map((item) => formatDate(item.day)));
const values = computed(() => usage.value.map((item) => Number(item.consumption)));
const totalConsumption = computed(() => values.value.reduce((sum, value) => sum + value, 0).toFixed(2));
const hasConsumption = computed(() => values.value.some((value) => value > 0));
const balance = computed(() => Number(account.value?.point_balance || 0).toFixed(2));
const today = computed(() => Number(account.value?.today_consumption || 0).toFixed(2));
const unlimited = computed(() => Number(account.value?.daily_limit) === 9999);
const limit = computed(() => (unlimited.value ? "不限制" : Number(account.value?.daily_limit || 0).toFixed(2)));
const remaining = computed(() =>
unlimited.value
? "不限制"
: Math.max(Number(account.value?.daily_limit || 0) - Number(account.value?.today_consumption || 0), 0).toFixed(2),
);
const avatarMaxSizeBytes = computed(() => Number(account.value?.avatar_max_size_bytes || 0));
const avatarLimitText = computed(() =>
avatarMaxSizeBytes.value > 0
? `支持 JPEG、PNG、WebP,文件不超过 ${(avatarMaxSizeBytes.value / 1024 / 1024).toFixed(0)} MB`
: "头像上传暂不可用",
);
/** loadAccountData 加载账户资料和额度,失败时保留明确错误状态而不是显示默认零值。 */
async function loadAccountData() {
accountLoading.value = true;
accountError.value = "";
try {
const { data } = await http.get("/account");
auth.account = data.data;
profileForm.username = String(account.value?.username || account.value?.account || "");
accountReady.value = true;
} catch (error) {
accountReady.value = false;
accountError.value = apiError(error).message || "账户信息加载失败";
} finally {
accountLoading.value = false;
}
}
/** loadUsageData 加载近三十日消耗,失败时仅影响趋势分区。 */
async function loadUsageData() {
usageLoading.value = true;
usageError.value = "";
try {
const { data } = await http.get("/usage/30-days");
usage.value = Array.isArray(data.data) ? data.data : [];
usageReady.value = true;
} catch (error) {
usageReady.value = false;
usageError.value = apiError(error).message || "消耗数据加载失败";
} finally {
usageLoading.value = false;
}
}
/** requestRecords 按页读取积分流水并校验分页响应结构。 */
async function requestRecords(page: number) {
const { data } = await http.get("/usage/consumption-records", { params: { page, page_size: pageSize } });
return data.data as PointRecordPage;
}
/** loadRecentRecords 加载用户中心摘要使用的最近十条积分流水。 */
async function loadRecentRecords() {
recordsLoading.value = true;
recordsError.value = "";
try {
const result = await requestRecords(1);
recentRecords.value = Array.isArray(result.items) ? result.items : [];
recordsTotal.value = Number(result.total || 0);
recordsReady.value = true;
} catch (error) {
recordsReady.value = false;
recordsError.value = apiError(error).message || "积分记录加载失败";
} finally {
recordsLoading.value = false;
}
}
/** loadDialogRecords 加载完整流水弹窗当前页,并提供弹窗内重试状态。 */
async function loadDialogRecords(page = recordsPage.value) {
dialogRecordsLoading.value = true;
dialogRecordsError.value = "";
try {
const result = await requestRecords(page);
dialogRecords.value = Array.isArray(result.items) ? result.items : [];
recordsTotal.value = Number(result.total || 0);
recordsPage.value = Number(result.page || page);
} catch (error) {
dialogRecordsError.value = apiError(error).message || "积分记录加载失败";
} finally {
dialogRecordsLoading.value = false;
}
}
/** load 初始化用户中心各分区,允许单个接口失败而不拖垮其他内容。 */
async function load() {
initializing.value = true;
await Promise.allSettled([loadAccountData(), loadUsageData(), loadRecentRecords()]);
initializing.value = false;
}
/** profileInitials 生成无头像时展示的用户名首字母。 */
function profileInitials() {
return String(account.value?.username || account.value?.account || "JH")
.slice(0, 2)
.toUpperCase();
}
/** startUsernameEdit 进入用户名编辑并恢复当前已保存内容。 */
function startUsernameEdit() {
profileForm.username = String(account.value?.username || account.value?.account || "");
usernameError.value = "";
editingUsername.value = true;
}
/** cancelUsernameEdit 退出用户名编辑并清除未提交错误。 */
function cancelUsernameEdit() {
usernameError.value = "";
editingUsername.value = false;
}
/** saveUsername 校验并保存用户名,将错误直接反馈在输入字段附近。 */
async function saveUsername() {
const username = profileForm.username.trim();
if ([...username].length < 5 || [...username].length > 12) {
usernameError.value = "用户名长度必须为 5~12 个字符";
return;
}
usernameError.value = "";
profileSaving.value = true;
try {
const { data } = await http.patch("/account/profile", { username });
auth.account = { ...account.value, ...data.data };
auth.updateCachedProfile({ username: data.data.username });
editingUsername.value = false;
toast.success("用户名已更新");
} catch (error) {
const detail = apiError(error);
usernameError.value = detail.message;
toast.error(detail.message, detail.copyText, "用户名修改失败");
} finally {
profileSaving.value = false;
}
}
/** chooseAvatar 打开头像文件选择器;存储不可用时保持控件禁用。 */
function chooseAvatar() {
if (avatarMaxSizeBytes.value > 0) avatarInput.value?.click();
}
/** selectAvatar 校验头像格式和体积,并在上传前生成本地预览。 */
function selectAvatar(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.warning("头像仅支持 JPEG、PNG 或 WebP 图片");
return;
}
if (avatarMaxSizeBytes.value <= 0 || file.size > avatarMaxSizeBytes.value) {
toast.warning(avatarLimitText.value);
return;
}
resetAvatarPreview();
pendingAvatarFile.value = file;
avatarPreviewUrl.value = URL.createObjectURL(file);
avatarDialogVisible.value = true;
}
/** uploadAvatar 上传预览确认后的头像并同步账户缓存。 */
async function uploadAvatar() {
if (!pendingAvatarFile.value) return;
avatarUploading.value = true;
try {
const form = new FormData();
form.append("file", pendingAvatarFile.value);
const { data } = await http.post("/account/avatar", form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (auth.account) auth.account = { ...auth.account, avatar_url: data.data.avatar_url };
auth.updateCachedProfile({ avatar_url: data.data.avatar_url });
avatarDialogVisible.value = false;
toast.success("头像已更新");
} catch (error) {
showError(error, "头像修改失败");
} finally {
avatarUploading.value = false;
}
}
/** resetAvatarPreview 释放本地预览地址并清除待上传文件。 */
function resetAvatarPreview() {
if (avatarPreviewUrl.value) URL.revokeObjectURL(avatarPreviewUrl.value);
avatarPreviewUrl.value = "";
pendingAvatarFile.value = undefined;
}
/** openPasswordDialog 清空历史输入并打开密码修改弹窗。 */
function openPasswordDialog() {
Object.assign(passwordForm, { originalPassword: "", newPassword: "", confirmPassword: "" });
passwordDialogVisible.value = true;
}
/** changePassword 校验密码表单并提交新密码。 */
async function changePassword() {
if (!(await passwordFormRef.value?.validate().catch(() => false))) return;
passwordSaving.value = true;
try {
await http.put("/account/password", {
original_password: passwordForm.originalPassword,
new_password: passwordForm.newPassword,
});
passwordDialogVisible.value = false;
toast.success("密码已修改");
} catch (error) {
showError(error, "密码修改失败");
} finally {
passwordSaving.value = false;
}
}
/** redeem 提交兑换码并独立刷新账户、趋势和流水分区。 */
async function redeem() {
if (!code.value.trim()) return;
redeeming.value = true;
try {
const { data } = await http.post("/redemption/redeem", { code: code.value });
toast.success(`兑换成功,已到账 ${Number(data.data.points).toFixed(2)} 积分`);
code.value = "";
await Promise.allSettled([loadAccountData(), loadUsageData(), loadRecentRecords()]);
if (recordsDialogVisible.value) await loadDialogRecords(recordsPage.value);
} catch (error) {
showError(error, "兑换失败");
} finally {
redeeming.value = false;
}
}
/** openRecordsDialog 打开流水弹窗并复用已加载的第一页数据。 */
function openRecordsDialog() {
recordsPage.value = 1;
dialogRecords.value = [...recentRecords.value];
dialogRecordsError.value = "";
recordsDialogVisible.value = true;
}
/** showError 通过统一消息组件展示操作失败详情。 */
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
onMounted(load);
onBeforeUnmount(resetAvatarPreview);
</script>
<template>
<div
v-loading="initializing"
class="web-page account-view"
>
<div class="account-dashboard">
<section class="summary-grid">
<article class="account-card balance-card">
<header>
<h2>创作额度</h2>
<span v-if="accountReady">每日限额 {{ limit }}</span>
</header>
<AccountSectionState
v-if="accountError"
class="card-state"
:message="accountError"
@retry="loadAccountData"
/>
<div
v-else-if="accountReady"
v-loading="accountLoading"
class="metric-grid"
>
<div>
<span>可用积分</span><strong>{{ balance }}</strong>
</div>
<div>
<span>今日消耗</span><strong>{{ today }}</strong>
</div>
<div>
<span>今日剩余可用</span><strong>{{ remaining }}</strong>
</div>
</div>
</article>
<article class="account-card profile-card">
<header><h2>账户资料</h2></header>
<AccountSectionState
v-if="accountError"
class="card-state"
:message="accountError"
@retry="loadAccountData"
/>
<div
v-else-if="accountReady"
v-loading="accountLoading"
class="profile-main"
>
<div class="avatar-control">
<button
v-loading="avatarUploading"
class="avatar-button"
type="button"
:title="avatarLimitText"
:disabled="avatarUploading || avatarMaxSizeBytes <= 0"
@click="chooseAvatar"
>
<img
v-if="account?.avatar_url"
:src="account.avatar_url"
alt=""
/>
<span v-else>{{ profileInitials() }}</span>
<i><Camera :size="14" /></i>
</button>
</div>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png,image/webp"
@change="selectAvatar"
/>
<div class="profile-copy">
<form
v-if="editingUsername"
class="username-edit"
@submit.prevent="saveUsername"
>
<div class="username-field">
<el-input
v-model.trim="profileForm.username"
maxlength="12"
show-word-limit
@input="usernameError = ''"
/>
<small :class="{ error: usernameError }">{{ usernameError || "用户名长度为 5~12 个字符" }}</small>
</div>
<el-button
native-type="submit"
:loading="profileSaving"
>保存</el-button
>
<el-button
native-type="button"
:disabled="profileSaving"
@click="cancelUsernameEdit"
>取消</el-button
>
</form>
<div
v-else
class="username-display"
>
<strong>{{ account?.username || account?.account }}</strong>
<button
type="button"
title="修改用户名"
@click="startUsernameEdit"
>
<Pencil :size="16" />
</button>
</div>
<p>UID {{ account?.uid || "--" }} · 账号 {{ account?.account || "--" }}</p>
</div>
<el-button
class="password-button"
@click="openPasswordDialog"
><Lock :size="16" />修改密码</el-button
>
</div>
</article>
<article class="account-card redeem-card">
<header><h2>兑换码</h2></header>
<form
class="redeem-form"
@submit.prevent="redeem"
>
<div class="redeem-field">
<el-input
v-model.trim="code"
maxlength="64"
placeholder="请输入兑换码"
>
<template #prefix><Key :size="16" /></template>
</el-input>
</div>
<el-button
native-type="submit"
type="primary"
:disabled="!code.trim() || redeeming"
:loading="redeeming"
><Wallet :size="16" />立即兑换</el-button
>
</form>
</article>
</section>
<section
v-loading="usageLoading && !initializing"
class="account-card chart-card"
>
<header>
<div>
<h2>近 30 日消耗</h2>
<p v-if="usageReady">合计 {{ totalConsumption }} 积分</p>
</div>
</header>
<AccountSectionState
v-if="usageError"
class="section-state"
:message="usageError"
@retry="loadUsageData"
/>
<ConsumptionChart
v-else-if="usageReady && hasConsumption"
:labels="labels"
:values="values"
/>
<div
v-else-if="usageReady"
class="empty-state"
>
近 30 日暂无积分消耗
</div>
</section>
<section
v-loading="recordsLoading && !initializing"
class="account-card records-card"
>
<header>
<div>
<h2>积分变动记录</h2>
<p v-if="recordsReady">共 {{ recordsTotal }} 条记录</p>
</div>
<el-button
v-if="recordsReady && recordsTotal > 0"
text
@click="openRecordsDialog"
>查看全部</el-button
>
</header>
<AccountSectionState
v-if="recordsError"
class="section-state"
:message="recordsError"
@retry="loadRecentRecords"
/>
<PointRecordList
v-else-if="recordsReady"
class="records-summary"
:records="recentRecords"
empty-text="暂无积分变动记录,兑换或使用创作能力后会在这里显示"
/>
</section>
</div>
<el-dialog
v-model="recordsDialogVisible"
title="积分变动记录"
width="min(820px, calc(100vw - 32px))"
align-center
class="web-dialog records-dialog"
>
<div
v-loading="dialogRecordsLoading"
class="dialog-record-list"
>
<AccountSectionState
v-if="dialogRecordsError"
:message="dialogRecordsError"
@retry="loadDialogRecords()"
/>
<PointRecordList
v-else
:records="dialogRecords"
empty-text="暂无积分变动记录"
/>
</div>
<template #footer>
<el-pagination
v-if="recordsTotal > pageSize"
v-model:current-page="recordsPage"
:page-size="pageSize"
:pager-count="5"
:total="recordsTotal"
layout="prev, pager, next"
@current-change="loadDialogRecords"
/>
</template>
</el-dialog>
<el-dialog
v-model="avatarDialogVisible"
title="确认头像"
width="min(420px, calc(100vw - 32px))"
align-center
class="web-dialog"
@closed="resetAvatarPreview"
>
<div class="avatar-preview">
<img
v-if="avatarPreviewUrl"
:src="avatarPreviewUrl"
alt=""
/>
<p>{{ avatarLimitText }}</p>
</div>
<template #footer>
<el-button
:disabled="avatarUploading"
@click="avatarDialogVisible = false"
>取消</el-button
>
<el-button
type="primary"
:loading="avatarUploading"
@click="uploadAvatar"
>确认上传</el-button
>
</template>
</el-dialog>
<el-dialog
v-model="passwordDialogVisible"
title="修改密码"
width="min(460px, calc(100vw - 32px))"
align-center
class="web-dialog"
@closed="passwordFormRef?.resetFields()"
>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-position="top"
>
<el-form-item
label="原密码"
prop="originalPassword"
><el-input
v-model="passwordForm.originalPassword"
type="password"
show-password
/></el-form-item>
<el-form-item
label="新密码(8~128 位)"
prop="newPassword"
><el-input
v-model="passwordForm.newPassword"
type="password"
show-password
/></el-form-item>
<el-form-item
label="确认新密码"
prop="confirmPassword"
><el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
/></el-form-item>
</el-form>
<template #footer
><el-button @click="passwordDialogVisible = false">取消</el-button
><el-button
type="primary"
:loading="passwordSaving"
@click="changePassword"
>保存</el-button
></template
>
</el-dialog>
</div>
</template>
<style scoped lang="scss" src="./AccountView.styles.scss"></style>
+488
View File
@@ -0,0 +1,488 @@
/* 剧集项目页样式,负责项目上下文栏、剧集卡片、导入区与创建选项的响应式布局。 */
.project-context-bar {
min-height: var(--control-height-lg);
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
}
.project-context-main {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-md);
}
.project-context-main strong {
overflow: hidden;
color: var(--web-text);
font-size: 20px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.back-button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 38px;
color: var(--web-accent-muted);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 9px;
}
.back-button:hover {
color: var(--web-text);
border-color: var(--web-line-emphasis);
}
.model-warning {
color: var(--creative-accent);
border-color: var(--web-line-emphasis);
}
.setup-notice {
margin-bottom: 16px;
padding: 15px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
}
.setup-notice div {
display: flex;
flex-direction: column;
gap: 4px;
}
.setup-notice span {
color: var(--web-text-caption);
font-size: 12px;
}
.episode-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-md);
}
.episode-card {
position: relative;
min-width: 0;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-lg);
cursor: pointer;
}
.episode-card:hover,
.episode-card:focus-visible {
border-color: var(--web-switch-border);
outline: none;
}
.episode-cover {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
display: grid;
place-items: center;
background: var(--web-surface-raised);
border-bottom: 1px solid var(--web-line-strong);
}
.episode-cover video,
.episode-cover :deep(.loading-media-element) {
height: 100%;
width: auto;
max-width: 100%;
display: block;
object-fit: contain;
pointer-events: none;
}
.episode-cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--web-episode-placeholder);
}
.episode-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 7px;
color: var(--web-episode-status-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
font-size: 11px;
}
.episode-status.active {
color: var(--web-project-tooltip-accent);
}
.episode-copy {
min-width: 0;
padding: 10px 12px 5px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.episode-title-row {
position: relative;
z-index: 1;
min-width: 0;
display: flex;
flex: 1;
align-items: center;
gap: 6px;
}
.episode-title-row h2 {
min-width: 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
}
.episode-title-row input {
width: min(240px, 100%);
height: 28px;
padding: 0 8px;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 6px;
outline: none;
}
.episode-title-row button {
width: 24px;
height: 24px;
padding: 0;
display: grid;
place-items: center;
flex: 0 0 24px;
color: var(--web-text-caption);
background: transparent;
border: 0;
border-radius: 5px;
cursor: pointer;
}
.episode-title-row button:hover,
.episode-title-row button:focus-visible {
color: var(--web-accent-dark);
background: var(--web-surface);
outline: 1px solid var(--web-line-emphasis);
}
.episode-delete {
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
flex: 0 0 30px;
color: var(--web-episode-copy);
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.episode-delete:hover {
background: var(--web-episode-copy-hover);
}
.episode-meta {
padding: 0 12px 10px;
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--web-project-tooltip-label);
font-size: 11px;
}
.episode-meta span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-empty {
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 9px;
color: var(--web-text-tertiary);
background: var(--web-surface);
border: 1px dashed var(--web-line-emphasis);
border-radius: var(--radius-lg);
}
.episode-empty strong {
color: var(--web-text-secondary);
font-size: 15px;
}
.creation-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.creation-options button {
min-width: 0;
padding: 18px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
color: var(--web-text-secondary);
text-align: left;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
cursor: pointer;
}
.creation-options button:hover,
.creation-options button:focus-visible {
color: var(--web-accent-muted);
border-color: var(--web-line-emphasis);
outline: none;
}
.creation-options button:disabled {
opacity: 0.55;
cursor: wait;
}
.creation-options button > span:last-child {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.creation-options strong {
font-size: 14px;
}
.creation-options small {
color: var(--web-text-hint);
font-size: 12px;
line-height: 1.6;
}
.creation-option-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 38px;
color: var(--web-accent-dark);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 8px;
}
.import-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.import-tabs :deep(.el-tabs__header) {
margin: 0 0 18px;
}
.import-tabs :deep(.el-tabs__nav-wrap::after) {
height: 1px;
background: var(--web-hover);
}
.import-tabs :deep(.el-tabs__item) {
height: 44px;
color: var(--web-text-note);
font-size: 13px;
}
.import-tabs :deep(.el-tabs__item:hover),
.import-tabs :deep(.el-tabs__item.is-active) {
color: var(--web-accent-dark);
}
.import-tabs :deep(.el-tabs__active-bar) {
height: 2px;
background: var(--web-primary);
}
.import-tab-label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.import-dialog-title {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--web-text);
font-size: 18px;
line-height: 24px;
}
.import-help {
color: var(--web-text-caption);
cursor: help;
}
.import-help:focus-visible {
color: var(--web-accent-dark);
outline: 1px solid var(--web-primary);
outline-offset: 2px;
border-radius: 50%;
}
.import-upload :deep(.el-upload),
.import-upload :deep(.el-upload-dragger) {
width: 100%;
}
.import-upload :deep(.el-upload-dragger) {
min-height: 250px;
padding: 54px 22px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: var(--web-text-secondary);
background: var(--web-surface-soft);
border-color: var(--web-line-strong);
}
.import-upload :deep(.el-upload-dragger:hover) {
border-color: var(--web-switch-border);
}
.import-upload :deep(.el-upload-dragger .el-icon--upload) {
margin: 0 0 14px;
color: var(--web-control-muted);
}
.import-upload :deep(.el-upload-dragger small) {
margin-top: 8px;
color: var(--web-text-disabled);
font-size: 11px;
}
:deep(.import-dialog .el-upload-dragger.is-dragover),
:deep(.import-dialog.page-drag-active .el-upload-dragger) {
color: var(--web-accent-muted) !important;
background: var(--web-surface) !important;
border-color: var(--web-primary) !important;
box-shadow: inset 0 0 0 1px var(--web-focus-inset);
}
:deep(.import-dialog .el-upload-dragger.is-dragover .el-icon--upload),
:deep(.import-dialog.page-drag-active .el-upload-dragger .el-icon--upload) {
color: var(--web-accent-dark);
}
.import-preview > strong {
color: var(--web-text);
}
.import-preview > p {
color: var(--web-text-input-placeholder);
}
.chapter-list {
max-height: 240px;
margin-bottom: 16px;
overflow: auto;
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.chapter-list span {
padding: 9px 12px;
display: flex;
justify-content: space-between;
color: var(--web-text-secondary);
border-bottom: 1px solid var(--web-line-strong);
font-size: 12px;
}
.chapter-list span:last-child {
border-bottom: 0;
}
.chapter-list small {
color: var(--web-text-tertiary);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767px) {
.creation-options {
grid-template-columns: 1fr;
}
.episode-list {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.setup-notice {
align-items: flex-start;
flex-direction: column;
}
.project-context-bar {
align-items: flex-start;
flex-direction: column;
}
.project-context-actions {
width: 100%;
flex-wrap: wrap;
}
.import-upload :deep(.el-upload-dragger) {
min-height: 210px;
padding: 40px 16px;
}
}
+688
View File
@@ -0,0 +1,688 @@
<!-- 短剧项目详情页,负责剧集导入、项目配置和剧集状态管理。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from "vue";
import { Edit, Plus, Setting, Upload } from "@element-plus/icons-vue";
import { ElMessageBox, genFileId, type UploadFile, type UploadInstance, type UploadRawFile } from "element-plus";
import { ArrowLeft, BookOpen, CircleHelp, FilePlus2, FileText, FileUp, LoaderCircle, Pencil, Trash2 } from "@/icons";
import { useRoute, useRouter } from "vue-router";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import ProjectDialog from "@/components/creative/ProjectDialog.vue";
import {
creativeApi,
type CreativeOptions,
type DramaImportPreview,
type DramaParseTask,
type ProjectDetail,
type ProjectPayload,
} from "@/api/creative";
import { apiError } from "@/api/http";
import { premiumDramaPromptTypes, useModelPrompts } from "@/composables/useModelPrompts";
import { useToastStore } from "@/stores/toast";
import { firstVideoFrameURL } from "@/utils/media";
const route = useRoute(),
router = useRouter(),
toast = useToastStore(),
projectId = String(route.params.projectId);
const project = ref<ProjectDetail | null>(null),
options = ref<CreativeOptions>({ styles: [], models: [] }),
tasks = ref<DramaParseTask[]>([]);
const loading = ref(false),
saving = ref(false),
editOpen = ref(false),
modelsOpen = ref(false),
creationOpen = ref(false),
importOpen = ref(false);
const importFile = ref<File>(),
rawText = ref(""),
preview = ref<DramaImportPreview>(),
chaptersPerEpisode = ref(1),
startEpisodeNo = ref(1),
importing = ref(false);
const uploadRef = ref<UploadInstance>();
const editingEpisodeId = ref(""),
editingEpisodeName = ref(""),
editingEpisodeInput = ref<HTMLInputElement>();
const importMode = ref<"file" | "text">("file"),
pageDragActive = ref(false);
const { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt } =
useModelPrompts();
let timer: ReturnType<typeof setInterval> | undefined;
let dragDepth = 0;
const modelReady = computed(() =>
["text", "image", "video"].every((type) => project.value?.model_configs.some((item) => item.model_type === type)),
);
const defaultModelConfigs = [
{ modelType: "text", purpose: "prompt_reverse", settings: { prompt: "" } },
{ modelType: "image", purpose: "image_generation", settings: { resolution: "1k", prompt: "" } },
{ modelType: "video", purpose: "video_generation", settings: { resolution: "480p", prompt: "" } },
] as const;
const taskByEpisode = computed(() => new Map(tasks.value.map((item) => [item.episode_id, item])));
const activeDramaStatuses = new Set(["queued", "running", "retry_wait", "cancel_requested"]);
const hasActive = computed(() => tasks.value.some((item) => activeDramaStatuses.has(item.status)));
const backTarget = computed(() => ({ name: "drama-projects" }));
function activeTaskForEpisode(episodeId: string) {
const task = taskByEpisode.value.get(episodeId);
return task && activeDramaStatuses.has(task.status) ? task : undefined;
}
function parseStatusText(task: DramaParseTask) {
if (task.status === "running") return "剧本解析中";
if (task.status === "retry_wait") return "剧本解析重试等待中";
if (task.status === "cancel_requested") return "剧本解析取消中";
return "剧本解析排队中";
}
/** 将项目缺失的模型配置按弹窗默认值持久化,不覆盖已经保存的模型选择。 */
async function ensureDefaultModelConfigs(loadedProject: ProjectDetail, loadedOptions: CreativeOptions) {
const configuredTypes = new Set(loadedProject.model_configs.map((item) => item.model_type));
const payload: Record<string, { model_id: string; settings: { resolution?: string; prompt: string } }> = {};
for (const config of defaultModelConfigs) {
if (configuredTypes.has(config.modelType)) continue;
const model = loadedOptions.models.find((item) => item.model_type === config.modelType);
if (model) payload[config.purpose] = { model_id: model.id, settings: { ...config.settings } };
}
if (!Object.keys(payload).length) return loadedProject;
await creativeApi.saveModels(projectId, payload);
return creativeApi.project(projectId);
}
/** 加载项目详情、创作选项和任务状态,并补齐首次使用时的默认模型配置。 */
async function load(show = true) {
if (show) loading.value = true;
try {
const [loadedProject, loadedOptions, loadedTasks] = await Promise.all([
creativeApi.project(projectId),
creativeApi.options(),
creativeApi.dramaParseTasks(projectId),
]);
options.value = loadedOptions;
tasks.value = loadedTasks;
project.value = await ensureDefaultModelConfigs(loadedProject, loadedOptions);
const max = Math.max(0, ...(project.value?.episodes || []).map((item) => item.episode_no));
startEpisodeNo.value = max + 1;
} catch (e) {
showError(e, "加载失败");
} finally {
loading.value = false;
}
syncPolling();
}
function syncPolling() {
if (hasActive.value && !timer) timer = setInterval(() => load(false), 3000);
else if (!hasActive.value && timer) {
clearInterval(timer);
timer = undefined;
}
}
async function saveProject(payload: ProjectPayload) {
saving.value = true;
try {
await creativeApi.updateProject(projectId, { ...payload, project_type: "premium_drama", localization: null });
editOpen.value = false;
toast.success("项目已保存");
await load(false);
} catch (e) {
showError(e, "保存失败");
} finally {
saving.value = false;
}
}
async function saveModels(payload: Record<string, unknown>) {
saving.value = true;
try {
await creativeApi.saveModels(projectId, payload);
modelsOpen.value = false;
toast.success("模型配置已保存");
await load(false);
} catch (e) {
showError(e, "保存失败");
} finally {
saving.value = false;
}
}
async function openModels() {
modelsOpen.value = true;
await loadPrompts(premiumDramaPromptTypes);
}
function openImportCreation() {
creationOpen.value = false;
importOpen.value = true;
}
async function createBlankEpisode() {
if (saving.value) return;
saving.value = true;
try {
const episode = await creativeApi.createEpisode(projectId);
creationOpen.value = false;
toast.success(`第 ${episode.episode_no} 集已创建`);
await load(false);
} catch (e) {
showError(e, "创建失败");
} finally {
saving.value = false;
}
}
function setEditingEpisodeInput(element: Element | ComponentPublicInstance | null) {
editingEpisodeInput.value = element instanceof HTMLInputElement ? element : undefined;
}
async function startEpisodeRename(episode: ProjectDetail["episodes"][number]) {
editingEpisodeId.value = episode.id;
editingEpisodeName.value = episode.name || `第${episode.episode_no}集`;
await nextTick();
editingEpisodeInput.value?.focus();
editingEpisodeInput.value?.select();
}
function cancelEpisodeRename() {
editingEpisodeId.value = "";
editingEpisodeName.value = "";
}
function finishEpisodeRename() {
editingEpisodeInput.value?.blur();
}
async function saveEpisodeName(episode: ProjectDetail["episodes"][number]) {
if (editingEpisodeId.value !== episode.id) return;
const name = editingEpisodeName.value.trim();
cancelEpisodeRename();
if (!name) {
toast.warning("剧集名称不能为空");
return;
}
const duplicate = project.value?.episodes.some(
(item) => item.id !== episode.id && item.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase(),
);
if (duplicate) {
toast.warning("同一项目下不可有同名剧集");
return;
}
if (name === episode.name) return;
const originalName = episode.name;
episode.name = name;
try {
await creativeApi.updateEpisodeName(projectId, episode.id, name);
toast.success("剧集名称已保存");
} catch (e) {
episode.name = originalName;
showError(e, "保存剧集名称失败");
}
}
async function removeEpisode(id: string, no: number) {
try {
await ElMessageBox.confirm(`确定删除第 ${no} 集吗?`, `删除剧集`, {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteEpisode(projectId, id);
toast.success("剧集已删除");
await load(false);
} catch (e) {
if (e !== "cancel" && e !== "close") showError(e, "删除失败");
}
}
async function previewImport() {
if (!importFile.value && !rawText.value.trim()) {
toast.warning("请选择文件或粘贴小说正文");
return;
}
importing.value = true;
try {
preview.value = await creativeApi.previewDramaImport(projectId, {
file: importFile.value,
rawText: rawText.value.trim(),
});
if (!preview.value.has_chapters) toast.info("未识别到章节标题,请确认是否将全部内容作为一集导入。");
} catch (e) {
showError(e, "读取小说失败");
} finally {
importing.value = false;
}
}
async function confirmImport() {
if (!preview.value) return;
if (!preview.value.has_chapters) {
try {
await ElMessageBox.confirm("未识别到章节标题,是否将全部内容作为一集导入?", "确认单集导入", {
type: "warning",
confirmButtonText: "作为一集导入",
cancelButtonText: "返回",
});
} catch {
return;
}
}
importing.value = true;
try {
await creativeApi.confirmDramaImport(projectId, {
import_token: preview.value.import_token,
chapters_per_episode: chaptersPerEpisode.value,
start_episode_no: startEpisodeNo.value,
treat_as_single_episode: !preview.value.has_chapters,
});
toast.success("小说已导入");
closeImport();
await load(false);
} catch (e) {
showError(e, "导入失败");
} finally {
importing.value = false;
}
}
function validateImportFile(file: File) {
const name = file.name.trim().toLowerCase();
if (![".txt", ".doc", ".docx"].some((extension) => name.endsWith(extension))) {
importFile.value = undefined;
preview.value = undefined;
uploadRef.value?.clearFiles();
toast.warning("仅支持TXT、DOC、DOCX文件");
return false;
}
if (file.size > 3 * 1024 * 1024) {
importFile.value = undefined;
preview.value = undefined;
uploadRef.value?.clearFiles();
toast.warning("小说文件不能超过3MB");
return false;
}
return true;
}
function selectFile(file: UploadFile) {
if (!file.raw || !validateImportFile(file.raw)) return;
importMode.value = "file";
importFile.value = file.raw;
rawText.value = "";
preview.value = undefined;
}
function useRawText() {
if (!rawText.value) return;
importFile.value = undefined;
uploadRef.value?.clearFiles();
preview.value = undefined;
}
function closeImport() {
importOpen.value = false;
importMode.value = "file";
importFile.value = undefined;
rawText.value = "";
preview.value = undefined;
resetPageDrag();
uploadRef.value?.clearFiles();
}
function isFileDrag(event: DragEvent) {
return Array.from(event.dataTransfer?.types || []).includes("Files");
}
function handlePageDragEnter(event: DragEvent) {
if (!importOpen.value || preview.value || !isFileDrag(event)) return;
event.preventDefault();
dragDepth += 1;
importMode.value = "file";
pageDragActive.value = true;
}
function handlePageDragOver(event: DragEvent) {
if (!importOpen.value || preview.value || !isFileDrag(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
pageDragActive.value = true;
}
function handlePageDragLeave(event: DragEvent) {
if (!pageDragActive.value) return;
if (!event.relatedTarget) {
resetPageDrag();
return;
}
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) pageDragActive.value = false;
}
async function handlePageDrop(event: DragEvent) {
if (!importOpen.value || preview.value || !isFileDrag(event)) return;
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer?.files?.[0];
resetPageDrag();
if (!file || !validateImportFile(file)) return;
importMode.value = "file";
await nextTick();
const rawFile = file as UploadRawFile;
rawFile.uid = genFileId();
uploadRef.value?.clearFiles();
uploadRef.value?.handleStart(rawFile);
}
function resetPageDrag() {
dragDepth = 0;
pageDragActive.value = false;
}
async function openEpisode(id: string) {
await router.push({
path: `/drama-projects/${projectId}/episodes/${id}`,
query: { from: String(route.query.from || "drama-projects") },
});
}
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
watch(importMode, (mode) => {
preview.value = undefined;
if (mode === "file") rawText.value = "";
else {
importFile.value = undefined;
uploadRef.value?.clearFiles();
}
});
onMounted(() => {
load();
document.addEventListener("dragenter", handlePageDragEnter, true);
document.addEventListener("dragover", handlePageDragOver, true);
document.addEventListener("dragleave", handlePageDragLeave, true);
document.addEventListener("drop", handlePageDrop, true);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
document.removeEventListener("dragenter", handlePageDragEnter, true);
document.removeEventListener("dragover", handlePageDragOver, true);
document.removeEventListener("dragleave", handlePageDragLeave, true);
document.removeEventListener("drop", handlePageDrop, true);
});
</script>
<template>
<CreativeShell page-title="短剧创作">
<section class="project-context-bar">
<div class="project-context-main">
<RouterLink
class="back-button"
:to="backTarget"
title="返回"
>
<ArrowLeft :size="18" />
</RouterLink>
<strong>{{ project?.name || "短剧创作项目" }}</strong>
</div>
<div class="project-context-actions creative-page-actions">
<el-button
:icon="Edit"
@click="editOpen = true"
>项目设置</el-button
><el-button
:icon="Setting"
:class="{ 'model-warning': !modelReady }"
@click="openModels"
>模型配置</el-button
><el-button
type="primary"
:icon="Plus"
@click="creationOpen = true"
>新建剧集</el-button
>
</div>
</section>
<div v-loading="loading">
<section
v-if="!modelReady && project"
class="setup-notice"
>
<div>
<strong>解析和生成前需要完成模型配置</strong
><span>分别选择文本、图像和视频模型,短剧创作与剧本反推配置相互独立。</span>
</div>
<el-button
type="primary"
@click="openModels"
>立即配置</el-button
>
</section>
<section class="episode-list">
<article
v-for="episode in project?.episodes || []"
:key="episode.id"
class="episode-card"
@click="openEpisode(episode.id)"
>
<div class="episode-cover">
<LoadingMedia
v-if="episode.cover_video_url"
type="video"
:src="firstVideoFrameURL(episode.cover_video_url)"
muted
playsinline
preload="metadata"
disablepictureinpicture
disableremoteplayback
controlslist="nodownload noplaybackrate noremoteplayback"
/>
<span
v-else
class="episode-cover-placeholder"
><BookOpen :size="30"
/></span>
<span
class="episode-status"
:class="{ active: activeTaskForEpisode(episode.id) }"
>
{{
activeTaskForEpisode(episode.id)
? parseStatusText(activeTaskForEpisode(episode.id)!)
: `第 ${episode.episode_no} 集`
}}
</span>
</div>
<div class="episode-copy">
<div class="episode-title-row">
<input
v-if="editingEpisodeId === episode.id"
:ref="setEditingEpisodeInput"
v-model="editingEpisodeName"
maxlength="160"
@click.stop
@blur="saveEpisodeName(episode)"
/>
<template v-else>
<h2>{{ episode.name || `第 ${episode.episode_no} 集` }}</h2>
<button
type="button"
title="编辑剧集名称"
@click.stop="startEpisodeRename(episode)"
>
<Pencil :size="14" />
</button>
</template>
</div>
<button
class="episode-delete"
type="button"
title="删除"
@click.stop="removeEpisode(episode.id, episode.episode_no)"
>
<Trash2 :size="15" />
</button>
</div>
<div class="episode-meta">
<span>{{ episode.storyboard_count || 0 }} 个分镜</span>
<span>{{ episode.analysis_message || (episode.has_text_source ? "原文已就绪" : "待添加原文") }}</span>
</div>
</article>
<div
v-if="!project?.episodes.length && !loading"
class="episode-empty"
>
<BookOpen :size="36" />
<strong>暂无剧集</strong>
<el-button
type="primary"
:icon="Plus"
@click="creationOpen = true"
>新建剧集</el-button
>
</div>
</section>
</div>
<ProjectDialog
v-model="editOpen"
:options="options"
:project="project"
:saving="saving"
project-type="premium_drama"
@save="saveProject"
/>
<ModelConfigDialog
v-if="project"
v-model="modelsOpen"
:options="options"
:configs="project.model_configs"
:prompts="prompts"
:prompt-selections="promptSelections"
:saving="saving"
project-type="premium_drama"
@save="saveModels"
@prompt-select="selectPrompt"
@prompt-create="createPrompt"
@prompt-update="updatePrompt"
@prompt-delete="deletePrompt"
/>
<el-dialog
v-model="creationOpen"
title="新建剧集"
width="min(560px,calc(100vw - 28px))"
class="creative-dialog"
>
<div class="creation-options">
<button
type="button"
@click="openImportCreation"
>
<span class="creation-option-icon"><FileUp :size="22" /></span>
<span><strong>导入剧本</strong><small>上传文件或粘贴正文,按章节批量创建剧集</small></span>
</button>
<button
type="button"
:disabled="saving"
@click="createBlankEpisode"
>
<span class="creation-option-icon"><FilePlus2 :size="22" /></span>
<span
><strong>{{ saving ? "正在创建" : "创建空白剧集" }}</strong
><small>自动创建下一集,稍后填写剧集原文</small></span
>
</button>
</div>
</el-dialog>
<el-dialog
:model-value="importOpen"
title="导入剧本"
width="min(700px,calc(100vw - 28px))"
:class="['creative-dialog', 'import-dialog', { 'page-drag-active': pageDragActive }]"
@close="closeImport"
>
<template #header>
<span class="import-dialog-title"
>导入剧本
<el-tooltip placement="top">
<template #content
>系统会按章节标题识别并切分内容,可设置每集包含的章节数。<br />未识别到有效章节标题时,可将全文作为一集导入。</template
>
<CircleHelp
class="import-help"
:size="16"
title="查看章节切分说明"
/>
</el-tooltip>
</span>
</template>
<el-tabs
v-if="!preview"
v-model="importMode"
class="import-tabs"
stretch
>
<el-tab-pane name="file"
><template #label
><span class="import-tab-label"
><el-icon> <Upload /> </el-icon>上传文件</span
></template
>
<div class="import-form">
<el-upload
ref="uploadRef"
class="import-upload"
drag
:auto-upload="false"
:limit="1"
accept=".txt,.doc,.docx"
:on-change="selectFile"
><el-icon class="el-icon--upload">
<Upload />
</el-icon>
<div>{{ pageDragActive ? "松开添加文件" : "拖入或选择TXT、DOC、DOCX文件" }}</div>
<small>单个文件最大3MB</small>
</el-upload>
</div>
</el-tab-pane>
<el-tab-pane name="text"
><template #label
><span class="import-tab-label"> <FileText :size="15" />粘贴文本 </span></template
>
<div class="import-form">
<el-input
v-model="rawText"
type="textarea"
:rows="12"
resize="none"
maxlength="3145728"
placeholder="粘贴小说正文"
@input="useRawText"
/>
</div>
</el-tab-pane>
</el-tabs>
<div
v-else
class="import-preview"
>
<strong>{{ preview.has_chapters ? `识别到 ${preview.chapters.length} 个章节` : "未识别到章节标题" }}</strong>
<p>共 {{ preview.total_characters.toLocaleString() }} 字</p>
<div
v-if="preview.has_chapters"
class="chapter-list"
>
<span
v-for="(chapter, index) in preview.chapters"
:key="index"
>{{ chapter.title }}<small>{{ chapter.char_count }} 字</small></span
>
</div>
<el-form label-position="top"
><el-form-item
v-if="preview.has_chapters"
label="每集包含章节数"
><el-input-number
v-model="chaptersPerEpisode"
:min="1"
:max="20" /></el-form-item
><el-form-item label="起始集数"
><el-input-number
v-model="startEpisodeNo"
:min="1"
:max="9999" /></el-form-item
></el-form>
</div>
<template #footer
><el-button
v-if="preview"
@click="preview = undefined"
>返回</el-button
><el-button @click="closeImport">取消</el-button
><el-button
type="primary"
:loading="importing"
@click="preview ? confirmImport() : previewImport()"
>{{ preview ? "确认导入" : "读取并预览" }}</el-button
></template
>
</el-dialog>
</CreativeShell>
</template>
<style scoped lang="scss" src="./DramaProjectView.styles.scss"></style>
@@ -0,0 +1,669 @@
/* 剧集工作台样式,负责分镜编辑表格、配置面板和生成状态的响应式布局。 */
.drama-workbench-shell {
height: 100dvh;
min-height: 0 !important;
padding-bottom: 12px !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.drama-workbench-shell :deep(.web-header) {
flex: 0 0 72px;
}
.drama-workbench-shell :deep(.creative-shell__content) {
height: auto;
min-height: 0;
max-width: none;
margin-top: 14px;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.drama-workbench-shell :deep(.creative-shell__scroll) {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
padding-bottom: 0;
}
.workbench-context-bar {
min-height: 38px;
margin-bottom: var(--space-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
flex: 0 0 auto;
}
.workbench-context-main {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-md);
}
.workbench-context-main strong {
min-width: 0;
overflow: hidden;
color: var(--web-text);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.back-button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 38px;
color: var(--web-accent-muted);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 9px;
}
.back-button:hover,
.back-button:focus-visible {
color: var(--web-text);
border-color: var(--web-switch-border);
outline: 2px solid var(--web-focus-ring);
}
.editor-layout {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
flex: 1;
gap: 12px;
overflow: hidden;
}
.storyboard-workspace,
.asset-rail {
min-width: 0;
min-height: 0;
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.storyboard-workspace {
display: flex;
flex-direction: column;
}
.workspace-toolbar {
height: 48px;
padding: 0 12px 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 48px;
border-bottom: 1px solid var(--web-line-strong);
}
.workspace-toolbar > div {
display: flex;
align-items: baseline;
gap: 9px;
}
.workspace-toolbar strong {
font-size: 14px;
}
.workspace-toolbar span {
color: var(--web-accent-soft);
font-size: 10px;
}
.workspace-toolbar button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 6px;
cursor: pointer;
}
.workspace-toolbar button {
height: 32px;
padding: 0 10px;
font-size: 11px;
}
.workspace-toolbar button:hover {
color: var(--web-text-strong);
background: var(--web-primary);
border-color: var(--web-primary);
}
.table-scroll {
min-width: 0;
min-height: 0;
flex: 1;
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track-panel);
scrollbar-width: thin;
}
.storyboard-table {
width: 100%;
min-width: 1134px;
border-spacing: 0;
border-collapse: separate;
table-layout: fixed;
}
.storyboard-table th {
position: sticky;
z-index: 8;
top: 0;
height: 42px;
padding: 0 10px;
color: var(--web-text-caption);
background: var(--web-surface-soft);
border-bottom: 1px solid var(--web-line-strong);
border-right: 1px solid var(--web-line-strong);
box-shadow: 0 1px 0 var(--web-line-strong);
font-size: 10px;
font-weight: 600;
text-align: left;
}
.storyboard-table th:last-child {
border-right: 0;
}
.storyboard-table th:not(.script-column) {
text-align: center;
}
.storyboard-table th.script-column,
.storyboard-table th.entity-column {
font-size: 11px;
}
.sequence-column {
width: 54px;
}
.script-column {
width: 380px;
}
.entity-column {
width: 140px;
}
.media-column {
width: 140px;
}
.storyboard-row > td {
height: 252px;
padding: 10px;
vertical-align: top;
background: var(--web-surface-soft);
border-bottom: 1px solid var(--web-line-strong);
border-right: 1px solid var(--web-line-strong);
transition: background-color 0.16s;
}
.storyboard-row > td:last-child {
border-right: 0;
}
.storyboard-row:hover > td {
background: var(--web-surface-soft);
}
.storyboard-row.active > td {
background: var(--web-surface);
}
.insert-row td {
height: 13px;
padding: 0;
background: var(--web-surface-soft);
}
.insert-row button {
width: 100%;
height: 13px;
padding: 0;
display: flex;
align-items: center;
color: var(--web-accent-soft);
background: transparent;
border: 0;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.insert-row:hover button,
.insert-row button:focus-visible {
opacity: 1;
}
.insert-row button span {
height: 1px;
flex: 1;
background: var(--web-danger-soft);
}
.insert-row button em {
padding: 0 8px;
display: flex;
align-items: center;
gap: 4px;
background: var(--web-surface-soft);
font-size: 9px;
font-style: normal;
white-space: nowrap;
}
.sequence-cell {
text-align: center;
}
.sequence-cell > b {
width: 24px;
height: 24px;
margin: 2px auto 10px;
display: grid;
place-items: center;
color: var(--web-text-strong);
background: var(--web-primary);
border-radius: 5px;
font-size: 11px;
}
.script-cell {
height: 252px;
}
.script-cell:focus-within {
position: relative;
z-index: 10;
}
.script-cell :deep(.asset-prompt-editor) {
height: 230px;
min-height: 230px;
max-height: 230px;
overflow: visible;
}
.script-cell :deep(.prompt-content) {
height: 100%;
min-height: 0;
padding: 10px;
overflow-y: auto;
background: var(--web-surface-soft);
border-radius: 4px;
font-size: 12px;
line-height: 1.65;
}
.script-content-display {
cursor: text;
}
.script-content-display :deep(.prompt-content) {
color: var(--web-text-secondary);
}
.script-content-display.empty :deep(.prompt-content) {
display: grid;
place-items: center;
color: var(--web-text-context);
}
.script-content-display.locked :deep(.prompt-content) {
cursor: default;
}
.entity-cell {
padding: 10px 6px !important;
}
.entity-chip {
position: relative;
margin-bottom: 6px;
overflow: hidden;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
transition:
border-color 0.16s,
background-color 0.16s;
}
.entity-chip:hover,
.entity-chip:focus-within {
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.entity-chip-main {
width: 100%;
padding: 5px 32px 5px 5px;
display: flex;
align-items: center;
gap: 6px;
color: inherit;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
}
.entity-chip-main:focus-visible {
outline: 2px solid var(--web-focus-ring-strong);
outline-offset: -2px;
}
.entity-chip img,
.entity-chip :deep(.loading-media-element),
.entity-chip-main > span {
width: 28px;
height: 28px;
display: grid;
place-items: center;
flex: 0 0 28px;
object-fit: cover;
color: var(--web-accent-dark);
background: var(--web-hover);
border-radius: 4px;
font-size: 11px;
}
.entity-chip small {
min-width: 0;
overflow: hidden;
color: var(--web-text-secondary);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-audio-icon {
flex: 0 0 auto;
color: var(--web-accent-dark);
}
.entity-remove {
position: absolute;
top: 50%;
right: 5px;
width: 24px;
height: 24px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-accent-dark);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: 5px;
cursor: pointer;
opacity: 0;
transform: translateY(-50%);
transition:
opacity 0.16s,
border-color 0.16s,
color 0.16s;
}
.entity-chip:hover .entity-remove,
.entity-chip:focus-within .entity-remove {
opacity: 1;
}
.entity-remove:hover:not(:disabled),
.entity-remove:focus-visible {
color: var(--web-accent-dark);
border-color: var(--web-line-critical);
}
.entity-remove:disabled {
color: var(--web-text-critical-muted);
border-color: var(--web-line-emphasis);
cursor: not-allowed;
}
.cell-empty {
height: 100%;
display: grid;
place-items: center;
color: var(--web-text-neutral);
font-size: 10px;
}
@media (hover: none) {
.entity-remove {
opacity: 1;
}
}
.media-cell {
padding: 10px 8px !important;
position: relative;
}
.media-focus-target {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
color: var(--web-accent-muted);
cursor: pointer;
}
.storyboard-image-error {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
z-index: 3;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
overflow: hidden;
color: var(--web-danger);
background: var(--web-danger-soft);
border: 1px solid var(--web-danger);
border-radius: 6px;
font-size: 9px;
line-height: 1.4;
cursor: pointer;
}
.storyboard-image-error span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-focus-target > img,
.media-focus-target > video,
.media-focus-target > :deep(.loading-media-element),
.media-focus-target > .media-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
object-fit: cover;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
}
.media-focus-target > img,
.media-focus-target > video,
.media-focus-target > :deep(.loading-media-element) {
pointer-events: none;
}
.media-empty {
flex-direction: column;
gap: 6px;
color: var(--web-text-panel-muted);
}
.media-empty small {
font-size: 9px;
}
.media-focus-target:hover > img,
.media-focus-target:hover > video,
.media-focus-target:hover > :deep(.loading-media-element),
.media-focus-target:hover > .media-empty,
.media-focus-target:focus-visible > img,
.media-focus-target:focus-visible > video,
.media-focus-target:focus-visible > :deep(.loading-media-element),
.media-focus-target:focus-visible > .media-empty {
border-color: var(--web-switch-border);
}
.media-focus-target:focus-visible {
outline: 2px solid var(--web-focus-ring-active);
outline-offset: 2px;
}
.row-actions {
display: flex;
align-items: center;
flex-direction: column;
gap: 6px;
}
.row-actions button {
width: 26px;
height: 26px;
padding: 0;
display: grid;
place-items: center;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
cursor: pointer;
opacity: 1;
transition:
opacity 0.15s,
border-color 0.15s,
color 0.15s;
}
.row-actions button:focus-visible,
.storyboard-row.locked .row-actions .lock-action {
opacity: 1;
}
.row-actions button:hover:not(:disabled) {
color: var(--web-accent-muted);
border-color: var(--web-switch-border);
}
.row-actions button.lock-action.active {
color: var(--web-accent-dark);
border-color: var(--web-switch-border);
}
.row-actions button.danger {
color: var(--web-accent-dark);
}
.row-actions button.danger:hover:not(:disabled) {
color: var(--web-accent-dark);
border-color: var(--web-line-emphasis);
}
.row-actions button:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.empty-storyboards {
min-height: 360px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--web-accent-soft);
background: transparent;
border: 0;
cursor: pointer;
}
.empty-storyboards strong {
color: var(--web-text-secondary);
}
.empty-storyboards span {
color: var(--web-text-tertiary);
font-size: 11px;
}
.parse-loading {
cursor: default;
}
.parse-loading > svg {
color: var(--web-accent-dark);
}
.parse-failed {
cursor: default;
}
.parse-failed > svg {
color: var(--web-accent-dark);
}
.parse-failed span {
max-width: min(560px, 80%);
color: var(--web-accent-dark);
font-size: 12px;
line-height: 1.6;
text-align: center;
}
.asset-rail {
display: flex;
}
.asset-rail :deep(.asset-panel) {
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
}
.parse-action.is-cancel {
color: var(--web-accent-dark);
background: var(--web-surface);
border-color: var(--web-line-emphasis);
}
.parse-action {
margin-left: var(--space-sm) !important;
}
.parse-action.is-cancel:hover,
.parse-action.is-cancel:focus-visible {
color: var(--web-text);
background: var(--web-hover);
border-color: var(--web-line-critical);
}
.source-dialog-content {
min-height: 430px;
}
.source-meta {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.source-meta span {
color: var(--web-accent-dark);
font-size: 11px;
}
.source-meta small {
color: var(--web-text-tertiary);
font-size: 10px;
}
.source-dialog :deep(.el-textarea__inner) {
line-height: 1.7;
scrollbar-color: var(--web-scrollbar-thumb) var(--web-scrollbar-track);
scrollbar-width: thin;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1100px) {
.editor-layout {
grid-template-columns: minmax(0, 1fr) 320px;
}
}
@media (max-width: 960px) {
.workbench-context-bar {
align-items: stretch;
flex-direction: column;
}
.workbench-context-actions {
width: 100%;
flex-wrap: wrap;
}
.parse-action {
margin-left: 0 !important;
}
}
@media (max-width: 820px) {
.drama-workbench-shell :deep(.creative-shell__content) {
margin-top: 10px;
}
.editor-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(340px, 1.25fr) minmax(250px, 0.75fr);
}
}
@media (max-width: 600px) {
.drama-workbench-shell :deep(.web-header) {
flex-basis: 68px;
}
}
@media (max-width: 520px) {
.workbench-context-actions :deep(.el-button) {
width: calc(50% - var(--space-xs));
flex: 1 1 calc(50% - var(--space-xs));
}
.workspace-toolbar {
align-items: flex-start;
height: auto;
padding: 10px;
flex-direction: column;
gap: 8px;
}
.workspace-toolbar button {
width: 100%;
}
.storyboard-table {
min-width: 1180px;
}
}
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
<!-- 用户登录页,负责凭据提交、登录状态反馈和成功跳转。 -->
<script setup lang="ts">
import { reactive, ref } from "vue";
import { Lock, User } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import { useRoute, useRouter } from "vue-router";
import logoUrl from "@/assets/logo.png";
import { apiError } from "@/api/http";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
const auth = useAuthStore();
const toast = useToastStore();
const route = useRoute();
const router = useRouter();
const formRef = ref<FormInstance>();
const form = reactive({ account: "", password: "" });
const rules: FormRules = {
account: [{ required: true, message: "请输入账号", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
};
/** 校验并提交登录凭据,成功后跳转至目标页面。 */
async function submit() {
if (auth.loading) return;
if (!(await formRef.value?.validate().catch(() => false))) return;
try {
await auth.login(form.account, form.password);
toast.success("登录成功");
const redirect = String(route.query.redirect || "/drama-projects");
await router.replace(redirect.startsWith("/") ? redirect : "/drama-projects");
} catch (error) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, "登录失败");
}
}
</script>
<template>
<main class="login-view web-page">
<section class="login-card">
<header class="login-card__brand">
<span
><img
:src="logoUrl"
alt=""
/></span>
<div>
<strong>剧游AI</strong>
</div>
</header>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
size="large"
@submit.prevent="submit"
>
<el-form-item
label="账号"
prop="account"
>
<el-input
v-model.trim="form.account"
autocomplete="username"
placeholder="请输入账号"
>
<template #prefix
><el-icon><User /></el-icon
></template>
</el-input>
</el-form-item>
<el-form-item
label="密码"
prop="password"
>
<el-input
v-model="form.password"
type="password"
autocomplete="current-password"
show-password
placeholder="请输入密码"
>
<template #prefix
><el-icon><Lock /></el-icon
></template>
</el-input>
</el-form-item>
<el-button
class="login-card__submit"
type="primary"
native-type="submit"
:loading="auth.loading"
>
登录
</el-button>
</el-form>
</section>
</main>
</template>
<style scoped lang="scss">
.login-view {
min-height: 100dvh;
padding: var(--page-gutter);
display: grid;
place-items: center;
background: var(--web-bg);
}
.login-card {
width: min(420px, 100%);
padding: var(--space-2xl);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
}
.login-card__brand {
margin-bottom: var(--space-xl);
display: flex;
align-items: center;
gap: 12px;
}
.login-card__brand > span {
width: var(--control-height-lg);
height: var(--control-height-lg);
display: grid;
place-items: center;
overflow: hidden;
background: var(--web-raised);
border: 1px solid var(--web-line);
border-radius: var(--radius-sm);
}
.login-card__brand img {
width: 34px;
height: 34px;
object-fit: contain;
}
.login-card__brand strong {
font-size: 18px;
font-weight: 650;
}
.login-card :deep(.el-form-item__label) {
color: var(--web-muted);
}
.login-card :deep(.el-input__wrapper) {
min-height: var(--control-height-lg);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-sm);
box-shadow: none;
}
.login-card :deep(.el-input__wrapper:hover) {
border-color: var(--web-line-emphasis);
}
.login-card :deep(.el-input__wrapper.is-focus) {
border-color: var(--web-primary);
box-shadow: 0 0 0 2px var(--web-dialog-focus-ring);
}
.login-card :deep(.el-input__inner) {
color: var(--web-text);
}
.login-card__submit {
width: 100%;
min-height: var(--control-height-lg);
margin-top: var(--space-sm);
color: var(--web-text-strong);
background: var(--web-primary);
border-color: var(--web-primary);
font-weight: 650;
}
.login-card__submit:hover {
background: var(--web-primary-hover);
border-color: var(--web-primary-hover);
}
@media (max-width: 767px) {
.login-view {
padding: var(--page-gutter-mobile);
}
.login-card {
padding: var(--space-xl);
}
}
</style>
+987
View File
@@ -0,0 +1,987 @@
<!-- 图片生成生图工具,负责临时配置、参考图提及、生成历史和图片预览。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { ElMessageBox } from "element-plus";
import { Image, LoaderCircle, Sparkles, Trash2, Upload, X } from "@/icons";
import CreativeSelect from "@/components/creative/CreativeSelect.vue";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import AssetPromptEditor from "@/components/creative/AssetPromptEditor.vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import ProductImageViewer, { type ProductViewerImage } from "@/components/creative/ProductImageViewer.vue";
import { creativeApi, type CreativeOptions, type ProjectAsset } from "@/api/creative";
import { productImageApi, type ProductImageGeneration } from "@/api/productImage";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { formatDateTime } from "@/utils/datetime";
interface LocalReference {
id: string;
file: File;
name: string;
url: string;
}
const toast = useToastStore();
const options = ref<CreativeOptions>({ styles: [], models: [], prices: [] });
const prompt = ref("");
const modelId = ref("");
const aspectRatio = ref("9:21");
const resolution = ref("1k");
const references = ref<LocalReference[]>([]);
const generations = ref<ProductImageGeneration[]>([]);
const loading = ref(true);
const generating = ref(false);
const previewIndex = ref(-1);
const referenceBusy = ref(false);
const fileInput = ref<HTMLInputElement>();
let pollingTimer: number | undefined;
const imageModels = computed(() =>
options.value.models
.filter((model) => model.model_type === "image")
.map((model) => ({
label: model.channel_name ? `【${model.channel_name}】${model.name}` : model.name,
value: model.id,
})),
);
const ratioOptions = ["1:1", "3:4", "4:3", "9:16", "9:21", "16:9"].map((value) => ({ label: value, value }));
const resolutionOptions = ["1k", "2k", "4k"].map((value) => ({ label: value, value }));
const completedImages = computed(() => generations.value.filter((item) => item.public_url));
const viewerImages = computed<ProductViewerImage[]>(() =>
[
...completedImages.value.map((item) => ({
id: item.task_id,
mediaId: item.media_asset_id,
url: item.public_url,
prompt: item.prompt,
})),
...references.value.map((reference) => ({
id: reference.id,
url: reference.url,
prompt: reference.name,
filename: reference.name,
})),
],
);
const previewItem = computed(() => viewerImages.value[previewIndex.value]);
const previewOpen = computed({
get: () => previewIndex.value >= 0,
set: (value: boolean) => { if (!value) closePreview(); },
});
const hasActiveTask = computed(() => generations.value.some((item) => activeStatuses.has(item.status)));
const promptLength = computed(() => [...prompt.value].length);
const price = computed(() => {
const selected = options.value.prices?.find(
(item) => item.model_id === modelId.value && item.price_key.toLowerCase() === resolution.value.toLowerCase(),
);
return selected ? Number(selected.price).toFixed(2) : "";
});
const activeStatuses = new Set([
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
]);
const referenceAssets = computed<ProjectAsset[]>(() =>
references.value.map((reference) => ({
id: reference.id,
project_id: "product-images",
asset_type: "custom",
name: reference.name,
image_url: reference.url,
user_edited: false,
})),
);
/** 加载可用模型和用户的图片生成历史;提示词与参考图刻意从空状态开始。 */
async function load() {
loading.value = true;
try {
const [creativeOptions, history] = await Promise.all([creativeApi.options(), productImageApi.generations()]);
options.value = creativeOptions;
generations.value = history;
if (!modelId.value) modelId.value = imageModels.value[0]?.value || "";
syncPolling();
} catch (error) {
showError(error, "加载图片生成失败");
} finally {
loading.value = false;
}
}
/** 打开本地文件选择器;文件只保留在当前页面内存中。 */
function chooseReference() {
if (!generating.value && references.value.length < 4) fileInput.value?.click();
}
/** 接收参考图并使用文件名作为 @ 提及候选名称。 */
function addReferences(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = "";
if (!files.length) return;
const existingNames = new Set(references.value.map((item) => item.name));
for (const file of files) {
if (references.value.length >= 4) break;
if (!file.type.startsWith("image/")) {
toast.warning("仅支持上传图片");
continue;
}
if (existingNames.has(file.name)) {
toast.warning(`参考图名称重复:${file.name}`);
continue;
}
existingNames.add(file.name);
references.value.push({
id: `${Date.now()}-${Math.random()}`,
file,
name: file.name,
url: URL.createObjectURL(file),
});
}
}
/** 删除临时参考图,并移除提示词中对应的文件名提及。 */
function removeReference(id: string) {
const index = references.value.findIndex((item) => item.id === id);
if (index < 0) return;
const [removed] = references.value.splice(index, 1);
URL.revokeObjectURL(removed.url);
prompt.value = prompt.value.replaceAll(`@${removed.name}`, "");
}
/** 提交当前页面配置和参考图;任务受理后立即释放提交状态,并继续刷新生成历史。 */
async function generate() {
if (!prompt.value.trim()) {
toast.warning("请输入图片生成提示词");
return;
}
if (promptLength.value > 5000) {
toast.warning("提示词不能超过 5000 个字符");
return;
}
if (!modelId.value) {
toast.warning("请选择图片模型");
return;
}
generating.value = true;
try {
await productImageApi.generate({
prompt: prompt.value,
model_id: modelId.value,
aspect_ratio: aspectRatio.value,
resolution: resolution.value,
references: references.value.map((item) => item.file),
});
generating.value = false;
toast.success("图片生成任务已提交");
await refreshGenerations();
} catch (error) {
showError(error, "提交生成失败");
} finally {
generating.value = false;
}
}
/** 刷新生成历史,避免覆盖当前尚未提交的输入。 */
async function refreshGenerations() {
try {
generations.value = await productImageApi.generations();
syncPolling();
} catch (error) {
stopPolling();
showError(error, "刷新效果图失败");
}
}
/** 根据任务状态启动或停止轮询。 */
function syncPolling() {
if (hasActiveTask.value && pollingTimer === undefined) pollingTimer = window.setInterval(refreshGenerations, 4000);
else if (!hasActiveTask.value) stopPolling();
}
/** 停止图片生成任务轮询。 */
function stopPolling() {
if (pollingTimer !== undefined) window.clearInterval(pollingTimer);
pollingTimer = undefined;
}
/** 删除一条历史结果并让后端同步清理 COS 对象。 */
async function removeGeneration(item: ProductImageGeneration) {
try {
const target = item.public_url ? "效果图" : "生成记录";
await ElMessageBox.confirm(`删除后无法恢复,确定删除这条${target}吗?`, `删除${target}`, {
confirmButtonText: "删除",
cancelButtonText: "取消",
type: "warning",
});
await productImageApi.removeGeneration(item.task_id);
generations.value = generations.value.filter((candidate) => candidate.task_id !== item.task_id);
closePreview();
toast.success("效果图已删除");
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除效果图失败");
}
}
/** 打开指定效果图的全屏预览。 */
function openPreview(item: ProductImageGeneration) {
const index = viewerImages.value.findIndex((candidate) => candidate.id === item.task_id);
if (index >= 0) {
previewIndex.value = index;
}
}
/** 打开指定参考图的大图查看器。 */
function openReferencePreview(reference: LocalReference) {
const index = viewerImages.value.findIndex((candidate) => candidate.id === reference.id);
if (index >= 0) previewIndex.value = index;
}
/** 关闭全屏预览并重置提示词面板。 */
function closePreview() {
previewIndex.value = -1;
}
/** 将大图查看器生成的文件加入当前参考图列表。 */
function addReferenceFile(file: File) {
if (references.value.length >= 4) { toast.warning("参考图最多 4 张"); return; }
const name = file.name || `参考图-${Date.now()}.png`;
references.value.push({ id: `${Date.now()}-${Math.random()}`, file, name, url: URL.createObjectURL(file) });
toast.success("已加入参考图");
}
/** 释放当前页面创建的本地对象 URL。 */
function releaseReferences() {
references.value.forEach((reference) => URL.revokeObjectURL(reference.url));
references.value = [];
}
/** 将接口异常转换为统一的图片生成提示。 */
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
onMounted(load);
onBeforeUnmount(() => {
stopPolling();
releaseReferences();
});
</script>
<template>
<CreativeShell class="product-workbench-shell">
<div class="product-workbench">
<section class="panel-block product-settings">
<header class="panel-block__header"><strong>生成设置</strong></header>
<div
v-loading="loading"
class="product-settings__body"
>
<label class="product-field product-field--prompt">
<span>提示词</span>
<div class="prompt-editor-wrap">
<AssetPromptEditor
v-model="prompt"
:assets="referenceAssets"
variant="drama-storyboard"
floating-preview
placeholder="输入提示词,输入 @ 插入参考图"
/>
<small class="prompt-count">{{ promptLength }} / 5000</small>
</div>
</label>
<section class="reference-section">
<header>
<strong>参考图</strong><small>{{ references.length }} / 4 张</small>
</header>
<div class="reference-grid">
<div
v-for="reference in references"
:key="reference.id"
class="reference-item"
@click="openReferencePreview(reference)"
>
<img
:src="reference.url"
:alt="reference.name"
/>
<button
type="button"
:title="`删除 ${reference.name}`"
@click.stop="removeReference(reference.id)"
>
<X :size="16" />
</button>
</div>
<button
type="button"
class="reference-add"
:disabled="references.length >= 4"
@click="chooseReference"
>
<Upload :size="22" /><span>添加图片</span>
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
@change="addReferences"
/>
</section>
<section class="generation-parameters">
<strong>生成参数</strong>
<div class="generation-parameters__grid">
<CreativeSelect
v-model="aspectRatio"
:options="ratioOptions"
placement="top-start"
placeholder="比例"
/>
<CreativeSelect
v-model="resolution"
:options="resolutionOptions"
placement="top-start"
placeholder="分辨率"
/>
</div>
<CreativeSelect
v-model="modelId"
:options="imageModels"
placement="top-start"
placeholder="请选择图片模型"
/>
</section>
</div>
<footer class="product-settings__footer">
<el-button
type="primary"
:loading="generating"
:disabled="loading"
@click="generate"
>
<span class="generation-button__content">
<span class="generation-button__action"><Sparkles :size="17" /><strong>开始生成</strong></span>
<small
v-if="price"
class="generation-button__price"
><b>{{ price }}</b
><span>积分</span></small
>
</span>
</el-button>
</footer>
</section>
<section class="panel-block product-results">
<header class="panel-block__header">
<strong>效果图</strong><small>{{ generations.length }} 条</small>
</header>
<div
v-loading="loading"
class="product-results__body"
>
<article
v-for="item in generations"
:key="item.task_id"
class="result-card"
:class="{ 'has-image': item.public_url }"
>
<button
type="button"
class="result-card__preview"
:disabled="!item.public_url"
@click="openPreview(item)"
>
<LoadingMedia
v-if="item.public_url"
type="image"
:src="item.public_url"
alt="图片生成效果"
/>
<span
v-else
class="result-card__state"
><LoaderCircle
v-if="activeStatuses.has(item.status)"
class="is-spinning"
:size="26"
/><Image
v-else
:size="26"
/><b>{{
activeStatuses.has(item.status) ? (item.error_message ? "结果同步中" : "生成中") : "生成失败"
}}</b
><small
v-if="item.error_message"
:title="item.error_message"
>{{ item.error_message }}</small
></span
>
<span class="result-card__meta"
><strong>{{ item.model_name || "图片模型" }}</strong
><time>{{ formatDateTime(item.created_at) }}</time></span
>
</button>
<button
v-if="!activeStatuses.has(item.status) || item.error_message"
type="button"
class="result-card__delete"
:title="item.public_url ? '删除效果图' : '删除生成记录'"
@click.stop="removeGeneration(item)"
>
<Trash2 :size="17" />
</button>
</article>
<div
v-if="!loading && !generations.length"
class="product-results__empty"
>
<Image :size="40" /><strong>还没有效果图</strong>
<p>完成左侧配置后开始生成。</p>
</div>
</div>
</section>
</div>
<ProductImageViewer
v-model="previewOpen"
:images="viewerImages"
:initial-id="previewItem?.id"
:reference-busy="referenceBusy"
@reference="addReferenceFile"
/>
</CreativeShell>
</template>
<style scoped lang="scss">
.product-workbench-shell {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.product-workbench-shell :deep(.creative-shell__scroll) {
min-height: 0;
padding-bottom: 20px;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.product-workbench {
min-height: 0;
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
flex: 1;
gap: 14px;
}
.panel-block {
min-width: 0;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 13px;
overflow: hidden;
}
.panel-block__header {
min-height: 56px;
padding: 0 18px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--web-line-strong);
}
.panel-block__header strong {
font-size: 16px;
}
.panel-block__header small {
color: var(--web-muted);
font-size: 12px;
}
.product-settings {
min-height: 620px;
display: flex;
flex-direction: column;
}
.product-settings__body {
min-height: 0;
padding: 18px;
display: flex;
flex: 1;
flex-direction: column;
gap: 18px;
overflow-y: auto;
}
.product-field {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.product-field > span,
.reference-section header strong,
.generation-parameters > strong {
color: var(--web-text-secondary);
font-size: 12px;
font-weight: 600;
}
.prompt-editor-wrap {
position: relative;
height: clamp(280px, 38dvh, 420px);
flex: 0 0 auto;
}
.prompt-count {
position: absolute;
right: 10px;
bottom: 7px;
z-index: 2;
color: var(--web-muted);
font-size: 11px;
pointer-events: none;
}
.reference-section header {
margin-bottom: 9px;
display: flex;
justify-content: space-between;
}
.reference-section header small {
color: var(--web-muted);
font-size: 11px;
}
.reference-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.reference-item,
.reference-add {
position: relative;
aspect-ratio: 1;
overflow: hidden;
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.reference-item {
cursor: pointer;
}
.reference-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.reference-item > button {
width: 28px;
height: 28px;
position: absolute;
top: 5px;
right: 5px;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition:
opacity 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.reference-item:hover > button,
.reference-item:focus-within > button {
opacity: 1;
}
.reference-add {
min-height: 76px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
color: var(--web-muted);
border-style: dashed;
cursor: pointer;
font-size: 11px;
transition:
color 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.reference-add:hover:not(:disabled) {
color: var(--web-text);
background: var(--web-hover);
border-color: var(--web-line-emphasis);
}
.reference-add:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.generation-parameters {
display: flex;
flex-direction: column;
gap: 8px;
}
.generation-parameters__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.product-settings__footer {
margin-top: auto;
padding: 14px 18px;
border-top: 1px solid var(--web-line-strong);
}
.product-settings__footer :deep(.el-button) {
width: 100%;
min-height: 44px;
}
.generation-button__content,
.generation-button__action,
.generation-button__price {
display: inline-flex;
align-items: center;
}
.generation-button__content {
justify-content: center;
gap: 14px;
white-space: nowrap;
}
.generation-button__action {
gap: 6px;
}
.generation-button__action strong {
font-size: 15px;
font-weight: 650;
line-height: 1;
}
.generation-button__price {
gap: 3px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.generation-button__price b {
font-size: 13px;
font-weight: 650;
}
.product-results {
min-height: 620px;
display: flex;
flex-direction: column;
}
.product-results__body {
min-height: 0;
padding: 18px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 180px));
align-content: start;
gap: 10px;
overflow-y: auto;
}
.result-card {
min-width: 0;
position: relative;
aspect-ratio: 1;
overflow: hidden;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
}
.result-card__preview {
width: 100%;
height: 100%;
padding: 0;
display: block;
overflow: hidden;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
}
.result-card__preview:disabled {
cursor: default;
}
.result-card__preview :deep(.loading-media),
.result-card__preview :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.result-card__meta {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 22px 10px 9px;
display: flex;
flex-direction: column;
gap: 3px;
color: var(--web-message-text);
background: var(--web-media-overlay);
pointer-events: none;
}
.result-card__meta strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-card__meta time {
color: var(--web-message-muted);
font-size: 10px;
}
.result-card__state {
height: 100%;
padding: 16px 16px 72px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 9px;
color: var(--web-muted);
font-size: 12px;
text-align: center;
}
.result-card__state small {
max-width: 100%;
overflow: hidden;
display: -webkit-box;
line-height: 1.45;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.result-card__delete {
width: 34px;
height: 34px;
position: absolute;
top: 7px;
right: 7px;
z-index: 2;
display: grid;
place-items: center;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
cursor: pointer;
opacity: 0;
transition:
opacity 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.result-card:hover .result-card__delete,
.result-card:focus-within .result-card__delete {
opacity: 1;
}
.result-card__delete:hover {
color: var(--web-danger);
background: var(--web-danger-soft);
border-color: var(--web-danger);
}
.product-results__empty {
min-height: 300px;
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 9px;
color: var(--web-muted);
font-size: 12px;
text-align: center;
}
.product-results__empty strong {
color: var(--web-text-secondary);
}
.product-results__empty p {
margin: 0;
}
.is-spinning {
animation: product-spin 0.9s linear infinite;
}
@keyframes product-spin {
to {
transform: rotate(360deg);
}
}
.image-preview {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
overflow: hidden;
color: var(--web-text-strong);
background: var(--web-dialog-mask);
}
.image-preview__topbar {
height: 64px;
position: absolute;
top: 0;
right: 0;
left: 0;
padding: 0 18px;
display: flex;
align-items: center;
gap: 8px;
}
.image-preview__topbar span {
color: var(--web-muted);
font-size: 12px;
}
.image-preview__topbar button,
.image-preview__arrow,
.image-preview__actions button,
.image-preview__prompt button {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
cursor: pointer;
transition:
color 0.18s,
background-color 0.18s,
border-color 0.18s;
}
.image-preview__topbar button {
margin-left: auto;
}
.image-preview__image {
width: calc(100vw - 160px);
height: calc(100dvh - 150px);
}
.image-preview__arrow {
position: absolute;
top: 50%;
z-index: 1;
}
.image-preview__arrow--previous {
left: 18px;
}
.image-preview__arrow--next {
right: 18px;
transform: rotate(180deg);
}
.image-preview__actions {
position: absolute;
bottom: 12px;
left: 50%;
padding: 4px;
display: flex;
gap: 4px;
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 10px;
transform: translateX(-50%);
}
.image-preview__actions button {
min-width: 36px;
min-height: 36px;
padding: 0 12px;
font-size: 13px;
}
.image-preview__prompt {
width: min(420px, calc(100vw - 32px));
max-height: calc(100dvh - 130px);
position: absolute;
top: 82px;
right: 18px;
padding: 18px;
overflow-y: auto;
color: var(--web-text);
background: var(--web-surface-soft);
border: 1px solid var(--web-line-strong);
border-radius: 12px;
}
.image-preview__prompt header {
padding-bottom: 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--web-line-strong);
}
.image-preview__prompt button {
min-height: 38px;
padding: 0 12px;
}
.image-preview__prompt p {
margin: 16px 0 0;
color: var(--web-text-secondary);
line-height: 1.75;
white-space: pre-wrap;
}
@media (max-width: 1050px) {
.product-workbench {
grid-template-columns: 320px minmax(0, 1fr);
}
.product-results__body {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
@media (max-width: 820px) {
.product-workbench-shell {
height: auto;
min-height: 100dvh;
overflow: hidden;
}
.product-workbench-shell :deep(.creative-shell__scroll) {
padding-bottom: 56px;
display: block;
overflow-y: auto;
}
.product-workbench {
grid-template-columns: 1fr;
flex: none;
}
.product-settings,
.product-results {
min-height: 0;
}
.product-results__body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.image-preview__image {
width: calc(100vw - 32px);
height: calc(100dvh - 170px);
}
.image-preview__arrow {
top: auto;
bottom: 82px;
}
.image-preview__prompt {
top: auto;
right: 16px;
bottom: 82px;
left: 16px;
width: auto;
max-height: 42dvh;
}
}
@media (max-width: 480px) {
.generation-parameters__grid {
grid-template-columns: 1fr;
}
.product-results__body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.result-card {
aspect-ratio: 4 / 3;
}
}
</style>
+280
View File
@@ -0,0 +1,280 @@
/* 重绘项目详情页样式,负责项目上下文栏、操作区和剧集卡片布局。 */
.redraw-project-header {
min-height: var(--control-height-lg);
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
gap: 14px;
}
.redraw-project-back {
width: 40px;
height: 40px;
display: grid;
place-items: center;
flex: 0 0 40px;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 8px;
}
.redraw-project-heading {
min-width: 0;
flex: 1;
}
.redraw-project-heading strong {
display: block;
overflow: hidden;
color: var(--web-text);
font-size: 20px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.redraw-project-actions {
display: flex;
align-items: center;
gap: 8px;
}
.redraw-episode-toolbar {
min-height: var(--control-height-lg);
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.redraw-episode-toolbar > span {
color: var(--web-project-count);
font-size: 12px;
white-space: nowrap;
}
.redraw-episode-empty button {
min-height: 42px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--web-text-strong);
background: var(--creative-accent);
border: 1px solid var(--creative-accent);
border-radius: 9px;
font-weight: 650;
cursor: pointer;
}
.model-warning {
color: var(--creative-accent);
border-color: var(--web-line-emphasis);
}
.redraw-episode-empty button:disabled {
cursor: wait;
opacity: 0.65;
}
.redraw-episode-grid {
min-height: 260px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
align-items: start;
gap: var(--space-md);
}
.redraw-episode-card {
min-width: 0;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
}
.redraw-episode-card:hover {
border-color: var(--web-project-card-hover-line);
}
.redraw-episode-cover {
aspect-ratio: 16 / 9;
position: relative;
display: grid;
place-items: center;
color: var(--web-episode-placeholder);
background: var(--web-surface);
border-bottom: 1px solid var(--web-line-strong);
overflow: hidden;
}
.redraw-episode-cover :deep(.loading-media),
.redraw-episode-cover :deep(.loading-media-element) {
width: 100%;
height: 100%;
object-fit: cover;
}
.redraw-episode-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 7px;
color: var(--web-episode-status-text);
background: var(--web-surface-raised);
border: 1px solid var(--web-line-strong);
border-radius: 5px;
font-size: 11px;
}
.redraw-episode-status.is-draft {
color: var(--web-text-tertiary);
background: var(--web-surface-raised);
border-color: var(--web-line-strong);
}
.redraw-episode-status.is-uploaded {
color: var(--web-accent-muted);
background: color-mix(in srgb, var(--web-accent-muted) 10%, var(--web-surface));
border-color: color-mix(in srgb, var(--web-accent-muted) 28%, var(--web-line));
}
.redraw-episode-status.is-analyzing {
color: var(--web-warning);
background: color-mix(in srgb, var(--web-warning) 11%, var(--web-surface));
border-color: color-mix(in srgb, var(--web-warning) 30%, var(--web-line));
}
.redraw-episode-status.is-ready {
color: var(--web-primary-hover);
background: var(--web-brand-soft);
border-color: var(--web-primary-light);
}
.redraw-episode-status.is-generating {
color: var(--web-info);
background: color-mix(in srgb, var(--web-info) 11%, var(--web-surface));
border-color: color-mix(in srgb, var(--web-info) 30%, var(--web-line));
}
.redraw-episode-status.is-failed {
color: var(--web-danger);
background: var(--web-danger-soft);
border-color: color-mix(in srgb, var(--web-danger) 30%, var(--web-line));
}
.redraw-episode-status.is-completed {
color: var(--web-complete);
background: color-mix(in srgb, var(--web-success) 11%, var(--web-surface));
border-color: var(--web-success-border);
}
.redraw-episode-copy {
padding: 10px 12px 5px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.redraw-episode-copy > div {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.redraw-episode-copy small,
.redraw-episode-meta {
color: var(--web-project-tooltip-label);
font-size: 11px;
}
.redraw-episode-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
}
.redraw-episode-copy button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
flex: 0 0 30px;
color: var(--web-episode-copy);
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
opacity: 1;
pointer-events: auto;
}
.redraw-episode-copy button:hover {
background: var(--web-episode-copy-hover);
}
.redraw-episode-meta {
padding: 0 12px 10px;
display: flex;
justify-content: space-between;
gap: 12px;
}
.redraw-episode-empty {
min-height: 280px;
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: var(--web-text-tertiary);
background: var(--web-surface);
border: 1px dashed var(--web-line-emphasis);
border-radius: var(--radius-lg);
text-align: center;
}
.redraw-episode-empty p {
margin: 0 0 6px;
color: var(--web-project-state-text);
font-size: 12px;
}
@media (max-width: 767px) {
.redraw-project-header {
align-items: flex-start;
flex-wrap: wrap;
}
.redraw-project-actions {
width: 100%;
margin-left: 54px;
}
.redraw-project-actions > button {
flex: 1;
}
.redraw-episode-toolbar {
align-items: stretch;
flex-direction: column;
gap: var(--space-sm);
}
.redraw-episode-toolbar :deep(.project-search-field) {
width: 100%;
}
.redraw-episode-grid {
grid-template-columns: 1fr;
}
}
+281
View File
@@ -0,0 +1,281 @@
<!-- 重绘项目详情页,负责项目剧集、配置和工作台入口。 -->
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { Plus, Setting } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ArrowLeft, Film, Trash2 } from "@/icons";
import { useRoute, useRouter } from "vue-router";
import ProjectSearchField from "@/components/ProjectSearchField.vue";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import { creativeApi, type CreativeOptions, type Episode, type ProjectDetail } from "@/api/creative";
import { apiError } from "@/api/http";
import { useModelPrompts } from "@/composables/useModelPrompts";
import { useToastStore } from "@/stores/toast";
import { formatDateTime } from "@/utils/datetime";
import { firstVideoFrameURL } from "@/utils/media";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const projectId = String(route.params.projectId);
const project = ref<ProjectDetail>();
const options = ref<CreativeOptions>({ styles: [], models: [] });
const loading = ref(false);
const creating = ref(false);
const modelsOpen = ref(false);
const savingModels = ref(false);
const keyword = ref("");
const modelReady = computed(() =>
project.value?.model_configs.some((item) => item.purpose === "prompt_reverse" && Boolean(item.model_id)),
);
const filteredEpisodes = computed(() => {
const value = keyword.value.trim().toLocaleLowerCase();
const episodes = project.value?.episodes || [];
if (!value) return episodes;
return episodes.filter(
(episode) =>
episode.name.toLocaleLowerCase().includes(value) ||
String(episode.episode_no).includes(value) ||
`第${episode.episode_no}集`.includes(value.replaceAll(" ", "")),
);
});
const { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt } =
useModelPrompts();
const statusLabels: Record<string, { label: string; tone: string }> = {
draft: { label: "待上传", tone: "is-draft" },
uploaded: { label: "待反推", tone: "is-uploaded" },
analyzing: { label: "分镜反推中", tone: "is-analyzing" },
review: { label: "待反推剧本", tone: "is-ready" },
generating: { label: "剧本反推中", tone: "is-generating" },
failed: { label: "处理失败", tone: "is-failed" },
};
/** 根据后端处理阶段和剧本产出标记生成剧集卡片的业务状态。 */
function episodeStatus(episode: Episode) {
if (episode.status === "review" && episode.has_redraw_script) {
return { label: "已完成", tone: "is-completed" };
}
return statusLabels[episode.status] || { label: episode.status, tone: "is-draft" };
}
async function load() {
loading.value = true;
try {
const [loadedProject, loadedOptions] = await Promise.all([creativeApi.project(projectId), creativeApi.options()]);
options.value = loadedOptions;
project.value = loadedProject;
if (project.value.project_type !== "video_redraw") {
await router.replace({ name: "drama-project", params: { projectId } });
return;
}
const configured = project.value.model_configs.some(
(item) => item.purpose === "prompt_reverse" && Boolean(item.model_id),
);
if (!configured) {
const defaultModel = loadedOptions.models.find((item) => item.model_type === "text" && item.multimodal);
if (defaultModel) {
await creativeApi.saveModels(projectId, {
prompt_reverse: { model_id: defaultModel.id, settings: { prompt: "" } },
});
project.value = await creativeApi.project(projectId);
}
}
} catch (error) {
showError(error, "加载项目失败");
} finally {
loading.value = false;
}
}
async function openModels() {
modelsOpen.value = true;
await loadPrompts(["剧本反推"]);
}
async function saveModels(payload: Record<string, any>) {
const value = payload.prompt_reverse;
if (!value?.model_id) return toast.warning("请选择反推模型");
savingModels.value = true;
try {
await creativeApi.saveModels(projectId, { prompt_reverse: value });
modelsOpen.value = false;
toast.success("模型配置已保存");
await load();
} catch (error) {
showError(error, "保存模型配置失败");
} finally {
savingModels.value = false;
}
}
async function createEpisode() {
if (creating.value) return;
creating.value = true;
try {
const episode = await creativeApi.createEpisode(projectId);
toast.success(`第 ${episode.episode_no} 集已创建`);
await router.push({
name: "redraw-workbench",
params: { projectId, episodeId: episode.id },
});
} catch (error) {
showError(error, "创建剧集失败");
} finally {
creating.value = false;
}
}
async function openEpisode(episodeId: string) {
await router.push({ name: "redraw-workbench", params: { projectId, episodeId } });
}
async function removeEpisode(episodeId: string, episodeNo: number) {
try {
await ElMessageBox.confirm(`确定删除第 ${episodeNo} 集吗?`, "删除剧集", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteEpisode(projectId, episodeId);
toast.success("剧集已删除");
await load();
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除剧集失败");
}
}
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
onMounted(load);
</script>
<template>
<CreativeShell page-title="剧本反推">
<section class="redraw-project-header">
<RouterLink
class="redraw-project-back"
:to="{ name: 'redraw-projects' }"
>
<ArrowLeft :size="18" />
</RouterLink>
<div class="redraw-project-heading">
<strong>{{ project?.name || "项目" }}</strong>
</div>
<div class="redraw-project-actions creative-page-actions">
<el-button
:icon="Setting"
:class="{ 'model-warning': !modelReady }"
@click="openModels"
>
模型配置
</el-button>
<el-button
type="primary"
:icon="Plus"
:loading="creating"
@click="createEpisode"
>
创建剧集
</el-button>
</div>
</section>
<section class="redraw-episode-toolbar">
<ProjectSearchField
v-model="keyword"
placeholder="搜索剧集名称或集数"
/>
<span>{{ filteredEpisodes.length }} 个剧集</span>
</section>
<section
v-loading="loading"
class="redraw-episode-grid"
>
<article
v-for="episode in filteredEpisodes"
:key="episode.id"
class="redraw-episode-card"
@click="openEpisode(episode.id)"
>
<div class="redraw-episode-cover">
<LoadingMedia
v-if="episode.source_video_url"
type="video"
:src="firstVideoFrameURL(episode.source_video_url)"
muted
playsinline
preload="metadata"
/>
<Film
v-else
:size="34"
stroke-width="1.25"
/>
<span :class="['redraw-episode-status', episodeStatus(episode).tone]">
{{ episodeStatus(episode).label }}
</span>
</div>
<div class="redraw-episode-copy">
<div>
<strong>{{ episode.name }}</strong>
</div>
<button
type="button"
@click.stop="removeEpisode(episode.id, episode.episode_no)"
>
<Trash2 :size="16" />
</button>
</div>
<div class="redraw-episode-meta">
<span>{{ episode.storyboard_count || 0 }} 个分镜</span>
<time>{{ formatDateTime(episode.updated_at || project?.updated_at || "") }}</time>
</div>
</article>
<div
v-if="!loading && !filteredEpisodes.length"
class="redraw-episode-empty"
>
<Film
:size="38"
stroke-width="1.2"
/>
<strong>{{ keyword.trim() ? "没有匹配的剧集" : "还没有剧集" }}</strong>
<p>{{ keyword.trim() ? "换一个关键词继续查找" : "创建第一集后上传原视频开始反推" }}</p>
<el-button
v-if="!keyword.trim()"
type="primary"
:icon="Plus"
:loading="creating"
@click="createEpisode"
>
创建剧集
</el-button>
</div>
</section>
<ModelConfigDialog
v-if="project"
v-model="modelsOpen"
analysis-only
:options="options"
:configs="project.model_configs"
:prompts="prompts"
:prompt-selections="promptSelections"
:saving="savingModels"
@save="saveModels"
@prompt-select="selectPrompt"
@prompt-create="createPrompt"
@prompt-update="updatePrompt"
@prompt-delete="deletePrompt"
/>
</CreativeShell>
</template>
<style scoped lang="scss" src="./RedrawProjectView.styles.scss"></style>
+712
View File
@@ -0,0 +1,712 @@
<!-- 创作项目列表页,负责短剧与重绘项目的查询、新建和删除。 -->
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { CircleAlert, Film } from "@/icons";
import { useRoute, useRouter } from "vue-router";
import CardActionMenu from "@/components/CardActionMenu.vue";
import ProjectSearchField from "@/components/ProjectSearchField.vue";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import LoadingMedia from "@/components/creative/LoadingMedia.vue";
import ProjectDialog from "@/components/creative/ProjectDialog.vue";
import RedrawProjectDialog from "@/components/creative/RedrawProjectDialog.vue";
import {
creativeApi,
type CreativeOptions,
type ProjectDetail,
type ProjectPayload,
type ProjectSummary,
} from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { formatDateTime } from "@/utils/datetime";
import { firstVideoFrameURL } from "@/utils/media";
const route = useRoute(),
router = useRouter(),
toast = useToastStore();
const isDrama = computed(() => route.meta.projectType === "premium_drama");
const pageLabel = computed(() => (isDrama.value ? "短剧创作" : "剧本反推"));
const itemLabel = computed(() => "项目");
const projects = ref<ProjectSummary[]>([]),
options = ref<CreativeOptions>({ styles: [], models: [] });
const loading = ref(false),
saving = ref(false),
dialogOpen = ref(false);
const openingProjectId = ref("");
const editingProject = ref<ProjectDetail | null>(null);
const keyword = ref("");
const filteredProjects = computed(() => {
const value = keyword.value.trim().toLocaleLowerCase();
if (!value) return projects.value;
return projects.value.filter((project) => project.name.toLocaleLowerCase().includes(value));
});
const eraNames: Record<string, string> = {
modern_city: "现代都市",
ancient_history: "古代历史",
ancient_fantasy: "古代玄幻",
ancient_xianxia: "古代仙侠",
future_scifi: "未来科幻",
other: "其他",
};
/** 获取项目年代展示文案,并兼容用户自定义年代。 */
function eraLabel(project: ProjectSummary) {
return project.era_type === "other" && project.custom_era?.trim()
? project.custom_era.trim()
: eraNames[project.era_type] || project.era_type;
}
/** 按当前项目类型加载项目列表。 */
async function load() {
loading.value = true;
try {
projects.value = isDrama.value ? await creativeApi.dramaProjects() : await creativeApi.projects();
} catch (e) {
showError(e, "加载失败");
} finally {
loading.value = false;
}
}
/** 打开新建项目对话框。 */
function openCreate() {
editingProject.value = null;
dialogOpen.value = true;
}
/** 加载项目详情并打开编辑对话框。 */
async function openEdit(project: ProjectSummary) {
try {
editingProject.value = await creativeApi.project(project.id);
dialogOpen.value = true;
} catch (e) {
showError(e, `加载${itemLabel.value}失败`);
}
}
/** 校验并保存项目,根据编辑状态调用新建或更新接口。 */
async function saveDialog(payload: ProjectPayload) {
if (!payload.name || (isDrama.value && !payload.style_id)) {
toast.warning("请填写项目名称并选择视觉风格");
return;
}
saving.value = true;
try {
if (editingProject.value) {
await creativeApi.updateProject(editingProject.value.id, {
...payload,
project_type: isDrama.value ? "premium_drama" : "video_redraw",
localization: isDrama.value ? null : payload.localization,
});
dialogOpen.value = false;
editingProject.value = null;
toast.success("项目已保存");
await load();
return;
}
const project = await creativeApi.createProject({
...payload,
project_type: isDrama.value ? "premium_drama" : "video_redraw",
localization: isDrama.value ? null : payload.localization,
});
dialogOpen.value = false;
toast.success("项目已创建");
await openProject(project.id);
} catch (e) {
showError(e, editingProject.value ? "保存失败" : "创建失败");
} finally {
saving.value = false;
}
}
/** 将空的可选项目字段转换为统一占位文案。 */
function formatOptionalText(value?: string) {
return value?.trim() || "未填写";
}
/** 根据项目类型进入对应详情页,并防止重复跳转。 */
async function openProject(projectId: string) {
if (openingProjectId.value) return;
if (isDrama.value) {
await router.push({
path: `/drama-projects/${projectId}`,
query: { from: "drama-projects" },
});
return;
}
openingProjectId.value = projectId;
try {
await router.push({
path: `/redraw-projects/${projectId}`,
query: { from: "redraw-projects" },
});
} catch (e) {
showError(e, "进入项目失败");
} finally {
openingProjectId.value = "";
}
}
/** 二次确认后删除项目并刷新列表。 */
async function removeProject(project: ProjectSummary) {
try {
await ElMessageBox.confirm(`确定删除${itemLabel.value}“${project.name}”吗?`, `删除${itemLabel.value}`, {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteProject(project.id);
toast.success(`${itemLabel.value}已删除`);
await load();
} catch (e) {
if (e !== "cancel" && e !== "close") showError(e, "删除失败");
}
}
/** 将接口异常转换为统一 Toast 错误信息。 */
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
onMounted(async () => {
try {
options.value = await creativeApi.options();
} catch (e) {
showError(e, "加载失败");
}
await load();
});
watch(isDrama, () => {
projects.value = [];
keyword.value = "";
void load();
});
</script>
<template>
<CreativeShell :page-title="pageLabel">
<section class="project-list-toolbar">
<ProjectSearchField
v-model="keyword"
:placeholder="`搜索${pageLabel}${itemLabel}`"
/>
<div class="project-list-actions creative-page-actions">
<span>{{ filteredProjects.length }} 个{{ itemLabel }}</span>
<el-button
type="primary"
:icon="Plus"
@click="openCreate"
>创建{{ itemLabel }}</el-button
>
</div>
</section>
<section
v-loading="loading"
class="project-grid"
>
<article
v-for="project in filteredProjects"
:key="project.id"
class="project-card"
:class="{ 'redraw-project-card': !isDrama }"
@click="openProject(project.id)"
>
<span
class="project-cover"
:class="project.aspect_ratio === '9:16' ? 'portrait' : 'landscape'"
>
<LoadingMedia
v-if="
(isDrama && project.cover_video_url) ||
(!isDrama && project.cover_url && project.cover_mime_type?.startsWith('video/'))
"
type="video"
:src="firstVideoFrameURL(isDrama ? project.cover_video_url! : project.cover_url!)"
muted
playsinline
preload="metadata"
disablepictureinpicture
disableremoteplayback
controlslist="nodownload noplaybackrate noremoteplayback"
/>
<LoadingMedia
v-else-if="project.cover_url"
type="image"
:src="project.cover_url"
:alt="`${project.name}封面`"
/>
<span
v-else
class="project-placeholder"
>
<Film
:size="30"
stroke-width="1.25"
/>
<small>暂无封面</small>
</span>
<span
v-if="!isDrama"
class="redraw-type-badge"
>
{{ project.short_drama_type || "未填写" }}
</span>
<span
v-if="openingProjectId === project.id"
class="processing-badge"
>正在进入</span
>
<span
v-else-if="Number(project.processing_count)"
class="processing-badge"
>处理中 {{ project.processing_count }}</span
>
</span>
<span
v-if="isDrama"
class="project-copy"
><strong>{{ project.name }}</strong
><small class="project-tags"
><span>{{ project.style_name }}</span
><span>{{ eraLabel(project) }}</span
><span>{{ project.aspect_ratio }}</span></small
></span
>
<span
v-else
class="project-copy redraw-project-copy"
>
<el-tooltip
placement="right-start"
effect="light"
popper-class="redraw-project-tooltip"
>
<template #content>
<div class="project-tooltip-content">
<header class="project-tooltip-header">
<CircleAlert :size="16" />
<strong>项目信息</strong>
</header>
<dl>
<div>
<dt>短剧名</dt>
<dd>{{ project.name }}</dd>
</div>
<div>
<dt>短剧类型</dt>
<dd>{{ project.short_drama_type || "未填写" }}</dd>
</div>
<div>
<dt>播放量</dt>
<dd>{{ formatOptionalText(project.play_count) }}</dd>
</div>
<div>
<dt>受众群体画像</dt>
<dd>{{ project.audience_profile || "未填写" }}</dd>
</div>
<div>
<dt>出品方</dt>
<dd>{{ project.producer || "未填写" }}</dd>
</div>
<div>
<dt>主演</dt>
<dd
v-if="project.cast_members?.length"
class="cast-tooltip-list"
>
<span
v-for="(member, index) in project.cast_members"
:key="`${member.name}-${index}`"
>
{{ member.name }}({{ formatOptionalText(member.follower_count) }} 粉丝)
</span>
</dd>
<dd v-else>未填写</dd>
</div>
</dl>
</div>
</template>
<span
class="project-info-trigger"
@click.stop
>
<CircleAlert :size="18" />
</span>
</el-tooltip>
<strong>{{ project.name }}</strong>
</span>
<span class="project-meta">
<span>{{ Number(project.episode_count) }} 集</span>
<time>{{ formatDateTime(project.updated_at) }}</time>
</span>
<CardActionMenu
:label="`${project.name} 更多操作`"
@edit="openEdit(project)"
@delete="removeProject(project)"
/>
</article>
<div
v-if="!loading && !filteredProjects.length"
class="project-state"
>
<Film
:size="38"
stroke-width="1.2"
/>
<strong>{{ keyword.trim() ? `没有匹配的${itemLabel}` : `还没有${pageLabel}${itemLabel}` }}</strong>
<p>{{ keyword.trim() ? "换一个关键词继续查找。" : `点击右上角按钮创建第一个${itemLabel}。` }}</p>
<el-button
v-if="!keyword.trim()"
type="primary"
:icon="Plus"
@click="openCreate"
>
创建{{ itemLabel }}
</el-button>
</div>
</section>
<ProjectDialog
v-if="isDrama"
v-model="dialogOpen"
:options="options"
:project="editingProject"
:saving="saving"
:project-type="isDrama ? 'premium_drama' : 'video_redraw'"
@save="saveDialog"
/>
<RedrawProjectDialog
v-else
v-model="dialogOpen"
:project="editingProject"
:saving="saving"
@save="saveDialog"
/>
</CreativeShell>
</template>
<style scoped lang="scss">
.project-list-toolbar {
min-height: var(--control-height-lg);
margin-bottom: var(--space-xl);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
}
.project-list-actions {
display: flex;
align-items: center;
gap: var(--space-lg);
}
.project-list-actions > span {
color: var(--web-project-count);
font-size: 12px;
white-space: nowrap;
}
.project-grid {
min-height: 240px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
align-items: stretch;
gap: var(--space-lg);
}
.project-card {
min-width: 0;
position: relative;
padding: 0;
text-align: left;
color: var(--web-text);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition:
background-color 0.18s,
border-color 0.18s,
color 0.18s;
}
.redraw-project-card {
align-self: start;
width: 100%;
}
.project-card:hover,
.project-card:focus-within {
color: var(--web-text);
background: var(--web-surface);
border-color: var(--web-border-strong);
}
.project-cover {
position: relative;
aspect-ratio: 16 / 9;
display: grid;
place-items: center;
overflow: hidden;
background: var(--web-surface-raised);
border-bottom: 1px solid var(--web-line);
}
.project-cover.landscape {
background: var(--web-surface);
}
.project-cover video,
.project-cover img,
.project-cover :deep(.loading-media-element) {
height: 100%;
width: auto;
max-width: 100%;
object-fit: contain;
pointer-events: none;
}
.project-placeholder {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--space-sm);
color: var(--web-project-placeholder);
}
.project-cover small {
font-family: var(--font-ui);
font-size: 12px;
letter-spacing: 0;
}
.processing-badge {
position: absolute;
top: 12px;
right: 54px;
padding: 5px 8px;
color: var(--web-accent-dark);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: var(--radius-sm);
font-size: 11px;
font-style: normal;
}
.redraw-type-badge {
position: absolute;
top: 12px;
left: 12px;
z-index: 2;
max-width: calc(100% - 72px);
padding: 5px 9px;
overflow: hidden;
color: var(--web-project-badge-text);
background: var(--web-surface);
border: 1px solid var(--web-line-emphasis);
border-radius: var(--radius-sm);
box-shadow: var(--web-shadow-project-badge);
font-size: 11px;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-copy {
padding: var(--space-lg) var(--space-lg) var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.project-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 600;
}
.redraw-project-copy {
min-width: 0;
padding: var(--space-md) var(--space-lg) var(--space-sm);
align-items: center;
flex-direction: row;
gap: var(--space-sm);
}
.redraw-project-copy strong {
min-width: 0;
flex: 1;
}
.project-info-trigger {
width: 28px;
height: 28px;
display: grid;
place-items: center;
flex: 0 0 28px;
color: var(--web-project-tooltip-accent);
background: transparent;
border: 0;
cursor: help;
}
.project-tooltip-content {
width: min(320px, calc(100vw - 52px));
height: min(320px, calc(100vh - 48px));
padding: 14px 16px 10px;
box-sizing: border-box;
color: var(--web-text);
overflow-y: auto;
overscroll-behavior: contain;
}
.project-tooltip-header {
padding-bottom: 10px;
display: flex;
align-items: center;
gap: 7px;
color: var(--web-project-tooltip-accent);
border-bottom: 1px solid var(--web-project-tooltip-line);
}
.project-tooltip-header strong {
color: var(--web-text);
font-size: 14px;
font-weight: 650;
}
.project-tooltip-content dl {
margin: 0;
display: grid;
}
.project-tooltip-content dl > div {
padding: 9px 0;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
border-bottom: 1px solid var(--web-project-tooltip-line-soft);
}
.project-tooltip-content dl > div:last-child {
border-bottom: 0;
}
.project-tooltip-content dt {
color: var(--web-project-tooltip-label);
font-size: 12px;
}
.project-tooltip-content dd {
margin: 0;
color: var(--web-text);
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.cast-tooltip-list {
display: flex;
flex-direction: column;
gap: 3px;
}
:global(.redraw-project-tooltip.el-popper) {
padding: 0;
background: var(--web-surface);
border-color: var(--web-line-strong);
border-radius: 10px;
box-shadow: var(--web-shadow-project-tooltip);
overflow: hidden;
}
:global(.redraw-project-tooltip.el-popper .el-popper__arrow::before) {
background: var(--web-surface);
border-color: var(--web-line-strong);
}
.project-copy small {
color: var(--web-project-copy-muted);
font-size: 12px;
}
.project-tags {
display: flex;
flex-wrap: wrap;
gap: 2px 8px;
line-height: 1.45;
}
.project-tags span:not(:last-child)::after {
content: "·";
margin-left: 8px;
color: var(--web-project-divider-text);
}
.project-meta {
padding: 0 var(--space-lg) var(--space-lg);
display: flex;
justify-content: space-between;
color: var(--web-project-meta);
font-size: 12px;
}
.redraw-project-copy + .project-meta {
padding: 0 var(--space-lg) var(--space-md);
}
.project-state {
min-height: 320px;
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: var(--space-sm);
color: var(--web-text-tertiary);
text-align: center;
}
.project-state strong {
color: var(--web-text-secondary);
font-size: 16px;
}
.project-state p {
margin: 0;
color: var(--web-project-state-text);
font-size: 12px;
}
.project-state :deep(.el-button) {
margin-top: var(--space-sm);
}
.field-error {
margin-top: 5px;
color: var(--web-accent-dark);
font-size: 12px;
line-height: 1.2;
}
.field-invalid:deep(.el-input__wrapper) {
border-color: var(--web-invalid-line);
box-shadow: 0 0 0 1px var(--web-invalid-outline);
}
@media (max-width: 820px) {
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
.project-list-toolbar {
align-items: stretch;
flex-direction: column;
gap: var(--space-md);
}
.project-list-toolbar .project-search-field {
width: 100%;
}
.project-list-actions {
justify-content: space-between;
}
}
@media (max-width: 520px) {
.project-grid {
grid-template-columns: 1fr;
}
}
</style>
+898
View File
@@ -0,0 +1,898 @@
<!-- 重绘工作台,负责编排源媒体、资产、分镜和生成任务。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { CircleClose, Document, Setting } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ArrowLeft } from "@/icons";
import { useRoute } from "vue-router";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import CreativeSelect from "@/components/creative/CreativeSelect.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import RedrawAssetPanel from "@/components/creative/RedrawAssetPanel.vue";
import RedrawScriptPanel from "@/components/creative/RedrawScriptPanel.vue";
import RedrawSourcePanel from "@/components/creative/RedrawSourcePanel.vue";
import RedrawTextPanel from "@/components/creative/RedrawTextPanel.vue";
import StoryboardTimeline from "@/components/creative/StoryboardTimeline.vue";
import {
creativeApi,
type CreativeOptions,
type GenerationTask,
type ProjectAsset,
type PromptPreset,
type RedrawWorkbenchData,
type Storyboard,
} from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { taskElapsedLabel } from "@/utils/taskTimeout";
const route = useRoute();
const toast = useToastStore();
const projectId = String(route.params.projectId);
const episodeId = String(route.params.episodeId);
const data = ref<RedrawWorkbenchData>();
const tasks = ref<GenerationTask[]>([]);
const selectedId = ref("");
const loading = ref(false);
const sourceUploading = ref(false);
const subtitleUploading = ref(false);
const subtitleFileName = ref("");
const startingAnalysis = ref(false);
const startingReinfer = ref(false);
const startingScript = ref(false);
const scriptContent = ref("");
const savedScriptContent = ref("");
const savingScript = ref(false);
const scriptSaveState = ref<"idle" | "saved" | "error">("idle");
const assetBusy = ref(false);
const polling = ref(false);
const draggingVideo = ref(false);
const subtitleInput = ref<HTMLInputElement>();
const taskClock = ref(Date.now());
const suppressSettingsWatch = ref(false);
const persistingSettings = ref(false);
const options = ref<CreativeOptions>({ styles: [], models: [] });
const prompts = ref<PromptPreset[]>([]);
const promptSelections = ref<Record<string, string>>({});
const modelsOpen = ref(false);
const savingModels = ref(false);
const audioSource = ref("video_audio");
const sourceLanguage = ref("");
const promptTypes = ["剧本反推"];
const audioSourceOptions = [
{ label: "识别视频音轨", value: "video_audio" },
{ label: "使用字幕文件", value: "subtitle_file" },
];
const languageOptions = [
{ label: "自动识别", value: "" },
{ label: "中文", value: "zh" },
{ label: "英语", value: "en" },
{ label: "日语", value: "ja" },
{ label: "韩语", value: "ko" },
];
const activeStatuses = new Set([
"pending_submission",
"submitting",
"submitted",
"processing",
"result_ready",
"downloading",
"cancel_requested",
]);
const selected = computed(() => data.value?.storyboards.find((item) => item.id === selectedId.value));
const analysisTask = computed(() =>
tasks.value.find(
(item) =>
item.task_type === "prompt_reverse" &&
item.mode !== "script" &&
!item.storyboard_id &&
activeStatuses.has(item.status),
),
);
const scriptTask = computed(
() =>
tasks.value
.filter((item) => item.task_type === "prompt_reverse" && item.mode === "script")
.sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0],
);
const latestReverseTask = computed(() => {
if (!selected.value) return undefined;
return tasks.value
.filter(
(item) =>
item.task_type === "prompt_reverse" &&
item.mode !== "script" &&
(item.storyboard_id === selected.value?.id ||
(!item.storyboard_id && item.sequence_no === selected.value?.sequence_no)),
)
.sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0];
});
const reinferTask = computed(() =>
latestReverseTask.value && activeStatuses.has(latestReverseTask.value.status) ? latestReverseTask.value : undefined,
);
const hasActiveStoryboardReverseTask = computed(() =>
tasks.value.some(
(item) =>
item.task_type === "prompt_reverse" &&
item.mode !== "script" &&
(Boolean(item.storyboard_id) || item.sequence_no !== undefined) &&
activeStatuses.has(item.status),
),
);
const hasAnalysisResult = computed(() =>
Boolean(data.value?.source.source_video_url && data.value?.storyboards.length),
);
const scriptReady = computed(
() =>
data.value?.source.status === "review" &&
Boolean(data.value.storyboards.length) &&
data.value.storyboards.every((item) => Boolean(item.prompt_content?.trim())) &&
!analysisTask.value &&
!hasActiveStoryboardReverseTask.value,
);
const hasActiveTask = computed(
() => tasks.value.some((item) => activeStatuses.has(item.status)) || data.value?.source.status === "analyzing",
);
const analysisButtonText = computed(() =>
startingAnalysis.value
? "正在启动"
: analysisTask.value?.status === "cancel_requested"
? "停止中"
: analysisTask.value
? `停止:${data.value?.source.analysis_message || "正在反推"} ${taskElapsedLabel(analysisTask.value, taskClock.value)}`
: hasAnalysisResult.value
? "全部重新反推"
: "开始反推",
);
const analysisDisabled = computed(
() =>
startingAnalysis.value ||
Boolean(scriptTask.value && activeStatuses.has(scriptTask.value.status)) ||
analysisTask.value?.status === "cancel_requested" ||
(audioSource.value === "subtitle_file" && !data.value?.source.subtitle_asset_id),
);
watch(
data,
(value) => {
if (!value) return;
suppressSettingsWatch.value = true;
audioSource.value = value.source.audio_source || "video_audio";
sourceLanguage.value = value.source.source_language || "";
subtitleFileName.value = value.source.subtitle_original_name || "";
if (!savingScript.value && scriptContent.value === savedScriptContent.value) {
scriptContent.value = value.script_content || "";
savedScriptContent.value = scriptContent.value;
}
if (!value.storyboards.some((item) => item.id === selectedId.value))
selectedId.value = value.storyboards[0]?.id || "";
queueMicrotask(() => (suppressSettingsWatch.value = false));
},
{ deep: true },
);
let scriptSaveTimer: ReturnType<typeof setTimeout> | undefined;
let activeScriptSave: Promise<boolean> | undefined;
/** 安排短延迟自动保存,合并连续输入并避免每次按键都发起请求。 */
function scheduleScriptSave() {
scriptSaveState.value = "idle";
if (scriptSaveTimer) clearTimeout(scriptSaveTimer);
scriptSaveTimer = setTimeout(() => void saveScript(), 400);
}
/** 串行保存最新剧本草稿;保存期间产生的新修改会在当前请求结束后继续提交。 */
function saveScript(): Promise<boolean> {
if (scriptSaveTimer) clearTimeout(scriptSaveTimer);
scriptSaveTimer = undefined;
if (activeScriptSave) {
return activeScriptSave.then((saved) =>
saved && scriptContent.value !== savedScriptContent.value ? saveScript() : saved,
);
}
if (scriptContent.value === savedScriptContent.value) return Promise.resolve(true);
const submitted = scriptContent.value;
savingScript.value = true;
activeScriptSave = (async () => {
try {
await creativeApi.saveRedrawScript(projectId, episodeId, submitted);
savedScriptContent.value = submitted;
if (data.value) data.value.script_content = submitted;
scriptSaveState.value = "saved";
return true;
} catch (error) {
scriptSaveState.value = "error";
showError(error, "自动保存剧本失败");
return false;
} finally {
savingScript.value = false;
activeScriptSave = undefined;
}
})();
return activeScriptSave.then((saved) =>
saved && scriptContent.value !== savedScriptContent.value ? saveScript() : saved,
);
}
async function persistAnalysisSettings() {
if (!data.value || persistingSettings.value || suppressSettingsWatch.value) return;
if (
(data.value.source.audio_source || "video_audio") === audioSource.value &&
(data.value.source.source_language || "") === sourceLanguage.value
)
return;
persistingSettings.value = true;
try {
await creativeApi.saveAnalysisSettings(projectId, episodeId, {
audio_source: audioSource.value,
source_language: sourceLanguage.value,
});
} catch (error) {
showError(error, "保存台词设置失败");
} finally {
persistingSettings.value = false;
}
}
watch([audioSource, sourceLanguage], () => void persistAnalysisSettings());
async function load(showLoading = true) {
if (showLoading) loading.value = true;
try {
[data.value, tasks.value] = await Promise.all([
creativeApi.redrawWorkbench(projectId, episodeId),
creativeApi.tasks(projectId, episodeId),
]);
} catch (error) {
showError(error, "加载失败");
} finally {
if (showLoading) loading.value = false;
}
}
async function loadModelOptions() {
try {
options.value = await creativeApi.options();
} catch (error) {
showError(error, "加载模型选项失败");
}
}
async function openModels() {
modelsOpen.value = true;
try {
const results = await Promise.all(promptTypes.map((type) => creativeApi.promptPresets(type)));
const merged = new Map<string, PromptPreset>();
const selectedPrompts: Record<string, string> = {};
results.forEach((result, index) => {
(result.prompts || []).forEach((prompt) => merged.set(prompt.id, prompt));
if (result.selected_id) selectedPrompts[promptTypes[index]] = result.selected_id;
});
prompts.value = [...merged.values()];
promptSelections.value = selectedPrompts;
} catch (error) {
showError(error, "加载提示词失败");
}
}
async function saveModels(payload: Record<string, any>) {
const value = payload.prompt_reverse;
if (!value?.model_id) return toast.warning("请选择反推模型");
savingModels.value = true;
try {
await creativeApi.saveModels(projectId, { prompt_reverse: value });
modelsOpen.value = false;
toast.success("模型配置已保存");
await load(false);
} catch (error) {
showError(error, "保存失败");
} finally {
savingModels.value = false;
}
}
async function selectPrompt(payload: { type: string; promptId: string }) {
try {
await creativeApi.selectPrompt(payload.type, payload.promptId);
promptSelections.value = { ...promptSelections.value, [payload.type]: payload.promptId };
} catch (error) {
showError(error, "启用提示词失败");
}
}
async function createPrompt(payload: { name: string; type: string; content: string }) {
try {
const saved = await creativeApi.createCustomPrompt(payload);
prompts.value = [...prompts.value, saved];
await creativeApi.selectPrompt(payload.type, saved.id);
promptSelections.value = { ...promptSelections.value, [payload.type]: saved.id };
} catch (error) {
showError(error, "保存自定义提示词失败");
}
}
async function updatePrompt(payload: { id: string; name: string; type: string; content: string }) {
try {
const saved = await creativeApi.updateCustomPrompt(payload.id, payload);
prompts.value = prompts.value.map((item) => (item.id === saved.id ? saved : item));
} catch (error) {
showError(error, "保存提示词失败");
}
}
async function deletePrompt(prompt: PromptPreset) {
try {
await ElMessageBox.confirm("删除后不可恢复,确定删除该提示词吗?", "删除提示词", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteCustomPrompt(prompt.id);
prompts.value = prompts.value.filter((item) => item.id !== prompt.id);
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除提示词失败");
}
}
async function validateVideo(file: File) {
const ext = file.name.toLowerCase().split(".").pop() || "";
if (!["mp4", "webm", "mov", "m4v"].includes(ext)) {
toast.error("不支持该视频格式");
return false;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("视频不能超过 50MB");
return false;
}
const url = URL.createObjectURL(file);
try {
const duration = await new Promise<number>((resolve, reject) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => resolve(video.duration);
video.onerror = () => reject(new Error());
video.src = url;
});
if (!Number.isFinite(duration) || duration <= 0 || duration > 180) {
toast.error("视频时长不能超过 3 分钟");
return false;
}
return true;
} catch {
toast.error("视频读取失败");
return false;
} finally {
URL.revokeObjectURL(url);
}
}
async function uploadSource(file: File) {
if (sourceUploading.value || !(await validateVideo(file))) return;
if (hasAnalysisResult.value) {
try {
await ElMessageBox.confirm("重新上传视频将删除当前剧本的分镜和提示词,资产会保留。是否继续?", "重新上传视频", {
type: "warning",
confirmButtonText: "继续上传",
cancelButtonText: "取消",
});
} catch {
return;
}
}
sourceUploading.value = true;
try {
await creativeApi.uploadSource(projectId, episodeId, file);
toast.success("原视频已上传");
await load(false);
} catch (error) {
showError(error, "上传失败");
} finally {
sourceUploading.value = false;
}
}
function dropVideo(event: DragEvent) {
draggingVideo.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) void uploadSource(file);
}
async function uploadSubtitle(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
subtitleUploading.value = true;
try {
await creativeApi.uploadSubtitle(projectId, episodeId, file);
subtitleFileName.value = file.name;
audioSource.value = "subtitle_file";
await load(false);
await persistAnalysisSettings();
} catch (error) {
showError(error, "上传失败");
} finally {
subtitleUploading.value = false;
}
}
async function startAnalysis() {
if (analysisTask.value) return stopReverseTask(analysisTask.value);
if (!data.value?.source.source_video_url) return toast.warning("请先上传原视频");
if (hasAnalysisResult.value) {
try {
await ElMessageBox.confirm("全部重新反推将替换当前分镜提示词和剧本,资产名称会保留。是否继续?", "全部重新反推", {
type: "warning",
confirmButtonText: "继续",
cancelButtonText: "取消",
});
} catch {
return;
}
}
startingAnalysis.value = true;
try {
await creativeApi.saveAnalysisSettings(projectId, episodeId, {
audio_source: audioSource.value,
source_language: sourceLanguage.value,
});
const task = hasAnalysisResult.value
? await creativeApi.reanalyze(projectId, episodeId)
: await creativeApi.analyze(projectId, episodeId);
tasks.value.unshift(task);
await load(false);
} catch (error) {
showError(error, "反推失败");
} finally {
startingAnalysis.value = false;
}
}
async function saveAsset(assetId: string, payload: Partial<ProjectAsset>) {
assetBusy.value = true;
try {
await creativeApi.updateAsset(projectId, assetId, { name: payload.name });
await load(false);
} catch (error) {
showError(error, "保存失败");
} finally {
assetBusy.value = false;
}
}
async function deleteAsset(asset: ProjectAsset) {
try {
await ElMessageBox.confirm(
`确定删除资产“${asset.name}”吗?删除后,分镜提示词中的角色文字会保留,但 @ 标签会移除。`,
"删除资产",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
},
);
assetBusy.value = true;
await creativeApi.deleteAsset(projectId, asset.id);
await load(false);
toast.success("资产已删除");
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除资产失败");
} finally {
assetBusy.value = false;
}
}
async function saveStoryboard(payload: Partial<Storyboard>) {
if (!selected.value) return;
try {
await creativeApi.updateStoryboard(projectId, selected.value.id, payload);
await load(false);
} catch (error) {
showError(error, "保存失败");
}
}
async function insertStoryboard(storyboard: Storyboard, position: "before" | "after") {
try {
const created = await creativeApi.insertStoryboard(projectId, storyboard.id, position);
selectedId.value = created.id;
await load(false);
} catch (error) {
showError(error, "插入分镜失败");
}
}
async function deleteStoryboard(storyboard: Storyboard) {
try {
await ElMessageBox.confirm(`确定删除分镜 ${storyboard.sequence_no} 吗?`, "删除分镜", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteStoryboard(projectId, storyboard.id);
await load(false);
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除分镜失败");
}
}
/** 二次确认后重新反推当前分镜,取消时不创建任务。 */
async function reinferStoryboard() {
if (!selected.value) return;
try {
await ElMessageBox.confirm("重新反推将仅替换当前分镜的反推结果,是否继续?", "重新反推当前分镜", {
type: "warning",
confirmButtonText: "继续反推",
cancelButtonText: "取消",
});
} catch {
return;
}
startingReinfer.value = true;
try {
tasks.value.unshift(await creativeApi.reinferStoryboard(projectId, episodeId, selected.value.id));
} catch (error) {
showError(error, "反推失败");
} finally {
startingReinfer.value = false;
}
}
async function reverseScript() {
if (!scriptReady.value) return;
if (scriptContent.value.trim()) {
try {
await ElMessageBox.confirm("重新反推将替换当前完整剧本,是否继续?", "重新反推为剧本", {
type: "warning",
confirmButtonText: "继续反推",
cancelButtonText: "取消",
});
} catch {
return;
}
}
if (!(await saveScript())) return;
startingScript.value = true;
try {
const task = await creativeApi.reverseRedrawScript(projectId, episodeId);
tasks.value.unshift(task);
toast.success("完整剧本反推任务已开始");
await load(false);
} catch (error) {
showError(error, "剧本反推失败");
} finally {
startingScript.value = false;
}
}
async function stopReverseTask(task: GenerationTask) {
try {
await ElMessageBox.confirm("确定停止当前反推任务吗?迟到结果将被丢弃,已扣积分不退。", "停止反推", {
type: "warning",
confirmButtonText: "确认停止",
cancelButtonText: "继续反推",
});
} catch (error) {
if (error === "cancel" || error === "close") return;
throw error;
}
try {
task.status = "cancel_requested";
await creativeApi.cancelTask(projectId, task.id);
await load(false);
} catch (error) {
showError(error, "停止反推失败");
}
}
async function refreshTasks() {
if (polling.value) return;
polling.value = true;
try {
const previous = new Map(tasks.value.map((item) => [item.id, item.status]));
const next = await creativeApi.tasks(projectId, episodeId);
const changed =
next.map((item) => `${item.id}:${item.status}`).join("|") !==
tasks.value.map((item) => `${item.id}:${item.status}`).join("|");
tasks.value = next;
const failedTasks = next.filter(
(item) => item.task_type === "prompt_reverse" && item.status === "failed" && previous.get(item.id) !== "failed",
);
for (const task of failedTasks) {
const title = task.mode === "script" ? "剧本反推失败" : task.storyboard_id ? "分镜反推失败" : "视频反推失败";
showTaskFailure(task, title);
}
if (changed || hasActiveTask.value) data.value = await creativeApi.redrawWorkbench(projectId, episodeId);
} finally {
polling.value = false;
}
}
function showTaskFailure(task: GenerationTask, title: string) {
const message = task.error_message?.trim() || title;
const detail = [`错误码:${task.error_code || "task_failed"}`, `错误描述:${message}`, `任务 ID:${task.id}`].join(
"\n",
);
toast.error(message, detail, title);
}
function showError(error: unknown, title: string) {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
let timer: ReturnType<typeof setTimeout> | undefined;
let clockTimer: ReturnType<typeof setInterval> | undefined;
let disposed = false;
async function pollTasks() {
await refreshTasks();
if (!disposed) timer = setTimeout(pollTasks, hasActiveTask.value ? 1000 : 5000);
}
onMounted(async () => {
await Promise.all([load(), loadModelOptions()]);
timer = setTimeout(pollTasks, 1000);
clockTimer = setInterval(() => (taskClock.value = Date.now()), 1000);
});
onBeforeUnmount(() => {
disposed = true;
if (timer) clearTimeout(timer);
if (clockTimer) clearInterval(clockTimer);
if (scriptSaveTimer) clearTimeout(scriptSaveTimer);
if (scriptContent.value !== savedScriptContent.value) void saveScript();
});
</script>
<template>
<CreativeShell
class="workbench-shell"
@dragenter.prevent="draggingVideo = true"
@dragover.prevent="draggingVideo = true"
@dragleave.prevent="draggingVideo = false"
@drop.prevent="dropVideo"
>
<section class="analysis-bar">
<div class="analysis-controls">
<RouterLink
class="analysis-back"
:to="{ name: 'redraw-project', params: { projectId } }"
><ArrowLeft :size="18"
/></RouterLink>
<label
><span>台词来源</span
><CreativeSelect
v-model="audioSource"
:options="audioSourceOptions"
/></label>
<label v-if="audioSource === 'video_audio'"
><span>源语言</span
><CreativeSelect
v-model="sourceLanguage"
:options="languageOptions"
clearable
placeholder="自动识别"
/></label>
<input
v-if="audioSource === 'subtitle_file'"
ref="subtitleInput"
class="source-file-input"
type="file"
accept=".srt,.vtt,.txt"
:disabled="subtitleUploading"
@change="uploadSubtitle"
/>
<el-button
v-if="audioSource === 'subtitle_file'"
:icon="Document"
:loading="subtitleUploading"
@click="subtitleInput?.click()"
>{{ subtitleFileName || "上传字幕" }}</el-button
>
</div>
<div class="analysis-actions creative-page-actions">
<el-button
:icon="Setting"
@click="openModels"
>模型配置</el-button
>
<el-button
:type="analysisTask ? 'danger' : 'primary'"
:icon="analysisTask ? CircleClose : undefined"
:disabled="analysisDisabled"
:loading="startingAnalysis"
@click="startAnalysis"
>{{ analysisButtonText }}</el-button
>
</div>
</section>
<div
v-loading="loading"
class="workbench-grid"
>
<RedrawSourcePanel
:source="data?.source"
:uploading="sourceUploading"
:dragging="draggingVideo"
@upload="uploadSource"
/>
<RedrawAssetPanel
:assets="data?.assets || []"
:busy="assetBusy"
@save="saveAsset"
@delete="deleteAsset"
/>
<RedrawTextPanel
:storyboards="data?.storyboards || []"
:assets="data?.assets || []"
:selected-id="selectedId"
:reinfer-task="reinferTask"
:now="taskClock"
:reinferring="startingReinfer"
@save="saveStoryboard"
@reinfer="reinferStoryboard"
@stop-reinfer="stopReverseTask"
/>
<RedrawScriptPanel
:content="scriptContent"
:ready="scriptReady"
:task="scriptTask"
:starting="startingScript"
:saving="savingScript"
:save-state="scriptSaveState"
:now="taskClock"
@update:content="
(content) => {
scriptContent = content;
scheduleScriptSave();
}
"
@save="saveScript"
@reverse="reverseScript"
@stop="stopReverseTask"
/>
</div>
<StoryboardTimeline
:storyboards="data?.storyboards || []"
:selected-id="selectedId"
:tasks="tasks"
@select="(item) => (selectedId = item.id)"
@insert="insertStoryboard"
@delete="deleteStoryboard"
/>
<ModelConfigDialog
v-if="data?.project"
v-model="modelsOpen"
analysis-only
:options="options"
:configs="data.project.model_configs"
:prompts="prompts"
:prompt-selections="promptSelections"
:saving="savingModels"
@save="saveModels"
@prompt-select="selectPrompt"
@prompt-create="createPrompt"
@prompt-update="updatePrompt"
@prompt-delete="deletePrompt"
/>
</CreativeShell>
</template>
<style scoped lang="scss">
.workbench-shell {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.workbench-shell :deep(.creative-shell__scroll) {
min-height: 0;
padding-bottom: 16px;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.source-file-input {
display: none;
}
.analysis-bar {
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-lg);
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
}
.analysis-controls,
.analysis-actions {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.analysis-controls {
flex-wrap: wrap;
}
.analysis-actions {
justify-self: end;
}
.analysis-controls label {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
}
.analysis-controls label > span {
flex: 0 0 auto;
color: var(--web-text-secondary);
font-size: 12px;
}
.analysis-controls .creative-select {
width: 150px;
}
.analysis-controls :deep(.el-button) {
max-width: 220px;
}
.analysis-controls :deep(.el-button > span) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.analysis-back {
width: 36px;
height: 36px;
display: grid;
place-items: center;
flex: 0 0 36px;
color: var(--web-text-secondary);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 7px;
}
.workbench-grid {
min-height: 0;
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(220px, 0.9fr) minmax(300px, 1.25fr) minmax(280px, 1.1fr);
flex: 1;
gap: var(--space-md);
}
.workbench-grid + :deep(.timeline-panel) {
margin-top: var(--space-md);
flex: 0 0 auto;
}
.workbench-grid > :deep(.redraw-column) {
min-height: 0;
background: var(--web-surface);
border: 1px solid var(--web-line);
border-radius: var(--radius-lg);
overflow: hidden;
}
.workbench-grid > :deep(.redraw-column > header) {
height: 42px;
padding: 0 13px;
display: flex;
align-items: center;
gap: 7px;
flex: 0 0 42px;
border-bottom: 1px solid var(--web-line-strong);
}
.workbench-grid > :deep(.redraw-column > header small) {
color: var(--web-script-hint);
font-size: 11px;
}
@media (max-width: 1400px) {
.workbench-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: auto;
}
.workbench-grid > :deep(.redraw-column) {
min-height: 420px;
}
}
@media (max-width: 900px) {
.workbench-shell,
.workbench-shell :deep(.creative-shell__scroll) {
height: auto;
overflow: visible;
}
.analysis-bar {
display: flex;
align-items: stretch;
flex-direction: column;
}
.analysis-controls,
.analysis-actions {
align-items: stretch;
flex-direction: column;
}
.analysis-controls label {
align-items: stretch;
flex-direction: column;
}
.analysis-controls .creative-select {
width: 100%;
}
.workbench-grid {
grid-template-columns: 1fr;
overflow: visible;
}
.workbench-grid > :deep(.redraw-column) {
min-height: 360px;
}
}
</style>
+321
View File
@@ -0,0 +1,321 @@
<!-- 剧本分析列表页,负责分析记录管理和模型配置入口。 -->
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { Setting } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { FileSearch, Plus, Trash2 } from "@/icons";
import { useRouter } from "vue-router";
import ProjectSearchField from "@/components/ProjectSearchField.vue";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import { creativeApi, type CreativeOptions, type ModelConfig, type ScriptAnalysis } from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { formatDateTime } from "@/utils/datetime";
import { useModelPrompts } from "@/composables/useModelPrompts";
import { ensureScriptAnalysisTextModel } from "@/utils/scriptAnalysisModel";
const router = useRouter();
const toast = useToastStore();
const scripts = ref<ScriptAnalysis[]>([]);
const options = ref<CreativeOptions>({ styles: [], models: [] });
const modelConfigs = ref<ModelConfig[]>([]);
const loading = ref(false);
const creating = ref(false);
const modelsOpen = ref(false);
const savingModels = ref(false);
const keyword = ref("");
const filteredScripts = computed(() => {
const value = keyword.value.trim().toLocaleLowerCase();
if (!value) return scripts.value;
return scripts.value.filter((script) => (script.name || "未命名剧本").toLocaleLowerCase().includes(value));
});
const { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt } =
useModelPrompts();
function showError(error: unknown, title = "加载失败") {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
async function load() {
loading.value = true;
try {
const [loadedScripts, loadedOptions, loadedConfigs] = await Promise.all([
creativeApi.scriptAnalyses(),
creativeApi.options(),
creativeApi.userModelConfigs("script_analysis"),
]);
scripts.value = loadedScripts;
options.value = loadedOptions;
modelConfigs.value = await ensureScriptAnalysisTextModel(loadedOptions, loadedConfigs);
await loadPrompts(["剧本分析"]);
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function saveModels(payload: Record<string, any>) {
const modelId = payload.prompt_reverse?.model_id;
if (!modelId) return toast.warning("请选择文本模型");
savingModels.value = true;
try {
await creativeApi.saveUserModelConfig("script_analysis", "text", modelId);
modelConfigs.value = await creativeApi.userModelConfigs("script_analysis");
modelsOpen.value = false;
toast.success("模型配置已保存");
} catch (error) {
showError(error, "保存模型配置失败");
} finally {
savingModels.value = false;
}
}
async function createScript() {
if (creating.value) return;
creating.value = true;
try {
const script = await creativeApi.createScriptAnalysis();
await router.push({ name: "script-analysis-workbench", params: { scriptId: script.id } });
} catch (error) {
showError(error, "创建失败");
} finally {
creating.value = false;
}
}
async function removeScript(script: ScriptAnalysis) {
try {
await ElMessageBox.confirm(`确定删除“${script.name}”吗?`, "删除剧本", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await creativeApi.deleteScriptAnalysis(script.id);
toast.success("剧本已删除");
await load();
} catch (error) {
if (error !== "cancel" && error !== "close") showError(error, "删除失败");
}
}
onMounted(load);
</script>
<template>
<CreativeShell>
<section class="script-toolbar">
<ProjectSearchField
v-model="keyword"
placeholder="搜索剧本名称"
/>
<div class="creative-page-actions">
<span class="script-count">{{ filteredScripts.length }} 个剧本</span>
<el-button @click="modelsOpen = true"
><el-icon><Setting /></el-icon>模型配置</el-button
>
<el-button
type="primary"
:loading="creating"
@click="createScript"
>
<Plus :size="16" />新建剧本
</el-button>
</div>
</section>
<section
v-loading="loading"
class="script-grid"
>
<article
v-for="script in filteredScripts"
:key="script.id"
class="script-card"
@click="router.push({ name: 'script-analysis-workbench', params: { scriptId: script.id } })"
>
<div class="script-card__icon"><FileSearch :size="22" /></div>
<div class="script-card__body">
<strong>{{ script.name || "未命名剧本" }}</strong>
<time>{{ formatDateTime(script.updated_at) }}</time>
</div>
<button
type="button"
class="script-card__delete"
title="删除剧本"
@click.stop="removeScript(script)"
>
<Trash2 :size="17" />
</button>
</article>
<div
v-if="!loading && !filteredScripts.length"
class="script-empty"
>
<FileSearch
:size="40"
stroke-width="1.2"
/>
<strong>{{ keyword.trim() ? "没有匹配的剧本" : "还没有剧本" }}</strong>
<p>{{ keyword.trim() ? "换一个关键词继续查找" : "新建剧本后即可开始分析" }}</p>
<el-button
v-if="!keyword.trim()"
type="primary"
:loading="creating"
@click="createScript"
>
<Plus :size="16" />新建剧本
</el-button>
</div>
</section>
<ModelConfigDialog
v-model="modelsOpen"
analysis-only
project-type="script_analysis"
:options="options"
:configs="modelConfigs"
:saving="savingModels"
:prompts="prompts"
:prompt-selections="promptSelections"
@save="saveModels"
@prompt-select="selectPrompt"
@prompt-create="createPrompt"
@prompt-update="updatePrompt"
@prompt-delete="deletePrompt"
/>
</CreativeShell>
</template>
<style scoped lang="scss">
.script-toolbar {
min-height: var(--control-height-lg);
margin-bottom: var(--space-xl);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
}
.script-toolbar .creative-page-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.script-count {
color: var(--web-project-count);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-regular);
white-space: nowrap;
}
.script-grid {
min-height: 260px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-items: start;
gap: var(--space-lg);
}
.script-card {
min-width: 0;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 13px;
position: relative;
color: var(--creative-text);
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: var(--radius-lg);
cursor: pointer;
transition:
border-color 0.18s,
background-color 0.18s;
}
.script-card:hover {
background: var(--web-hover);
border-color: var(--creative-accent);
}
.script-card__icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
display: grid;
place-items: center;
color: var(--creative-accent);
background: color-mix(in srgb, var(--creative-accent) 12%, transparent);
border-radius: 9px;
}
.script-card__body {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
}
.script-card__body strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
}
.script-card__body time {
color: var(--creative-muted);
font-size: 12px;
}
.script-card__delete {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: var(--creative-muted);
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
opacity: 1;
pointer-events: auto;
transition:
color 0.18s,
background-color 0.18s;
}
.script-card__delete:hover {
color: var(--web-danger);
background: color-mix(in srgb, var(--web-danger) 12%, transparent);
}
.script-empty {
min-height: 260px;
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: var(--creative-muted);
text-align: center;
}
.script-empty strong {
color: var(--creative-text);
font-size: 16px;
}
.script-empty p {
margin: 0;
font-size: 12px;
}
.script-empty :deep(.el-button) {
margin-top: var(--space-sm);
}
@media (max-width: 767px) {
.script-toolbar {
align-items: stretch;
flex-direction: column;
gap: var(--space-md);
}
.script-toolbar :deep(.project-search-field),
.script-toolbar .creative-page-actions {
width: 100%;
}
.script-toolbar .creative-page-actions {
justify-content: space-between;
}
.script-toolbar :deep(.el-button) {
flex: 1;
}
.script-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,544 @@
<!-- 剧本分析工作台,负责原文编辑、模型配置、分析任务和结果展示。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { CircleClose, Setting } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { ArrowLeft, Pencil, Sparkles } from "@/icons";
import { onBeforeRouteLeave, useRoute } from "vue-router";
import CreativeShell from "@/components/creative/CreativeShell.vue";
import ModelConfigDialog from "@/components/creative/ModelConfigDialog.vue";
import ScriptImportDialog from "@/components/creative/ScriptImportDialog.vue";
import ScriptAnalysisResult from "@/components/creative/ScriptAnalysisResult.vue";
import { creativeApi, type CreativeOptions, type ModelConfig, type ScriptAnalysis } from "@/api/creative";
import { apiError } from "@/api/http";
import { useToastStore } from "@/stores/toast";
import { useModelPrompts } from "@/composables/useModelPrompts";
import { ensureScriptAnalysisTextModel } from "@/utils/scriptAnalysisModel";
const route = useRoute();
const toast = useToastStore();
const script = ref<ScriptAnalysis | null>(null);
const options = ref<CreativeOptions>({ styles: [], models: [] });
const modelConfigs = ref<ModelConfig[]>([]);
const name = ref("");
const sourceContent = ref("");
const resultContent = ref("");
const loading = ref(false);
const saving = ref(false);
const modelsOpen = ref(false);
const savingModels = ref(false);
const editingName = ref(false);
const nameInputRef = ref<{ focus: () => void } | null>(null);
let activeSave: Promise<boolean> | null = null;
let pollingTimer: ReturnType<typeof setInterval> | undefined;
const { prompts, promptSelections, loadPrompts, selectPrompt, createPrompt, updatePrompt, deletePrompt } =
useModelPrompts();
const analysisActive = computed(
() => script.value?.analysis_status === "queued" || script.value?.analysis_status === "running",
);
const analysisCancelling = computed(() => analysisActive.value && script.value?.analysis_message === "正在取消分析");
const modelConfigured = computed(() =>
modelConfigs.value.some((config) => config.model_type === "text" && Boolean(config.model_id)),
);
const savedContent = ref({ name: "", sourceContent: "", resultContent: "" });
const hasUnsavedChanges = computed(() => {
return (
name.value !== savedContent.value.name ||
sourceContent.value !== savedContent.value.sourceContent ||
resultContent.value !== savedContent.value.resultContent
);
});
const nameInputWidth = computed(() => {
const text = name.value || "未命名剧本";
const textWidth = Array.from(text).reduce((width, character) => {
return width + (/^[\x00-\xff]$/.test(character) ? 0.6 : 1);
}, 0);
return `${Math.min(420, Math.max(160, Math.ceil(textWidth * 16 + 28)))}px`;
});
async function editName() {
editingName.value = true;
await nextTick();
nameInputRef.value?.focus();
}
function rememberSavedContent() {
savedContent.value = {
name: name.value,
sourceContent: sourceContent.value,
resultContent: resultContent.value,
};
}
async function autoSaveOnBlur() {
if (loading.value || !hasUnsavedChanges.value) return;
const saved = await save();
if (saved && hasUnsavedChanges.value) await save();
}
function finishNameEditing() {
editingName.value = false;
void autoSaveOnBlur();
}
function showError(error: unknown, title = "加载失败") {
const detail = apiError(error);
toast.error(detail.message, detail.copyText, title);
}
async function load() {
loading.value = true;
try {
const [loadedScript, loadedOptions, loadedConfigs] = await Promise.all([
creativeApi.scriptAnalysis(String(route.params.scriptId)),
creativeApi.options(),
creativeApi.userModelConfigs("script_analysis"),
]);
script.value = loadedScript;
options.value = loadedOptions;
modelConfigs.value = await ensureScriptAnalysisTextModel(loadedOptions, loadedConfigs);
await loadPrompts(["剧本分析"]);
name.value = script.value.name;
sourceContent.value = script.value.source_content;
resultContent.value = script.value.result_content;
rememberSavedContent();
syncPolling();
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function refreshAnalysis() {
if (!script.value) return;
try {
const updated = await creativeApi.scriptAnalysis(script.value.id);
script.value = updated;
resultContent.value = updated.result_content;
savedContent.value.resultContent = updated.result_content;
syncPolling();
} catch (error) {
stopPolling();
showError(error, "刷新分析状态失败");
}
}
function syncPolling() {
if (analysisActive.value && !pollingTimer) pollingTimer = setInterval(refreshAnalysis, 2500);
else if (!analysisActive.value) stopPolling();
}
function stopPolling() {
if (pollingTimer) clearInterval(pollingTimer);
pollingTimer = undefined;
}
async function saveModels(payload: Record<string, any>) {
const modelId = payload.prompt_reverse?.model_id;
if (!modelId) return toast.warning("请选择文本模型");
savingModels.value = true;
try {
await creativeApi.saveUserModelConfig("script_analysis", "text", modelId);
modelConfigs.value = await creativeApi.userModelConfigs("script_analysis");
modelsOpen.value = false;
toast.success("模型配置已保存");
} catch (error) {
showError(error, "保存模型配置失败");
} finally {
savingModels.value = false;
}
}
async function persistCurrentContent() {
if (!script.value) return false;
const submitted = {
name: name.value,
sourceContent: sourceContent.value,
resultContent: resultContent.value,
};
saving.value = true;
try {
const updated = await creativeApi.updateScriptAnalysis(script.value.id, {
name: submitted.name.trim() || "未命名剧本",
source_content: submitted.sourceContent,
result_content: submitted.resultContent,
});
script.value = updated;
if (name.value === submitted.name) name.value = updated.name;
if (sourceContent.value === submitted.sourceContent) sourceContent.value = updated.source_content;
if (resultContent.value === submitted.resultContent) resultContent.value = updated.result_content;
savedContent.value = {
name: updated.name,
sourceContent: updated.source_content,
resultContent: updated.result_content,
};
editingName.value = false;
return true;
} catch (error) {
showError(error, "保存失败");
return false;
} finally {
saving.value = false;
}
}
function save() {
if (activeSave) return activeSave;
activeSave = persistCurrentContent().finally(() => {
activeSave = null;
});
return activeSave;
}
async function analyze() {
if (!script.value || analysisActive.value) return;
if (!modelConfigured.value) {
modelsOpen.value = true;
toast.warning("请先选择并保存剧本分析文本模型");
return;
}
if (!sourceContent.value.trim()) {
toast.warning("请先填写剧本原文");
return;
}
if (!(await save())) return;
try {
await creativeApi.analyzeScriptAnalysis(script.value.id);
script.value.analysis_status = "queued";
script.value.analysis_message = "剧本分析排队中";
syncPolling();
toast.success("剧本分析任务已提交");
} catch (error) {
showError(error, "开始分析失败");
}
}
async function cancelAnalysis() {
if (!script.value || !analysisActive.value) return;
try {
await ElMessageBox.confirm("确定取消当前剧本分析任务吗?取消后已扣积分不退。", "取消分析", {
type: "warning",
confirmButtonText: "确认取消",
cancelButtonText: "继续分析",
});
} catch (error) {
if (error === "cancel" || error === "close") return;
throw error;
}
try {
await creativeApi.cancelScriptAnalysis(script.value.id);
script.value.analysis_message = "正在取消分析";
await refreshAnalysis();
} catch (error) {
showError(error, "取消分析失败");
}
}
function handleAnalysisAction() {
if (analysisActive.value) void cancelAnalysis();
else void analyze();
}
function applyImportedScript(imported: ScriptAnalysis) {
script.value = imported;
name.value = imported.name;
sourceContent.value = imported.source_content;
resultContent.value = imported.result_content;
rememberSavedContent();
}
async function prepareScriptImport() {
if (activeSave && !(await activeSave)) return false;
if (hasUnsavedChanges.value && !(await save())) return false;
return true;
}
onBeforeRouteLeave(async () => {
if (activeSave && !(await activeSave)) return false;
if (!hasUnsavedChanges.value) return true;
return save();
});
onMounted(() => {
void load();
});
onBeforeUnmount(stopPolling);
</script>
<template>
<CreativeShell class="script-workbench-shell">
<section class="script-context-bar">
<div class="workbench-heading">
<RouterLink
class="back-button"
:to="{ name: 'script-analysis' }"
title="返回"
>
<ArrowLeft :size="18" />
</RouterLink>
<el-input
v-if="editingName"
ref="nameInputRef"
v-model="name"
class="name-input"
:style="{ '--name-input-width': nameInputWidth }"
maxlength="120"
placeholder="未命名剧本"
@blur="finishNameEditing"
/>
<div
v-else
class="name-display"
>
<strong>{{ name || "未命名剧本" }}</strong>
<button
type="button"
class="name-edit-button"
title="编辑剧本名"
@click="editName"
>
<Pencil :size="15" />
</button>
</div>
</div>
<div class="script-context-actions creative-page-actions">
<el-button @click="modelsOpen = true"
><el-icon><Setting /></el-icon>模型配置</el-button
>
<ScriptImportDialog
v-if="script"
:script-id="script.id"
:disabled="loading || analysisActive"
:before-import="prepareScriptImport"
@imported="applyImportedScript"
/>
<el-button
class="analyze-button"
:type="analysisActive ? 'danger' : 'primary'"
:loading="saving"
:disabled="loading || analysisCancelling || (!analysisActive && !sourceContent.trim())"
@click="handleAnalysisAction"
><el-icon v-if="analysisActive"><CircleClose /></el-icon
><Sparkles
v-else
:size="16"
/>{{
analysisActive
? analysisCancelling
? "取消中"
: "取消任务"
: script?.analysis_status === "succeeded" || script?.analysis_status === "failed"
? "重新分析"
: "开始分析"
}}</el-button
>
</div>
</section>
<section
v-loading="loading"
class="workbench-panels"
>
<article class="workbench-panel">
<header>
<strong>剧本原文</strong><span>{{ sourceContent.length }} 字</span>
</header>
<el-input
v-model="sourceContent"
type="textarea"
resize="none"
placeholder="在这里粘贴或编辑剧本原文"
@blur="autoSaveOnBlur"
/>
</article>
<ScriptAnalysisResult
:result="script?.analysis_result"
:characters="script?.characters"
:status="script?.analysis_status"
:message="script?.analysis_message"
/>
</section>
<ModelConfigDialog
v-model="modelsOpen"
analysis-only
project-type="script_analysis"
:options="options"
:configs="modelConfigs"
:saving="savingModels"
:prompts="prompts"
:prompt-selections="promptSelections"
@save="saveModels"
@prompt-select="selectPrompt"
@prompt-create="createPrompt"
@prompt-update="updatePrompt"
@prompt-delete="deletePrompt"
/>
</CreativeShell>
</template>
<style scoped lang="scss">
.script-workbench-shell {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.script-workbench-shell :deep(.creative-shell__scroll) {
min-height: 0;
padding-bottom: 20px;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.script-context-bar {
min-height: var(--control-height-lg);
margin-bottom: var(--space-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
}
.workbench-heading {
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
}
.back-button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 38px;
color: var(--web-accent-muted);
background: var(--web-surface);
border: 1px solid var(--web-line-strong);
border-radius: 9px;
}
.back-button:hover {
color: var(--web-text);
border-color: var(--web-line-emphasis);
}
.name-display {
min-width: 0;
max-width: min(420px, 50vw);
display: flex;
align-items: center;
gap: 8px;
}
.name-display strong {
min-width: 0;
overflow: hidden;
color: var(--creative-text);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.name-edit-button {
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
flex: 0 0 30px;
color: var(--creative-muted);
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
}
.name-edit-button:hover {
color: var(--creative-accent);
background: var(--web-hover);
}
.name-input {
width: min(var(--name-input-width), 50vw);
}
.name-input :deep(.el-input__wrapper) {
min-height: 42px;
padding: 0 12px;
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 9px;
box-shadow: none;
}
.name-input :deep(.el-input__wrapper:hover) {
border-color: var(--web-line-emphasis);
}
.name-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--creative-accent);
box-shadow: 0 0 0 1px var(--creative-accent);
}
.name-input :deep(.el-input__inner) {
color: var(--creative-text);
font-size: 16px;
font-weight: 600;
}
.workbench-panels {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
flex: 1;
gap: 16px;
}
.analyze-button {
font-weight: 400;
}
.workbench-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--creative-surface);
border: 1px solid var(--creative-line);
border-radius: 12px;
overflow: hidden;
}
.workbench-panel header {
min-height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--creative-line);
}
.workbench-panel header strong {
font-size: 14px;
}
.workbench-panel header span {
color: var(--creative-muted);
font-size: 12px;
}
.workbench-panel :deep(.el-textarea),
.workbench-panel :deep(.el-textarea__inner) {
height: 100%;
flex: 1;
}
.workbench-panel :deep(.el-textarea__inner) {
min-height: 0 !important;
padding: 16px;
color: var(--creative-text);
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
font-size: 14px;
line-height: 1.75;
}
@media (max-width: 800px) {
.script-workbench-shell,
.script-workbench-shell :deep(.creative-shell__scroll) {
height: auto;
overflow: visible;
}
.workbench-heading {
flex: 1;
}
.name-display {
max-width: 100%;
}
.name-input {
width: 100%;
}
.script-context-bar {
align-items: stretch;
flex-direction: column;
}
.script-context-actions {
width: 100%;
flex-wrap: wrap;
}
.workbench-panels {
min-height: auto;
grid-template-columns: 1fr;
}
.workbench-panel {
min-height: 420px;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"include": ["vite.config.ts"],
"compilerOptions": {
"composite": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}
+47
View File
@@ -0,0 +1,47 @@
// 剧游AI用户端 Vite 构建配置,配置 Vue 插件、路径别名、代理与输出目录
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { findAvailablePort } from '../vite-port'
const devHost = '127.0.0.1'
const devPort = await findAvailablePort(devHost, 5501)
const apiProxy = {
'/api': {
target: 'http://127.0.0.1:8123',
changeOrigin: true,
},
}
export default defineConfig({
base: '/',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
build: {
outDir: '../API/static/web',
emptyOutDir: true,
},
server: {
host: devHost,
port: devPort,
open: false,
proxy: apiProxy,
},
preview: {
proxy: apiProxy,
},
})