初始化
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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>
|
||||
Generated
+2944
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
import ToastContainer from "@/components/ToastContainer.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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$/, "")} 秒`;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user