Initial AI manga platform
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Manga User App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "AI Manga",
|
||||
"appid": "__UNI__AI_MANGA",
|
||||
"description": "AI manga short drama user app, H5 first with mini program and app route reservations",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"h5": {
|
||||
"title": "AI Manga"
|
||||
},
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
}
|
||||
},
|
||||
"app-plus": {
|
||||
"safearea": {
|
||||
"bottom": {
|
||||
"offset": "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "user-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "vite --host 0.0.0.0 --port ${USER_APP_PORT:-5174}",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
|
||||
"lint": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "src/pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "漫剧制作台"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/auth/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/help/tutorial",
|
||||
"style": {
|
||||
"navigationBarTitleText": "新手教程"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "新建项目"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/user/projects",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的项目"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/source-select",
|
||||
"style": {
|
||||
"navigationBarTitleText": "来源选择"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/original-setting",
|
||||
"style": {
|
||||
"navigationBarTitleText": "原创设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/upload-novel",
|
||||
"style": {
|
||||
"navigationBarTitleText": "上传小说"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/copyright",
|
||||
"style": {
|
||||
"navigationBarTitleText": "版权确认"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/story-bible",
|
||||
"style": {
|
||||
"navigationBarTitleText": "故事圣经"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/characters",
|
||||
"style": {
|
||||
"navigationBarTitleText": "角色库"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/episodes",
|
||||
"style": {
|
||||
"navigationBarTitleText": "分集计划"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/storyboard",
|
||||
"style": {
|
||||
"navigationBarTitleText": "脚本分镜"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/progress",
|
||||
"style": {
|
||||
"navigationBarTitleText": "生成进度"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/projects/result",
|
||||
"style": {
|
||||
"navigationBarTitleText": "成品漫剧"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/pages/user/profile",
|
||||
"style": {
|
||||
"navigationBarTitleText": "用户中心"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "漫剧制作台",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f4f6f8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from './pages/index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
interface ApiEnvelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
interface ApiCryptoHandshake {
|
||||
version: number;
|
||||
algorithm: string;
|
||||
session_id: string;
|
||||
server_public_key: JsonWebKey;
|
||||
salt: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface ClientConfig {
|
||||
api_crypto_enabled: boolean;
|
||||
api_crypto_mode: string;
|
||||
api_crypto_session_ttl_seconds: number;
|
||||
}
|
||||
|
||||
interface ApiCryptoEnvelope {
|
||||
encrypted?: boolean;
|
||||
version: number;
|
||||
session_id: string;
|
||||
client_public_key?: JsonWebKey;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
interface ApiCryptoSession {
|
||||
sessionId: string;
|
||||
clientPublicKey: JsonWebKey;
|
||||
aesKey: CryptoKey;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface EncryptedUploadFilePayload {
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
content_base64: string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function getWebCrypto() {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。');
|
||||
}
|
||||
|
||||
return globalThis.crypto;
|
||||
}
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
|
||||
return binary;
|
||||
}
|
||||
|
||||
function binaryToBytes(binary: string) {
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array) {
|
||||
return btoa(bytesToBinary(bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
|
||||
return binaryToBytes(atob(padded));
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
return btoa(bytesToBinary(bytes));
|
||||
}
|
||||
|
||||
export function base64ToBytes(value: string) {
|
||||
return binaryToBytes(atob(value));
|
||||
}
|
||||
|
||||
export async function fileToEncryptedUploadPayload(file: File): Promise<EncryptedUploadFilePayload> {
|
||||
return {
|
||||
original_name: file.name,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
content_base64: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
};
|
||||
}
|
||||
|
||||
function jsonToBase64Url(value: unknown) {
|
||||
return bytesToBase64Url(encoder.encode(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
record.version === 1 &&
|
||||
typeof record.session_id === 'string' &&
|
||||
typeof record.iv === 'string' &&
|
||||
typeof record.ciphertext === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export class ApiCryptoClient {
|
||||
private session: ApiCryptoSession | null = null;
|
||||
private pendingSession: Promise<ApiCryptoSession> | null = null;
|
||||
private enabledCache: { value: boolean; expiresAt: number } | null = null;
|
||||
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
async encryptionHeaders() {
|
||||
if (!(await this.isEnabled())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = await this.getSession();
|
||||
return this.buildHeaders(session);
|
||||
}
|
||||
|
||||
async encryptBody(body: unknown) {
|
||||
if (!(await this.isEnabled())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await this.getSession();
|
||||
const crypto = getWebCrypto();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
session.aesKey,
|
||||
encoder.encode(JSON.stringify(body ?? null))
|
||||
);
|
||||
|
||||
return {
|
||||
body: {
|
||||
version: 1,
|
||||
session_id: session.sessionId,
|
||||
client_public_key: session.clientPublicKey,
|
||||
iv: bytesToBase64Url(iv),
|
||||
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
|
||||
},
|
||||
headers: this.buildHeaders(session)
|
||||
};
|
||||
}
|
||||
|
||||
async isEnabled() {
|
||||
const mode = this.configuredMode();
|
||||
|
||||
if (['1', 'true', 'yes', 'on'].includes(mode)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(mode)) return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (this.enabledCache && this.enabledCache.expiresAt > now) {
|
||||
return this.enabledCache.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/client-config`, {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
const envelope = (await response.json()) as ApiEnvelope<ClientConfig>;
|
||||
const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled);
|
||||
|
||||
this.enabledCache = {
|
||||
value,
|
||||
expiresAt: now + 3000
|
||||
};
|
||||
|
||||
return value;
|
||||
} catch {
|
||||
this.enabledCache = {
|
||||
value: false,
|
||||
expiresAt: now + 3000
|
||||
};
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async decryptResponse<T>(payload: unknown) {
|
||||
if (!isEncryptedEnvelope(payload)) {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
const session = await this.getSession(payload.session_id);
|
||||
const plaintext = await getWebCrypto().subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) },
|
||||
session.aesKey,
|
||||
base64UrlToBytes(payload.ciphertext)
|
||||
);
|
||||
|
||||
return JSON.parse(decoder.decode(plaintext)) as T;
|
||||
}
|
||||
|
||||
private async getSession(expectedSessionId?: string) {
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
this.session &&
|
||||
this.session.expiresAt > now &&
|
||||
(!expectedSessionId || this.session.sessionId === expectedSessionId)
|
||||
) {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
if (expectedSessionId) {
|
||||
throw new Error('API 加密会话已失效,请刷新页面后重试。');
|
||||
}
|
||||
|
||||
if (!this.pendingSession) {
|
||||
this.pendingSession = this.createSession().finally(() => {
|
||||
this.pendingSession = null;
|
||||
});
|
||||
}
|
||||
|
||||
this.session = await this.pendingSession;
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private async createSession() {
|
||||
const response = await fetch(`${this.baseUrl}/crypto/handshake`, {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
const envelope = (await response.json()) as ApiEnvelope<ApiCryptoHandshake>;
|
||||
|
||||
if (!response.ok || envelope.code !== 0) {
|
||||
throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const crypto = getWebCrypto();
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits']
|
||||
);
|
||||
const serverPublicKey = await crypto.subtle.importKey(
|
||||
'jwk',
|
||||
envelope.data.server_public_key,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: serverPublicKey },
|
||||
keyPair.privateKey,
|
||||
256
|
||||
);
|
||||
const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [
|
||||
'deriveKey'
|
||||
]);
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: base64UrlToBytes(envelope.data.salt),
|
||||
info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`)
|
||||
},
|
||||
hkdfKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
|
||||
return {
|
||||
sessionId: envelope.data.session_id,
|
||||
clientPublicKey,
|
||||
aesKey,
|
||||
expiresAt: Date.parse(envelope.data.expires_at) - 30_000
|
||||
};
|
||||
}
|
||||
|
||||
private buildHeaders(session: ApiCryptoSession) {
|
||||
return {
|
||||
'x-api-encrypted': 'v1',
|
||||
'x-api-session-id': session.sessionId,
|
||||
'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey)
|
||||
};
|
||||
}
|
||||
|
||||
private configuredMode() {
|
||||
return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
const tutorialConcepts = [
|
||||
{
|
||||
title: '故事圣经',
|
||||
text: '整部作品的设定规则书,包含主线、世界观、风格、禁区和改编口径。'
|
||||
},
|
||||
{
|
||||
title: '角色圣经',
|
||||
text: '角色库,记录主角、反派、配角的身份、外貌、关系和一致性要求。'
|
||||
},
|
||||
{
|
||||
title: '场景',
|
||||
text: '场景字段由 AI 在脚本和分镜阶段生成,包括场景名、地点、画面、动作和镜头运动。'
|
||||
},
|
||||
{
|
||||
title: '额度',
|
||||
text: '内部测试不展示支付,余额由后台人工增加。真实图片、语音和视频生成会消耗额度。'
|
||||
}
|
||||
];
|
||||
|
||||
const tutorialSteps = [
|
||||
'新建项目,选择 AI 原创小说或上传小说改编。',
|
||||
'准备小说来源;上传小说先做版权确认,再解析章节。',
|
||||
'生成并确认故事圣经,统一整部作品的方向。',
|
||||
'抽取并确认角色库,需要一致性的角色再生成锚点图。',
|
||||
'生成分集计划、单集脚本和分镜。',
|
||||
'确认分镜后生成图片、音频、字幕并合成视频。',
|
||||
'完成文本/视频审核后预览、下载或授权公开案例。'
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="user-shell">
|
||||
<section class="app-main tutorial-page">
|
||||
<section class="panel tutorial-hero">
|
||||
<div>
|
||||
<span class="eyebrow">Guide</span>
|
||||
<h1>新手教程</h1>
|
||||
<p>先看懂这些节点,再开始跑一集。详细版在项目根目录 OPERATION_GUIDE.md。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Concepts</span>
|
||||
<h2>核心概念</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<article v-for="item in tutorialConcepts" :key="item.title" class="mini-card tutorial-card">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.text }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Flow</span>
|
||||
<h2>一集操作顺序</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tutorial-steps">
|
||||
<article v-for="(step, index) in tutorialSteps" :key="step" class="tutorial-step">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<div>
|
||||
<strong>第 {{ index + 1 }} 步</strong>
|
||||
<p>{{ step }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import IndexPage from '../index/index.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<IndexPage />
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import type { MediaAssetRow, SafeProject, SafeRenderTask } from './api/client';
|
||||
|
||||
export const projectStatusLabels: Record<string, string> = {
|
||||
source_selecting: '选择来源',
|
||||
novel_generating: '原创小说',
|
||||
novel_uploaded: '文本就绪',
|
||||
copyright_pending: '版权确认',
|
||||
copyright_confirmed: '版权已确认',
|
||||
text_parsing: '文本解析',
|
||||
text_parse_failed: '解析失败',
|
||||
story_bible_generating: '故事圣经',
|
||||
waiting_story_confirm: '待确认故事',
|
||||
story_confirmed: '故事已确认',
|
||||
character_extracting: '角色抽取',
|
||||
character_generating: '角色生成',
|
||||
waiting_character_confirm: '待确认角色',
|
||||
character_confirmed: '角色已确认',
|
||||
episode_planning: '分集计划',
|
||||
waiting_episode_confirm: '待确认分集',
|
||||
episode_confirmed: '分集已确认',
|
||||
script_generating: '脚本生成',
|
||||
waiting_script_confirm: '待确认脚本',
|
||||
script_confirmed: '脚本已确认',
|
||||
storyboard_generating: '分镜生成',
|
||||
waiting_storyboard_confirm: '待确认分镜',
|
||||
storyboard_confirmed: '分镜已确认',
|
||||
preview_images_generated: '预览图完成',
|
||||
final_images_generated: '正式图完成',
|
||||
audio_generated: '音频完成',
|
||||
subtitle_generated: '字幕完成',
|
||||
video_rendered: '视频完成',
|
||||
actor_profile_generated: '演员定妆',
|
||||
live_action_shots_prepared: '真人分镜',
|
||||
live_action_keyframes_generated: '关键帧',
|
||||
live_action_clips_generated: '视频片段',
|
||||
live_action_video_rendered: '真人短剧',
|
||||
completed: '已完成',
|
||||
manual_required: '人工介入',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
archived: '已归档'
|
||||
};
|
||||
|
||||
export const taskTypeLabels: Record<string, string> = {
|
||||
novel_generate: '小说生成',
|
||||
novel_parse: '小说解析',
|
||||
story_bible_generate: '故事圣经',
|
||||
character_extract: '角色抽取',
|
||||
episode_plan_generate: '分集计划',
|
||||
script_generate: '单集脚本',
|
||||
storyboard_generate: '分镜脚本',
|
||||
character_image_generate: '角色图',
|
||||
shot_image_generate: '分镜图',
|
||||
audio_generate: '多角色音频',
|
||||
subtitle_generate: '字幕',
|
||||
video_render: '视频合成',
|
||||
live_action_keyframe_generate: '真人关键帧',
|
||||
live_action_video_clip_generate: '真人视频片段',
|
||||
live_action_video_render: '真人短剧合成',
|
||||
qc_check: '质检',
|
||||
manual_review: '人工审核'
|
||||
};
|
||||
|
||||
export const workflowSteps = [
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'copyright', label: '版权' },
|
||||
{ key: 'story', label: '故事' },
|
||||
{ key: 'characters', label: '角色' },
|
||||
{ key: 'memory', label: '记忆' },
|
||||
{ key: 'episodes', label: '分集' },
|
||||
{ key: 'script', label: '脚本' },
|
||||
{ key: 'storyboard', label: '分镜' },
|
||||
{ key: 'images', label: '图片' },
|
||||
{ key: 'media', label: '音频字幕' },
|
||||
{ key: 'result', label: '成品' }
|
||||
] as const;
|
||||
|
||||
const statusStepIndex: Record<string, number> = {
|
||||
source_selecting: 0,
|
||||
novel_generating: 0,
|
||||
novel_uploaded: 1,
|
||||
copyright_pending: 1,
|
||||
copyright_confirmed: 1,
|
||||
text_parsing: 1,
|
||||
story_bible_generating: 2,
|
||||
waiting_story_confirm: 2,
|
||||
story_confirmed: 3,
|
||||
character_extracting: 3,
|
||||
character_generating: 3,
|
||||
waiting_character_confirm: 3,
|
||||
character_confirmed: 4,
|
||||
episode_planning: 5,
|
||||
waiting_episode_confirm: 5,
|
||||
episode_confirmed: 6,
|
||||
script_generating: 6,
|
||||
waiting_script_confirm: 6,
|
||||
script_confirmed: 7,
|
||||
storyboard_generating: 7,
|
||||
waiting_storyboard_confirm: 7,
|
||||
storyboard_confirmed: 8,
|
||||
character_image_generated: 8,
|
||||
preview_images_generated: 8,
|
||||
final_images_generated: 9,
|
||||
audio_generated: 9,
|
||||
subtitle_generated: 9,
|
||||
video_rendered: 10,
|
||||
actor_profile_generated: 4,
|
||||
live_action_shots_prepared: 8,
|
||||
live_action_keyframes_generated: 8,
|
||||
live_action_clips_generated: 9,
|
||||
live_action_video_rendered: 10,
|
||||
completed: 10
|
||||
};
|
||||
|
||||
export function labelProjectStatus(status?: string | null) {
|
||||
return status ? projectStatusLabels[status] ?? status : '未开始';
|
||||
}
|
||||
|
||||
export function labelTaskType(taskType: string) {
|
||||
return taskTypeLabels[taskType] ?? taskType;
|
||||
}
|
||||
|
||||
export function taskStatusTone(status: string) {
|
||||
if (status === 'success') return 'success';
|
||||
if (status === 'failed' || status === 'manual_required') return 'danger';
|
||||
if (status === 'running' || status === 'retrying') return 'running';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
export function progressOf(project: SafeProject | null, tasks: SafeRenderTask[], mediaAssets: MediaAssetRow[]) {
|
||||
if (!project) return 0;
|
||||
const baseIndex = statusStepIndex[project.status] ?? 0;
|
||||
const base = Math.round((baseIndex / (workflowSteps.length - 1)) * 100);
|
||||
const successfulTasks = tasks.filter((task) => task.status === 'success').length;
|
||||
const taskBonus = Math.min(12, successfulTasks * 2);
|
||||
const hasVideo = mediaAssets.some((row) => row.asset.asset_type === 'video');
|
||||
|
||||
return Math.min(100, Math.max(base, hasVideo ? 100 : base + taskBonus));
|
||||
}
|
||||
|
||||
export function formatBytes(size: string | null) {
|
||||
const value = Number(size ?? 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '-';
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vitest"
|
||||
],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: Number(process.env.USER_APP_PORT ?? 5174)
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user