fix: resolve all flake8 errors and apply Prettier formatting #131

Merged
xiaoxia merged 1 commits from fix/lint-errors into develop 2026-06-30 18:26:54 +08:00
76 changed files with 2161 additions and 1540 deletions
+13
View File
@@ -0,0 +1,13 @@
[flake8]
max-line-length = 120
exclude =
.git,
__pycache__,
.venv,
venv,
node_modules,
alembic
per-file-ignores =
tests/integration/*:F821
tests/unit/*:F821
+1 -1
View File
@@ -59,7 +59,7 @@ def _validate_video_mime_type(content_type: str | None) -> str:
if base_type not in ALLOWED_VIDEO_MIME_TYPES: if base_type not in ALLOWED_VIDEO_MIME_TYPES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp", detail="只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
) )
return base_type return base_type
+1 -1
View File
@@ -14,7 +14,7 @@ from fastapi import Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.redis import NoopSessionStore, SessionStore from packages.adapters.redis import NoopSessionStore, SessionStore
from packages.adapters.smtp import EmailConfig, NoopEmailService, get_email_service from packages.adapters.smtp import EmailConfig, EmailService, NoopEmailService, get_email_service
from packages.adapters.sqlalchemy_impl.asset_library_repository import ( from packages.adapters.sqlalchemy_impl.asset_library_repository import (
SQLAlchemyAssetLibraryRepository, SQLAlchemyAssetLibraryRepository,
) )
+22 -22
View File
@@ -2,8 +2,8 @@
* 素材相关 API * 素材相关 API
* Phase 1 重构:去掉 project_id,素材直接归属用户 * Phase 1 重构:去掉 project_id,素材直接归属用户
*/ */
import apiClient from './client'; import apiClient from "./client";
import { getOrCreateDefaultProject } from './projects'; import { getOrCreateDefaultProject } from "./projects";
/** 素材条目 */ /** 素材条目 */
export interface AssetItem { export interface AssetItem {
@@ -24,7 +24,7 @@ export interface AssetItem {
export interface AssetLibraryItem { export interface AssetLibraryItem {
id: string; id: string;
name: string; name: string;
kind: 'video' | 'voice' | 'image'; kind: "video" | "voice" | "image";
asset_count?: number; asset_count?: number;
total_size?: number; total_size?: number;
created_at?: string; created_at?: string;
@@ -35,7 +35,7 @@ export interface IngestJob {
id: string; id: string;
library_id: string; library_id: string;
storage_key: string; storage_key: string;
status: 'pending' | 'processing' | 'completed' | 'failed'; status: "pending" | "processing" | "completed" | "failed";
error_message: string; error_message: string;
result_asset_id: string; result_asset_id: string;
} }
@@ -44,7 +44,7 @@ export interface IngestJob {
export interface ClassificationJob { export interface ClassificationJob {
id: string; id: string;
asset_id: string; asset_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed'; status: "pending" | "processing" | "completed" | "failed";
classification: string; classification: string;
confidence: number; confidence: number;
error_message: string; error_message: string;
@@ -72,7 +72,7 @@ export interface AssetDiagnosis {
}>; }>;
gaps: Array<{ gaps: Array<{
key: string; key: string;
severity: 'critical' | 'warning' | 'info'; severity: "critical" | "warning" | "info";
message: string; message: string;
recommendation: string; recommendation: string;
}>; }>;
@@ -82,7 +82,7 @@ export interface AssetDiagnosis {
/** 获取当前用户的素材诊断信息 */ /** 获取当前用户的素材诊断信息 */
export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => { export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => {
const response = await apiClient.get('/asset-diagnosis'); const response = await apiClient.get("/asset-diagnosis");
return response.data; return response.data;
}; };
@@ -90,18 +90,18 @@ export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => {
/** 获取当前用户的所有素材库 */ /** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => { export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get('/asset-libraries'); const response = await apiClient.get("/asset-libraries");
return response.data.items || []; return response.data.items || [];
}; };
/** 创建素材库(自动获取或创建默认项目以提供 project_id */ /** 创建素材库(自动获取或创建默认项目以提供 project_id */
export const createAssetLibrary = async (data: { export const createAssetLibrary = async (data: {
name: string; name: string;
kind: 'video' | 'voice' | 'image'; kind: "video" | "voice" | "image";
}): Promise<AssetLibraryItem> => { }): Promise<AssetLibraryItem> => {
// 后端要求 project_id,前端自动管理默认项目 // 后端要求 project_id,前端自动管理默认项目
const project = await getOrCreateDefaultProject(); const project = await getOrCreateDefaultProject();
const response = await apiClient.post('/asset-libraries', { const response = await apiClient.post("/asset-libraries", {
project_id: project.id, project_id: project.id,
...data, ...data,
}); });
@@ -117,7 +117,7 @@ export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
/** 获取素材库下的所有素材 */ /** 获取素材库下的所有素材 */
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => { export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
const response = await apiClient.get('/assets', { const response = await apiClient.get("/assets", {
params: { library_id: libraryId }, params: { library_id: libraryId },
}); });
return response.data.items || []; return response.data.items || [];
@@ -126,7 +126,7 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
/** 更新素材审核状态 */ /** 更新素材审核状态 */
export const updateAssetReviewStatus = async ( export const updateAssetReviewStatus = async (
assetId: string, assetId: string,
reviewStatus: 'pending_review' | 'approved' | 'rejected' reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => { ): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, { const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus, review_status: reviewStatus,
@@ -143,10 +143,10 @@ export const deleteAsset = async (assetId: string): Promise<void> => {
/** 表单上传素材(小文件) */ /** 表单上传素材(小文件) */
export const uploadAsset = async ( export const uploadAsset = async (
formData: FormData formData: FormData,
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { ): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
const response = await apiClient.post('/upload', formData, { const response = await apiClient.post("/upload", formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000, timeout: 30 * 60 * 1000,
}); });
return response.data; return response.data;
@@ -167,7 +167,7 @@ export const prepareDirectUpload = async (data: {
fields: Record<string, string>; fields: Record<string, string>;
max_size_bytes: number; max_size_bytes: number;
}> => { }> => {
const response = await apiClient.post('/upload/direct/prepare', data); const response = await apiClient.post("/upload/direct/prepare", data);
return response.data; return response.data;
}; };
@@ -177,7 +177,7 @@ export const completeDirectUpload = async (data: {
library_id: string; library_id: string;
storage_key: string; storage_key: string;
}): Promise<{ storage_key: string; ingest_job_id: string }> => { }): Promise<{ storage_key: string; ingest_job_id: string }> => {
const response = await apiClient.post('/upload/direct/complete', data); const response = await apiClient.post("/upload/direct/complete", data);
return response.data; return response.data;
}; };
@@ -193,15 +193,15 @@ export const uploadAssetDirect = async (data: {
project_id: project.id, project_id: project.id,
library_id: data.library_id, library_id: data.library_id,
filename: data.file.name, filename: data.file.name,
content_type: data.file.type || 'application/octet-stream', content_type: data.file.type || "application/octet-stream",
file_size: data.file.size, file_size: data.file.size,
}); });
const directForm = new FormData(); const directForm = new FormData();
Object.entries(prepared.fields).forEach(([key, value]) => Object.entries(prepared.fields).forEach(([key, value]) =>
directForm.append(key, value) directForm.append(key, value),
); );
directForm.append('file', data.file); directForm.append("file", data.file);
const uploadResponse = await fetch(prepared.upload_url, { const uploadResponse = await fetch(prepared.upload_url, {
method: prepared.method, method: prepared.method,
@@ -230,13 +230,13 @@ export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
export const submitClassificationJob = async (data: { export const submitClassificationJob = async (data: {
asset_id: string; asset_id: string;
}): Promise<ClassificationJob> => { }): Promise<ClassificationJob> => {
const response = await apiClient.post('/classification-jobs', data); const response = await apiClient.post("/classification-jobs", data);
return response.data; return response.data;
}; };
/** 查询分类任务状态 */ /** 查询分类任务状态 */
export const getClassificationJob = async ( export const getClassificationJob = async (
jobId: string jobId: string,
): Promise<ClassificationJob> => { ): Promise<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`); const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data; return response.data;
+27 -27
View File
@@ -1,47 +1,47 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from "vitest";
import { normalizeUser } from './auth'; import { normalizeUser } from "./auth";
describe('normalizeUser', () => { describe("normalizeUser", () => {
it('normalizes canonical API current-user fields', () => { it("normalizes canonical API current-user fields", () => {
expect( expect(
normalizeUser({ normalizeUser({
user_id: 'user-1', user_id: "user-1",
email: 'user@example.com', email: "user@example.com",
username: 'user', username: "user",
display_name: 'User', display_name: "User",
email_verified: true, email_verified: true,
}) }),
).toEqual({ ).toEqual({
id: 'user-1', id: "user-1",
user_id: 'user-1', user_id: "user-1",
email: 'user@example.com', email: "user@example.com",
username: 'user', username: "user",
display_name: 'User', display_name: "User",
is_email_verified: true, is_email_verified: true,
email_verified: true, email_verified: true,
created_at: undefined, created_at: undefined,
}); });
}); });
it('keeps compatibility with legacy UI-shaped user fields', () => { it("keeps compatibility with legacy UI-shaped user fields", () => {
expect( expect(
normalizeUser({ normalizeUser({
id: 'user-2', id: "user-2",
email: 'legacy@example.com', email: "legacy@example.com",
username: 'legacy', username: "legacy",
display_name: 'Legacy', display_name: "Legacy",
is_email_verified: false, is_email_verified: false,
created_at: '2026-06-22T00:00:00Z', created_at: "2026-06-22T00:00:00Z",
}) }),
).toEqual({ ).toEqual({
id: 'user-2', id: "user-2",
user_id: 'user-2', user_id: "user-2",
email: 'legacy@example.com', email: "legacy@example.com",
username: 'legacy', username: "legacy",
display_name: 'Legacy', display_name: "Legacy",
is_email_verified: false, is_email_verified: false,
email_verified: false, email_verified: false,
created_at: '2026-06-22T00:00:00Z', created_at: "2026-06-22T00:00:00Z",
}); });
}); });
}); });
+19 -13
View File
@@ -1,7 +1,7 @@
/** /**
* 认证相关 API * 认证相关 API
*/ */
import apiClient from './client'; import apiClient from "./client";
// 类型定义 // 类型定义
export interface LoginRequest { export interface LoginRequest {
@@ -50,7 +50,7 @@ export interface UserResponse {
} }
export const normalizeUser = (data: UserResponse): User => { export const normalizeUser = (data: UserResponse): User => {
const userId = data.id ?? data.user_id ?? ''; const userId = data.id ?? data.user_id ?? "";
const emailVerified = data.is_email_verified ?? data.email_verified ?? false; const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
return { return {
@@ -67,39 +67,43 @@ export const normalizeUser = (data: UserResponse): User => {
// 登录 // 登录
export const login = async (data: LoginRequest): Promise<LoginResponse> => { export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post('/auth/login', data); const response = await apiClient.post("/auth/login", data);
return response.data; return response.data;
}; };
// 注册 // 注册
export const register = async (data: RegisterRequest): Promise<{ message: string }> => { export const register = async (
const response = await apiClient.post('/auth/register', data); data: RegisterRequest,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/register", data);
return response.data; return response.data;
}; };
// 登出 // 登出
export const logout = async (): Promise<void> => { export const logout = async (): Promise<void> => {
await apiClient.post('/auth/logout'); await apiClient.post("/auth/logout");
}; };
// 获取当前用户 // 获取当前用户
export const getCurrentUser = async (): Promise<User> => { export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>('/auth/me'); const response = await apiClient.get<UserResponse>("/auth/me");
return normalizeUser(response.data); return normalizeUser(response.data);
}; };
// 请求密码重置 // 请求密码重置
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => { export const requestPasswordReset = async (
const response = await apiClient.post('/auth/forgot-password', { email }); email: string,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/forgot-password", { email });
return response.data; return response.data;
}; };
// 重置密码 // 重置密码
export const resetPassword = async ( export const resetPassword = async (
token: string, token: string,
newPassword: string newPassword: string,
): Promise<{ message: string }> => { ): Promise<{ message: string }> => {
const response = await apiClient.post('/auth/reset-password', { const response = await apiClient.post("/auth/reset-password", {
token, token,
new_password: newPassword, new_password: newPassword,
}); });
@@ -107,7 +111,9 @@ export const resetPassword = async (
}; };
// 验证邮箱 // 验证邮箱
export const verifyEmail = async (token: string): Promise<{ message: string }> => { export const verifyEmail = async (
const response = await apiClient.post('/auth/verify-email', { token }); token: string,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/verify-email", { token });
return response.data; return response.data;
}; };
+18 -16
View File
@@ -2,23 +2,23 @@
* API 客户端配置 * API 客户端配置
* 封装 Axios 实例,配置拦截器和 Token 管理 * 封装 Axios 实例,配置拦截器和 Token 管理
*/ */
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { message } from 'antd'; import { message } from "antd";
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from "@/store/authStore";
// 创建 Axios 实例 // 创建 Axios 实例
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: '/api/v1', baseURL: "/api/v1",
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
// 请求拦截器:添加 Token // 请求拦截器:添加 Token
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('access_token'); const token = localStorage.getItem("access_token");
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@@ -26,13 +26,15 @@ apiClient.interceptors.request.use(
}, },
(error: AxiosError) => { (error: AxiosError) => {
return Promise.reject(error); return Promise.reject(error);
} },
); );
// 响应拦截器:统一错误提示 + 处理未授权状态 // 响应拦截器:统一错误提示 + 处理未授权状态
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { async (
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
// 401 → 清除登录态 // 401 → 清除登录态
if (error.response?.status === 401) { if (error.response?.status === 401) {
useAuthStore.getState().clearAuth(); useAuthStore.getState().clearAuth();
@@ -43,11 +45,11 @@ apiClient.interceptors.response.use(
const serverMsg = data?.detail || data?.message || data?.msg; const serverMsg = data?.detail || data?.message || data?.msg;
let handled = false; let handled = false;
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
message.error('请求超时,请检查网络后重试'); message.error("请求超时,请检查网络后重试");
handled = true; handled = true;
} else if (!error.response) { } else if (!error.response) {
message.error('网络连接异常,请检查网络设置'); message.error("网络连接异常,请检查网络设置");
handled = true; handled = true;
} else if (serverMsg) { } else if (serverMsg) {
message.error(serverMsg); message.error(serverMsg);
@@ -55,16 +57,16 @@ apiClient.interceptors.response.use(
} else { } else {
const status = error.response?.status; const status = error.response?.status;
if (status === 413) { if (status === 413) {
message.error('文件过大,请缩小后重试'); message.error("文件过大,请缩小后重试");
handled = true; handled = true;
} else if (status === 415) { } else if (status === 415) {
message.error('不支持的文件格式'); message.error("不支持的文件格式");
handled = true; handled = true;
} else if (status === 503) { } else if (status === 503) {
message.error('服务暂不可用,请稍后再试'); message.error("服务暂不可用,请稍后再试");
handled = true; handled = true;
} else if (status && status >= 500) { } else if (status && status >= 500) {
message.error('服务器繁忙,请稍后再试'); message.error("服务器繁忙,请稍后再试");
handled = true; handled = true;
} }
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理 // 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
@@ -77,7 +79,7 @@ apiClient.interceptors.response.use(
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default apiClient; export default apiClient;
+7 -8
View File
@@ -2,7 +2,7 @@
* 仪表盘 API * 仪表盘 API
* Phase 1 新增:用户仪表盘概览 * Phase 1 新增:用户仪表盘概览
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 仪表盘概览数据 */ /** 仪表盘概览数据 */
export interface DashboardOverview { export interface DashboardOverview {
@@ -29,15 +29,14 @@ export interface DashboardOverview {
}>; }>;
/** 订阅信息 */ /** 订阅信息 */
subscription: { subscription: {
plan: 'free' | 'pro' | 'enterprise'; plan: "free" | "pro" | "enterprise";
status: 'active' | 'inactive' | 'expired'; status: "active" | "inactive" | "expired";
expires_at?: string; expires_at?: string;
}; };
} }
/** 获取仪表盘概览数据 */ /** 获取仪表盘概览数据 */
export const getDashboardOverview = export const getDashboardOverview = async (): Promise<DashboardOverview> => {
async (): Promise<DashboardOverview> => { const response = await apiClient.get("/dashboard/overview");
const response = await apiClient.get('/dashboard/overview'); return response.data;
return response.data; };
};
+51 -48
View File
@@ -2,10 +2,11 @@
* 查重 API 模块 * 查重 API 模块
* 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换) * 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换)
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 查重记录状态 */ /** 查重记录状态 */
export type DuplicationStatus = 'pending' | 'processing' | 'completed' | 'failed'; export type DuplicationStatus =
"pending" | "processing" | "completed" | "failed";
/** 查重记录 */ /** 查重记录 */
export interface DuplicationRecord { export interface DuplicationRecord {
@@ -68,55 +69,55 @@ export interface DuplicationUploadResponse {
/** mock 查重记录列表 */ /** mock 查重记录列表 */
const MOCK_RECORDS: DuplicationRecord[] = [ const MOCK_RECORDS: DuplicationRecord[] = [
{ {
id: 'dup-001', id: "dup-001",
filename: '日常vlog_01.mp4', filename: "日常vlog_01.mp4",
file_size: 125_000_000, file_size: 125_000_000,
duration_seconds: 180, duration_seconds: 180,
status: 'completed', status: "completed",
duplicate_rate: 23.5, duplicate_rate: 23.5,
duplicate_count: 3, duplicate_count: 3,
created_at: '2026-06-27T10:00:00Z', created_at: "2026-06-27T10:00:00Z",
updated_at: '2026-06-27T10:05:00Z', updated_at: "2026-06-27T10:05:00Z",
}, },
{ {
id: 'dup-002', id: "dup-002",
filename: '美食分享_片段.mp4', filename: "美食分享_片段.mp4",
file_size: 45_000_000, file_size: 45_000_000,
duration_seconds: 60, duration_seconds: 60,
status: 'completed', status: "completed",
duplicate_rate: 5.2, duplicate_rate: 5.2,
duplicate_count: 1, duplicate_count: 1,
created_at: '2026-06-27T11:30:00Z', created_at: "2026-06-27T11:30:00Z",
updated_at: '2026-06-27T11:32:00Z', updated_at: "2026-06-27T11:32:00Z",
}, },
{ {
id: 'dup-003', id: "dup-003",
filename: '旅行记录_巴黎.mp4', filename: "旅行记录_巴黎.mp4",
file_size: 320_000_000, file_size: 320_000_000,
duration_seconds: 420, duration_seconds: 420,
status: 'processing', status: "processing",
created_at: '2026-06-28T09:00:00Z', created_at: "2026-06-28T09:00:00Z",
updated_at: '2026-06-28T09:00:00Z', updated_at: "2026-06-28T09:00:00Z",
}, },
{ {
id: 'dup-004', id: "dup-004",
filename: '产品展示_新版.mp4', filename: "产品展示_新版.mp4",
file_size: 88_000_000, file_size: 88_000_000,
duration_seconds: 90, duration_seconds: 90,
status: 'completed', status: "completed",
duplicate_rate: 67.8, duplicate_rate: 67.8,
duplicate_count: 8, duplicate_count: 8,
created_at: '2026-06-26T15:00:00Z', created_at: "2026-06-26T15:00:00Z",
updated_at: '2026-06-26T15:10:00Z', updated_at: "2026-06-26T15:10:00Z",
}, },
{ {
id: 'dup-005', id: "dup-005",
filename: '教程_剪辑技巧.mp4', filename: "教程_剪辑技巧.mp4",
file_size: 200_000_000, file_size: 200_000_000,
duration_seconds: 300, duration_seconds: 300,
status: 'failed', status: "failed",
created_at: '2026-06-26T14:00:00Z', created_at: "2026-06-26T14:00:00Z",
updated_at: '2026-06-26T14:01:00Z', updated_at: "2026-06-26T14:01:00Z",
}, },
]; ];
@@ -125,31 +126,31 @@ const MOCK_DETAIL: DuplicationDetail = {
...MOCK_RECORDS[0], ...MOCK_RECORDS[0],
segments: [ segments: [
{ {
id: 'seg-001', id: "seg-001",
source_start: 10, source_start: 10,
source_end: 25, source_end: 25,
matched_video_id: 'asset-101', matched_video_id: "asset-101",
matched_video_name: '日常vlog_素材库.mp4', matched_video_name: "日常vlog_素材库.mp4",
matched_start: 45, matched_start: 45,
matched_end: 60, matched_end: 60,
similarity: 92.3, similarity: 92.3,
}, },
{ {
id: 'seg-002', id: "seg-002",
source_start: 60, source_start: 60,
source_end: 78, source_end: 78,
matched_video_id: 'asset-205', matched_video_id: "asset-205",
matched_video_name: '城市风光_合集.mp4', matched_video_name: "城市风光_合集.mp4",
matched_start: 120, matched_start: 120,
matched_end: 138, matched_end: 138,
similarity: 85.7, similarity: 85.7,
}, },
{ {
id: 'seg-003', id: "seg-003",
source_start: 150, source_start: 150,
source_end: 165, source_end: 165,
matched_video_id: 'asset-310', matched_video_id: "asset-310",
matched_video_name: '背景音乐_配套画面.mp4', matched_video_name: "背景音乐_配套画面.mp4",
matched_start: 30, matched_start: 30,
matched_end: 45, matched_end: 45,
similarity: 78.1, similarity: 78.1,
@@ -164,21 +165,21 @@ const USE_MOCK = true;
/** 上传视频进行查重 */ /** 上传视频进行查重 */
export const uploadForDuplication = async ( export const uploadForDuplication = async (
file: File file: File,
): Promise<DuplicationUploadResponse> => { ): Promise<DuplicationUploadResponse> => {
if (USE_MOCK) { if (USE_MOCK) {
// 模拟上传延迟 // 模拟上传延迟
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 1500));
return { return {
id: `dup-${Date.now()}`, id: `dup-${Date.now()}`,
status: 'processing', status: "processing",
message: `文件 "${file.name}" 已上传,正在查重中...`, message: `文件 "${file.name}" 已上传,正在查重中...`,
}; };
} }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append("file", file);
const response = await apiClient.post('/duplication/upload', formData, { const response = await apiClient.post("/duplication/upload", formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { "Content-Type": "multipart/form-data" },
}); });
return response.data; return response.data;
}; };
@@ -189,13 +190,13 @@ export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_RECORDS; return MOCK_RECORDS;
} }
const response = await apiClient.get('/duplication/records'); const response = await apiClient.get("/duplication/records");
return response.data; return response.data;
}; };
/** 获取查重详情 */ /** 获取查重详情 */
export const getDuplicationDetail = async ( export const getDuplicationDetail = async (
recordId: string recordId: string,
): Promise<DuplicationDetail> => { ): Promise<DuplicationDetail> => {
if (USE_MOCK) { if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
@@ -208,7 +209,7 @@ export const getDuplicationDetail = async (
/** 删除查重记录 */ /** 删除查重记录 */
export const deleteDuplicationRecord = async ( export const deleteDuplicationRecord = async (
recordId: string recordId: string,
): Promise<void> => { ): Promise<void> => {
if (USE_MOCK) { if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
@@ -219,16 +220,18 @@ export const deleteDuplicationRecord = async (
/** 重新查重 */ /** 重新查重 */
export const retryDuplication = async ( export const retryDuplication = async (
recordId: string recordId: string,
): Promise<DuplicationUploadResponse> => { ): Promise<DuplicationUploadResponse> => {
if (USE_MOCK) { if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
return { return {
id: recordId, id: recordId,
status: 'processing', status: "processing",
message: '已重新提交查重', message: "已重新提交查重",
}; };
} }
const response = await apiClient.post(`/duplication/records/${recordId}/retry`); const response = await apiClient.post(
`/duplication/records/${recordId}/retry`,
);
return response.data; return response.data;
}; };
+25 -17
View File
@@ -2,27 +2,27 @@
* 剪辑计划编辑器 API * 剪辑计划编辑器 API
* 对接后端 /api/v1/templates 路由 * 对接后端 /api/v1/templates 路由
*/ */
import apiClient from './client'; import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */ /** 模板模式(后端枚举值) */
export type TemplateMode = 'pip' | 'voice_over' | 'one_take' | 'voice_pip'; export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip";
/** 模式显示名称映射 */ /** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = { export const MODE_LABELS: Record<TemplateMode, string> = {
pip: '画中画', pip: "画中画",
voice_over: '人物口播', voice_over: "人物口播",
one_take: '一镜到底', one_take: "一镜到底",
voice_pip: '口播+混剪', voice_pip: "口播+混剪",
}; };
/** 模式颜色映射 */ /** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = { export const MODE_COLORS: Record<TemplateMode, string> = {
pip: 'blue', pip: "blue",
voice_over: 'green', voice_over: "green",
one_take: 'orange', one_take: "orange",
voice_pip: 'purple', voice_pip: "purple",
}; };
/** 标题配置 */ /** 标题配置 */
@@ -94,7 +94,7 @@ export interface SaveTemplatePayload {
subtitle_config: SubtitleConfig; subtitle_config: SubtitleConfig;
bgm_config: BgmConfig; bgm_config: BgmConfig;
estimated_duration: number; estimated_duration: number;
segments: Omit<TemplateSegment, 'id'>[]; segments: Omit<TemplateSegment, "id">[];
} }
/** 使用模板生成请求体 */ /** 使用模板生成请求体 */
@@ -135,20 +135,23 @@ export const getEditingTemplates = async (params?: {
skip?: number; skip?: number;
limit?: number; limit?: number;
}): Promise<EditingTemplate[]> => { }): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>('/templates', { const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,
limit: params?.limit ?? 50, limit: params?.limit ?? 50,
}, },
}); });
let list = response.data.items; let list = response.data.items;
if (params?.category) list = list.filter((t) => t.category === params.category); if (params?.category)
list = list.filter((t) => t.category === params.category);
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)); if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
return list; return list;
}; };
/** 获取模板详情 */ /** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => { export const getEditingTemplate = async (
id: string,
): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`); const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
return response.data; return response.data;
}; };
@@ -157,7 +160,7 @@ export const getEditingTemplate = async (id: string): Promise<EditingTemplate> =
export const createEditingTemplate = async ( export const createEditingTemplate = async (
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>('/templates', data); const response = await apiClient.post<EditingTemplate>("/templates", data);
return response.data; return response.data;
}; };
@@ -166,7 +169,10 @@ export const updateEditingTemplate = async (
id: string, id: string,
data: SaveTemplatePayload, data: SaveTemplatePayload,
): Promise<EditingTemplate> => { ): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data); const response = await apiClient.patch<EditingTemplate>(
`/templates/${id}`,
data,
);
return response.data; return response.data;
}; };
@@ -177,7 +183,9 @@ export const deleteEditingTemplate = async (id: string): Promise<void> => {
/** 获取模板分类列表 */ /** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => { export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>('/templates/categories/list'); const response = await apiClient.get<ListCategoriesResponse>(
"/templates/categories/list",
);
return response.data.items; return response.data.items;
}; };
+6 -10
View File
@@ -2,7 +2,7 @@
* 成品相关 API * 成品相关 API
* Phase 1 重构:去掉 projectId,成品直接归属用户 * Phase 1 重构:去掉 projectId,成品直接归属用户
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 成品条目 */ /** 成品条目 */
export interface ProductItem { export interface ProductItem {
@@ -13,7 +13,7 @@ export interface ProductItem {
duration_seconds?: number; duration_seconds?: number;
file_size?: number; file_size?: number;
resolution?: string; resolution?: string;
status: 'processing' | 'completed' | 'failed'; status: "processing" | "completed" | "failed";
/** 查重率(百分比) */ /** 查重率(百分比) */
duplicate_rate?: number; duplicate_rate?: number;
created_at?: string; created_at?: string;
@@ -22,14 +22,12 @@ export interface ProductItem {
/** 获取当前用户的所有成品 */ /** 获取当前用户的所有成品 */
export const getProducts = async (): Promise<ProductItem[]> => { export const getProducts = async (): Promise<ProductItem[]> => {
const response = await apiClient.get('/products'); const response = await apiClient.get("/products");
return response.data.items || response.data || []; return response.data.items || response.data || [];
}; };
/** 获取单个成品详情 */ /** 获取单个成品详情 */
export const getProduct = async ( export const getProduct = async (productId: string): Promise<ProductItem> => {
productId: string
): Promise<ProductItem> => {
const response = await apiClient.get(`/products/${productId}`); const response = await apiClient.get(`/products/${productId}`);
return response.data; return response.data;
}; };
@@ -41,10 +39,8 @@ export const deleteProduct = async (productId: string): Promise<void> => {
/** 获取成品下载链接 */ /** 获取成品下载链接 */
export const getProductDownloadUrl = async ( export const getProductDownloadUrl = async (
productId: string productId: string,
): Promise<{ url: string; expires_at: string }> => { ): Promise<{ url: string; expires_at: string }> => {
const response = await apiClient.get( const response = await apiClient.get(`/products/${productId}/download-url`);
`/products/${productId}/download-url`
);
return response.data; return response.data;
}; };
+7 -6
View File
@@ -2,7 +2,7 @@
* 项目相关 API * 项目相关 API
* 素材库需要 project_id,前端自动管理默认项目 * 素材库需要 project_id,前端自动管理默认项目
*/ */
import apiClient from './client'; import apiClient from "./client";
export interface ProjectItem { export interface ProjectItem {
id: string; id: string;
@@ -30,7 +30,8 @@ const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
/** 获取当前用户的项目列表 */ /** 获取当前用户的项目列表 */
export const getProjects = async (): Promise<ProjectItem[]> => { export const getProjects = async (): Promise<ProjectItem[]> => {
const response = await apiClient.get<BackendListProjectsResponse>('/projects'); const response =
await apiClient.get<BackendListProjectsResponse>("/projects");
return (response.data.items || []).map(toProjectItem); return (response.data.items || []).map(toProjectItem);
}; };
@@ -39,9 +40,9 @@ export const createProject = async (data: {
name: string; name: string;
description?: string; description?: string;
}): Promise<ProjectItem> => { }): Promise<ProjectItem> => {
const response = await apiClient.post<BackendProjectResponse>('/projects', { const response = await apiClient.post<BackendProjectResponse>("/projects", {
name: data.name, name: data.name,
description: data.description || '', description: data.description || "",
}); });
return toProjectItem(response.data); return toProjectItem(response.data);
}; };
@@ -54,7 +55,7 @@ export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
} }
// 没有项目时自动创建默认项目 // 没有项目时自动创建默认项目
return createProject({ return createProject({
name: '默认项目', name: "默认项目",
description: '系统自动创建的默认项目', description: "系统自动创建的默认项目",
}); });
}; };
+22 -13
View File
@@ -2,19 +2,19 @@
* 订阅 API 模块 * 订阅 API 模块
* 对接后端订阅管理接口 * 对接后端订阅管理接口
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 套餐类型 */ /** 套餐类型 */
export type PlanType = 'free' | 'standard' | 'pro' | 'enterprise'; export type PlanType = "free" | "standard" | "pro" | "enterprise";
/** 订阅状态 */ /** 订阅状态 */
export type SubscriptionStatus = 'active' | 'expired' | 'cancelled' | 'trial'; export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
/** 账单状态 */ /** 账单状态 */
export type BillingStatus = 'paid' | 'pending' | 'failed' | 'refunded'; export type BillingStatus = "paid" | "pending" | "failed" | "refunded";
/** 计费周期 */ /** 计费周期 */
export type BillingCycle = 'monthly' | 'yearly'; export type BillingCycle = "monthly" | "yearly";
/** 套餐信息 */ /** 套餐信息 */
export interface Plan { export interface Plan {
@@ -70,30 +70,39 @@ export interface ChangePlanResponse {
/** 获取当前订阅信息 */ /** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => { export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get('/subscription/current'); const response = await apiClient.get("/subscription/current");
return response.data; return response.data;
}; };
/** 获取账单记录列表 */ /** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => { export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get('/subscription/billing-records'); const response = await apiClient.get("/subscription/billing-records");
return response.data; return response.data;
}; };
/** 升级/降级套餐 */ /** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => { export const changePlan = async (
const response = await apiClient.post('/subscription/change-plan', request); request: ChangePlanRequest,
): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request);
return response.data; return response.data;
}; };
/** 取消订阅 */ /** 取消订阅 */
export const cancelSubscription = async (): Promise<{ success: boolean; message: string }> => { export const cancelSubscription = async (): Promise<{
const response = await apiClient.post('/subscription/cancel'); success: boolean;
message: string;
}> => {
const response = await apiClient.post("/subscription/cancel");
return response.data; return response.data;
}; };
/** 切换自动续费 */ /** 切换自动续费 */
export const toggleAutoRenew = async (enabled: boolean): Promise<{ success: boolean; message: string }> => { export const toggleAutoRenew = async (
const response = await apiClient.post('/subscription/toggle-auto-renew', { enabled }); enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
});
return response.data; return response.data;
}; };
+4 -4
View File
@@ -5,14 +5,14 @@
* - GET /api/v1/tasks — 用户级任务列表(跨 project) * - GET /api/v1/tasks — 用户级任务列表(跨 project)
* - POST /api/v1/tasks/{task_id}/retry — 简化重试 * - POST /api/v1/tasks/{task_id}/retry — 简化重试
*/ */
import apiClient from './client'; import apiClient from "./client";
/* ──────────── 类型定义 ──────────── */ /* ──────────── 类型定义 ──────────── */
/** 任务条目(对应用户级 UserTaskResponse */ /** 任务条目(对应用户级 UserTaskResponse */
export interface TaskItem { export interface TaskItem {
id: string; id: string;
task_type: 'ingest' | 'generation' | string; task_type: "ingest" | "generation" | string;
project_id: string; project_id: string;
template_id: string; template_id: string;
status: string; status: string;
@@ -58,7 +58,7 @@ export const createGenerationTask = async (
params: CreateGenerationTaskRequest, params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => { ): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>( const { data } = await apiClient.post<CreateGenerationTaskResponse>(
'/generation/tasks', "/generation/tasks",
params, params,
); );
return data; return data;
@@ -66,7 +66,7 @@ export const createGenerationTask = async (
/** 获取当前用户的所有任务(跨 project) */ /** 获取当前用户的所有任务(跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => { export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get('/tasks'); const { data } = await apiClient.get("/tasks");
return data.items || []; return data.items || [];
}; };
+5 -5
View File
@@ -2,7 +2,7 @@
* 模板相关 API * 模板相关 API
* Phase 1 新增:全局模板库 * Phase 1 新增:全局模板库
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 模板条目 */ /** 模板条目 */
export interface TemplateItem { export interface TemplateItem {
@@ -21,13 +21,13 @@ export interface TemplateItem {
/** 获取全局模板列表 */ /** 获取全局模板列表 */
export const getTemplates = async (): Promise<TemplateItem[]> => { export const getTemplates = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get('/templates'); const response = await apiClient.get("/templates");
return response.data.items || response.data || []; return response.data.items || response.data || [];
}; };
/** 获取单个模板详情 */ /** 获取单个模板详情 */
export const getTemplate = async ( export const getTemplate = async (
templateId: string templateId: string,
): Promise<TemplateItem> => { ): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`); const response = await apiClient.get(`/templates/${templateId}`);
return response.data; return response.data;
@@ -35,10 +35,10 @@ export const getTemplate = async (
/** 收藏 / 取消收藏模板 */ /** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async ( export const toggleFavoriteTemplate = async (
templateId: string templateId: string,
): Promise<{ is_favorite: boolean }> => { ): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post( const response = await apiClient.post(
`/templates/${templateId}/toggle-favorite` `/templates/${templateId}/toggle-favorite`,
); );
return response.data; return response.data;
}; };
+10 -5
View File
@@ -3,7 +3,7 @@
* Phase 1 新增:全局标题库 * Phase 1 新增:全局标题库
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示 * 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 标题条目(前端展示用) */ /** 标题条目(前端展示用) */
export interface TitleItem { export interface TitleItem {
@@ -68,7 +68,9 @@ export interface CreateTitleRequest {
/** 获取当前用户的所有标题 */ /** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => { export const getTitles = async (): Promise<TitleItem[]> => {
const response = await apiClient.get<{ items: BackendTitleResponse[] }>('/titles'); const response = await apiClient.get<{ items: BackendTitleResponse[] }>(
"/titles",
);
return (response.data.items || []).map(toTitleItem); return (response.data.items || []).map(toTitleItem);
}; };
@@ -80,9 +82,12 @@ export const createTitle = async (
const payload: BackendCreateTitleRequest = { const payload: BackendCreateTitleRequest = {
name: data.content.slice(0, 255), name: data.content.slice(0, 255),
text: data.content.slice(0, 500), text: data.content.slice(0, 500),
category: data.category || 'default', category: data.category || "default",
}; };
const response = await apiClient.post<BackendTitleResponse>('/titles', payload); const response = await apiClient.post<BackendTitleResponse>(
"/titles",
payload,
);
return toTitleItem(response.data); return toTitleItem(response.data);
}; };
@@ -116,6 +121,6 @@ export const deleteTitle = async (titleId: string): Promise<void> => {
export const batchImportTitles = async ( export const batchImportTitles = async (
titles: string[], titles: string[],
): Promise<{ imported_count: number }> => { ): Promise<{ imported_count: number }> => {
const response = await apiClient.post('/titles/batch-import', { titles }); const response = await apiClient.post("/titles/batch-import", { titles });
return response.data; return response.data;
}; };
+6 -6
View File
@@ -2,7 +2,7 @@
* 配音相关 API * 配音相关 API
* Phase 1 新增:全局配音库 * Phase 1 新增:全局配音库
*/ */
import apiClient from './client'; import apiClient from "./client";
/** 配音条目 */ /** 配音条目 */
export interface VoiceItem { export interface VoiceItem {
@@ -28,22 +28,22 @@ export interface CreateVoiceRequest {
/** 获取当前用户的所有配音 */ /** 获取当前用户的所有配音 */
export const getVoices = async (): Promise<VoiceItem[]> => { export const getVoices = async (): Promise<VoiceItem[]> => {
const response = await apiClient.get('/voices'); const response = await apiClient.get("/voices");
return response.data.items || response.data || []; return response.data.items || response.data || [];
}; };
/** 创建配音 */ /** 创建配音 */
export const createVoice = async ( export const createVoice = async (
data: CreateVoiceRequest data: CreateVoiceRequest,
): Promise<VoiceItem> => { ): Promise<VoiceItem> => {
const response = await apiClient.post('/voices', data); const response = await apiClient.post("/voices", data);
return response.data; return response.data;
}; };
/** 更新配音 */ /** 更新配音 */
export const updateVoice = async ( export const updateVoice = async (
voiceId: string, voiceId: string,
data: Partial<CreateVoiceRequest> data: Partial<CreateVoiceRequest>,
): Promise<VoiceItem> => { ): Promise<VoiceItem> => {
const response = await apiClient.patch(`/voices/${voiceId}`, data); const response = await apiClient.patch(`/voices/${voiceId}`, data);
return response.data; return response.data;
@@ -60,6 +60,6 @@ export const generateAIVoice = async (data: {
voice_type?: string; voice_type?: string;
speed?: number; speed?: number;
}): Promise<VoiceItem> => { }): Promise<VoiceItem> => {
const response = await apiClient.post('/voices/generate', data); const response = await apiClient.post("/voices/generate", data);
return response.data; return response.data;
}; };
+19 -17
View File
@@ -8,14 +8,14 @@
border-radius: 14px !important; border-radius: 14px !important;
padding: 10px 20px !important; padding: 10px 20px !important;
font-weight: 600 !important; font-weight: 600 !important;
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important; box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
transition: all 0.2s !important; transition: all 0.2s !important;
cursor: pointer; cursor: pointer;
height: auto !important; height: auto !important;
} }
.xx-primary-btn:hover { .xx-primary-btn:hover {
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important; box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -37,17 +37,17 @@
/* ==================== 卡片 ==================== */ /* ==================== 卡片 ==================== */
.xx-card { .xx-card {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px; border-radius: 22px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 24px; padding: 24px;
margin-bottom: 20px; margin-bottom: 20px;
transition: all 0.25s; transition: all 0.25s;
} }
.xx-card:hover { .xx-card:hover {
box-shadow: 0 26px 64px rgba(15,23,42,0.14); box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -81,10 +81,10 @@
/* ==================== 表格样式 ==================== */ /* ==================== 表格样式 ==================== */
.xx-table-card { .xx-table-card {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 22px; border-radius: 22px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 20px; padding: 20px;
overflow: hidden; overflow: hidden;
} }
@@ -145,7 +145,7 @@
.xx-search-input:focus { .xx-search-input:focus {
border-color: #4f46e5; border-color: #4f46e5;
box-shadow: 0 0 0 4px rgba(79,70,229,0.1); box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1);
} }
/* ==================== Modal ==================== */ /* ==================== Modal ==================== */
@@ -203,7 +203,9 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.xx-grid-2, .xx-grid-3, .xx-grid-4 { .xx-grid-2,
.xx-grid-3,
.xx-grid-4 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -219,7 +221,7 @@
.xx-quota-item:hover { .xx-quota-item:hover {
border-color: #4f46e5; border-color: #4f46e5;
box-shadow: 0 8px 24px rgba(79,70,229,0.1); box-shadow: 0 8px 24px rgba(79, 70, 229, 0.1);
} }
/* ==================== 进度条 ==================== */ /* ==================== 进度条 ==================== */
@@ -249,7 +251,7 @@
/* Card overrides */ /* Card overrides */
.ant-card { .ant-card {
border-radius: 22px !important; border-radius: 22px !important;
border: 1px solid rgba(226,232,240,0.95) !important; border: 1px solid rgba(226, 232, 240, 0.95) !important;
} }
.ant-card-head { .ant-card-head {
@@ -298,7 +300,7 @@
background: linear-gradient(135deg, #6366f1, #4f46e5) !important; background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
border: none !important; border: none !important;
border-radius: 14px !important; border-radius: 14px !important;
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important; box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
height: auto !important; height: auto !important;
padding: 10px 20px !important; padding: 10px 20px !important;
font-weight: 600 !important; font-weight: 600 !important;
@@ -306,7 +308,7 @@
.ant-btn-primary:hover { .ant-btn-primary:hover {
background: linear-gradient(135deg, #6366f1, #4f46e5) !important; background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important; box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -329,7 +331,7 @@
.ant-select-focused .ant-select-selector { .ant-select-focused .ant-select-selector {
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
} }
/* Input overrides */ /* Input overrides */
@@ -345,7 +347,7 @@
.ant-input:focus { .ant-input:focus {
border-color: #4f46e5 !important; border-color: #4f46e5 !important;
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important;
} }
/* Progress overrides */ /* Progress overrides */
+86 -35
View File
@@ -2,8 +2,8 @@
* Phase 1 Header 重构 * Phase 1 Header 重构
* 扁平化导航菜单 + 手机端汉堡菜单 * 扁平化导航菜单 + 手机端汉堡菜单
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { Avatar, Dropdown, Drawer, Space } from 'antd'; import { Avatar, Dropdown, Drawer, Space } from "antd";
import { import {
LogoutOutlined, LogoutOutlined,
SettingOutlined, SettingOutlined,
@@ -20,12 +20,12 @@ import {
ScanOutlined, ScanOutlined,
EditOutlined, EditOutlined,
FolderOutlined, FolderOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from "react-router-dom";
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from "@/store/authStore";
import { useLogout } from '@/hooks/useAuth'; import { useLogout } from "@/hooks/useAuth";
import type { MenuProps } from 'antd'; import type { MenuProps } from "antd";
import './Header.css'; import "./Header.css";
/** 导航项定义 */ /** 导航项定义 */
interface NavItem { interface NavItem {
@@ -37,17 +37,62 @@ interface NavItem {
/** 固定导航菜单 */ /** 固定导航菜单 */
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ key: 'dashboard', label: '概览', path: '/dashboard', icon: <DashboardOutlined /> }, {
{ key: 'assets', label: '素材库', path: '/assets', icon: <FileOutlined /> }, key: "dashboard",
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> }, label: "概览",
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> }, path: "/dashboard",
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> }, icon: <DashboardOutlined />,
{ key: 'editing-planner', label: '剪辑编辑器', path: '/editing-planner', icon: <EditOutlined /> }, },
{ key: 'my-templates', label: '我的模板', path: '/my-templates', icon: <FolderOutlined /> }, { key: "assets", label: "素材库", path: "/assets", icon: <FileOutlined /> },
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> }, {
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> }, key: "titles",
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> }, label: "标题库",
{ key: 'duplication', label: '查重', path: '/duplication', icon: <ScanOutlined /> }, path: "/titles",
icon: <FileTextOutlined />,
},
{ key: "voices", label: "配音库", path: "/voices", icon: <AudioOutlined /> },
{
key: "templates",
label: "模板库",
path: "/templates",
icon: <AppstoreOutlined />,
},
{
key: "editing-planner",
label: "剪辑编辑器",
path: "/editing-planner",
icon: <EditOutlined />,
},
{
key: "my-templates",
label: "我的模板",
path: "/my-templates",
icon: <FolderOutlined />,
},
{
key: "generate",
label: "一键生成",
path: "/generate",
icon: <VideoCameraOutlined />,
},
{
key: "history",
label: "任务历史",
path: "/history",
icon: <HistoryOutlined />,
},
{
key: "products",
label: "成品库",
path: "/products",
icon: <TrophyOutlined />,
},
{
key: "duplication",
label: "查重",
path: "/duplication",
icon: <ScanOutlined />,
},
]; ];
const Header: React.FC = () => { const Header: React.FC = () => {
@@ -58,32 +103,32 @@ const Header: React.FC = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
/** 用户下拉菜单 */ /** 用户下拉菜单 */
const menuItems: MenuProps['items'] = [ const menuItems: MenuProps["items"] = [
{ {
key: 'profile', key: "profile",
icon: <UserOutlined />, icon: <UserOutlined />,
label: '个人设置', label: "个人设置",
onClick: () => navigate('/profile'), onClick: () => navigate("/profile"),
}, },
{ {
key: 'subscription', key: "subscription",
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: '订阅管理', label: "订阅管理",
onClick: () => navigate('/subscription'), onClick: () => navigate("/subscription"),
}, },
{ type: 'divider' }, { type: "divider" },
{ {
key: 'logout', key: "logout",
icon: <LogoutOutlined />, icon: <LogoutOutlined />,
label: '退出登录', label: "退出登录",
onClick: () => logoutMutation.mutateAsync(), onClick: () => logoutMutation.mutateAsync(),
}, },
]; ];
/** 判断导航项是否激活 */ /** 判断导航项是否激活 */
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/dashboard') { if (path === "/dashboard") {
return location.pathname === '/' || location.pathname === '/dashboard'; return location.pathname === "/" || location.pathname === "/dashboard";
} }
return location.pathname.startsWith(path); return location.pathname.startsWith(path);
}; };
@@ -91,7 +136,11 @@ const Header: React.FC = () => {
return ( return (
<header className="xx-top-nav"> <header className="xx-top-nav">
<div className="xx-top-nav-inner"> <div className="xx-top-nav-inner">
<button className="xx-brand" type="button" onClick={() => navigate('/dashboard')}> <button
className="xx-brand"
type="button"
onClick={() => navigate("/dashboard")}
>
<span className="xx-logo">🦐</span> <span className="xx-logo">🦐</span>
<span className="xx-brand-text"></span> <span className="xx-brand-text"></span>
</button> </button>
@@ -101,7 +150,7 @@ const Header: React.FC = () => {
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<button <button
key={item.key} key={item.key}
className={isActive(item.path) ? 'active' : ''} className={isActive(item.path) ? "active" : ""}
type="button" type="button"
onClick={() => navigate(item.path)} onClick={() => navigate(item.path)}
> >
@@ -123,7 +172,9 @@ const Header: React.FC = () => {
<Dropdown menu={{ items: menuItems }} placement="bottomRight"> <Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Space className="xx-user-menu"> <Space className="xx-user-menu">
<Avatar className="xx-avatar" icon={<UserOutlined />} /> <Avatar className="xx-avatar" icon={<UserOutlined />} />
<span className="xx-username">{user?.display_name || user?.username || '小虾用户'}</span> <span className="xx-username">
{user?.display_name || user?.username || "小虾用户"}
</span>
</Space> </Space>
</Dropdown> </Dropdown>
</div> </div>
@@ -142,7 +193,7 @@ const Header: React.FC = () => {
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<button <button
key={item.key} key={item.key}
className={`xx-mobile-nav-item ${isActive(item.path) ? 'active' : ''}`} className={`xx-mobile-nav-item ${isActive(item.path) ? "active" : ""}`}
type="button" type="button"
onClick={() => { onClick={() => {
navigate(item.path); navigate(item.path);
@@ -30,5 +30,7 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;
} }
@@ -1,7 +1,7 @@
import React from 'react'; import React from "react";
import { Outlet } from 'react-router-dom'; import { Outlet } from "react-router-dom";
import Header from './Header'; import Header from "./Header";
import './MainLayout.css'; import "./MainLayout.css";
const MainLayout: React.FC = () => { const MainLayout: React.FC = () => {
return ( return (
+13 -13
View File
@@ -1,10 +1,10 @@
/** /**
* 认证相关 Hooks * 认证相关 Hooks
*/ */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import * as authApi from '@/api/auth'; import * as authApi from "@/api/auth";
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from "@/store/authStore";
// 登录 Hook // 登录 Hook
export const useLogin = () => { export const useLogin = () => {
@@ -21,17 +21,17 @@ export const useLogin = () => {
const refreshToken = data.refresh_token ?? null; const refreshToken = data.refresh_token ?? null;
// 先保存 token // 先保存 token
localStorage.setItem('access_token', data.access_token); localStorage.setItem("access_token", data.access_token);
if (refreshToken) { if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken); localStorage.setItem("refresh_token", refreshToken);
} else { } else {
localStorage.removeItem('refresh_token'); localStorage.removeItem("refresh_token");
} }
// 获取用户信息 // 获取用户信息
const user = await authApi.getCurrentUser(); const user = await authApi.getCurrentUser();
setAuth(user, data.access_token, refreshToken); setAuth(user, data.access_token, refreshToken);
navigate('/'); navigate("/");
return data; return data;
}; };
@@ -48,8 +48,8 @@ export const useRegister = () => {
const register = async (data: Parameters<typeof authApi.register>[0]) => { const register = async (data: Parameters<typeof authApi.register>[0]) => {
const result = await mutation.mutateAsync(data); const result = await mutation.mutateAsync(data);
navigate('/login', { navigate("/login", {
state: { message: '注册成功!请查收验证邮件。' }, state: { message: "注册成功!请查收验证邮件。" },
}); });
return result; return result;
}; };
@@ -75,7 +75,7 @@ export const useLogout = () => {
} finally { } finally {
clearAuth(); clearAuth();
queryClient.clear(); queryClient.clear();
navigate('/login'); navigate("/login");
} }
}; };
@@ -87,7 +87,7 @@ export const useCurrentUser = () => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return useQuery({ return useQuery({
queryKey: ['currentUser'], queryKey: ["currentUser"],
queryFn: authApi.getCurrentUser, queryFn: authApi.getCurrentUser,
enabled: isAuthenticated, enabled: isAuthenticated,
}); });
+6 -3
View File
@@ -8,14 +8,17 @@
box-sizing: border-box; box-sizing: border-box;
} }
html, body, #root { html,
body,
#root {
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', font-family:
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: #0f172a; color: #0f172a;
+33 -33
View File
@@ -2,15 +2,15 @@
* 更新主入口,引入全局样式 * 更新主入口,引入全局样式
* V21 设计系统配置 * V21 设计系统配置
*/ */
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import { RouterProvider } from 'react-router-dom'; import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigProvider } from 'antd'; import { ConfigProvider } from "antd";
import zhCN from 'antd/locale/zh_CN'; import zhCN from "antd/locale/zh_CN";
import router from './router'; import router from "./router";
import './index.css'; import "./index.css";
import './styles/global.css'; import "./styles/global.css";
// 创建 React Query 客户端 // 创建 React Query 客户端
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -28,42 +28,42 @@ const queryClient = new QueryClient({
const theme = { const theme = {
token: { token: {
// 主色调 - Indigo // 主色调 - Indigo
colorPrimary: '#4f46e5', colorPrimary: "#4f46e5",
colorSuccess: '#10b981', colorSuccess: "#10b981",
colorWarning: '#f59e0b', colorWarning: "#f59e0b",
colorError: '#ef4444', colorError: "#ef4444",
colorInfo: '#4f46e5', colorInfo: "#4f46e5",
// 圆角 - V21 大圆角 // 圆角 - V21 大圆角
borderRadius: 14, borderRadius: 14,
borderRadiusLG: 22, borderRadiusLG: 22,
borderRadiusSM: 10, borderRadiusSM: 10,
// 文字色 // 文字色
colorText: '#0f172a', colorText: "#0f172a",
colorTextSecondary: '#64748b', colorTextSecondary: "#64748b",
colorTextTertiary: '#94a3b8', colorTextTertiary: "#94a3b8",
// 背景色 // 背景色
colorBgContainer: '#ffffff', colorBgContainer: "#ffffff",
colorBgLayout: '#f8fafc', colorBgLayout: "#f8fafc",
// 边框色 // 边框色
colorBorder: '#e2e8f0', colorBorder: "#e2e8f0",
colorBorderSecondary: '#f1f5f9', colorBorderSecondary: "#f1f5f9",
// 阴影 // 阴影
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.06)', boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)",
boxShadowSecondary: '0 24px 70px rgba(15, 23, 42, 0.09)', boxShadowSecondary: "0 24px 70px rgba(15, 23, 42, 0.09)",
}, },
components: { components: {
Button: { Button: {
// 按钮圆角 // 按钮圆角
borderRadius: 14, borderRadius: 14,
// 主按钮渐变通过 CSS 实现 // 主按钮渐变通过 CSS 实现
colorPrimary: '#4f46e5', colorPrimary: "#4f46e5",
colorPrimaryHover: '#6366f1', colorPrimaryHover: "#6366f1",
primaryShadow: '0 14px 26px rgba(79, 70, 229, 0.22)', primaryShadow: "0 14px 26px rgba(79, 70, 229, 0.22)",
}, },
Card: { Card: {
borderRadiusLG: 28, borderRadiusLG: 28,
@@ -87,12 +87,12 @@ const theme = {
}, },
}; };
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}> <ConfigProvider locale={zhCN} theme={theme}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode> </React.StrictMode>,
); );
+1 -1
View File
@@ -468,7 +468,7 @@
} }
.xx-log-detail-code { .xx-log-detail-code {
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
background: rgba(248, 250, 252, 0.8); background: rgba(248, 250, 252, 0.8);
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 10px;
+7 -7
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from "react";
import { Button, Card, Result } from 'antd'; import { Button, Card, Result } from "antd";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import './Admin.css'; import "./Admin.css";
const AdminComingSoon: React.FC = () => { const AdminComingSoon: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -15,9 +15,9 @@ const AdminComingSoon: React.FC = () => {
title="Admin 后台暂未开放" title="Admin 后台暂未开放"
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。" subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
extra={[ extra={[
<Button <Button
type="primary" type="primary"
onClick={() => navigate('/')} onClick={() => navigate("/")}
className="xx-primary-btn" className="xx-primary-btn"
> >
+97 -78
View File
@@ -2,8 +2,8 @@
* 素材库页面 * 素材库页面
* 展示用户所有素材,支持响应式上传 * 展示用户所有素材,支持响应式上传
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Card, Card,
Button, Button,
@@ -22,7 +22,7 @@ import {
Tag, Tag,
Image, Image,
Popconfirm, Popconfirm,
} from 'antd'; } from "antd";
import { import {
PlusOutlined, PlusOutlined,
DeleteOutlined, DeleteOutlined,
@@ -30,7 +30,7 @@ import {
PictureOutlined, PictureOutlined,
AudioOutlined, AudioOutlined,
InboxOutlined, InboxOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { import {
getAssetLibraries, getAssetLibraries,
createAssetLibrary, createAssetLibrary,
@@ -38,8 +38,8 @@ import {
deleteAsset, deleteAsset,
uploadAsset, uploadAsset,
uploadAssetDirect, uploadAssetDirect,
} from '@/api/assets'; } from "@/api/assets";
import { getOrCreateDefaultProject } from '@/api/projects'; import { getOrCreateDefaultProject } from "@/api/projects";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Dragger } = Upload; const { Dragger } = Upload;
@@ -52,12 +52,12 @@ const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; // 104857600 bytes
/** 素材库类型图标 */ /** 素材库类型图标 */
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => { const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
switch (kind) { switch (kind) {
case 'video': case "video":
return <VideoCameraOutlined style={{ color: '#1890ff' }} />; return <VideoCameraOutlined style={{ color: "#1890ff" }} />;
case 'voice': case "voice":
return <AudioOutlined style={{ color: '#52c41a' }} />; return <AudioOutlined style={{ color: "#52c41a" }} />;
case 'image': case "image":
return <PictureOutlined style={{ color: '#faad14' }} />; return <PictureOutlined style={{ color: "#faad14" }} />;
default: default:
return <VideoCameraOutlined />; return <VideoCameraOutlined />;
} }
@@ -65,30 +65,30 @@ const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
/** 素材库类型标签 */ /** 素材库类型标签 */
const kindLabel: Record<string, string> = { const kindLabel: Record<string, string> = {
video: '视频', video: "视频",
voice: '配音', voice: "配音",
image: '图片', image: "图片",
}; };
const AssetLibrary: React.FC = () => { const AssetLibrary: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeLibrary, setActiveLibrary] = useState<string>(''); const [activeLibrary, setActiveLibrary] = useState<string>("");
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState(''); const [newLibName, setNewLibName] = useState("");
const [newLibKind, setNewLibKind] = useState<'video' | 'voice' | 'image'>( const [newLibKind, setNewLibKind] = useState<"video" | "voice" | "image">(
'video' "video",
); );
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
// 获取素材库列表 // 获取素材库列表
const { data: libraries = [], isLoading: libsLoading } = useQuery({ const { data: libraries = [], isLoading: libsLoading } = useQuery({
queryKey: ['asset-libraries'], queryKey: ["asset-libraries"],
queryFn: getAssetLibraries, queryFn: getAssetLibraries,
}); });
// 获取当前素材库的素材 // 获取当前素材库的素材
const { data: assets = [], isLoading: assetsLoading } = useQuery({ const { data: assets = [], isLoading: assetsLoading } = useQuery({
queryKey: ['assets', activeLibrary], queryKey: ["assets", activeLibrary],
queryFn: () => getAssets(activeLibrary), queryFn: () => getAssets(activeLibrary),
enabled: !!activeLibrary, enabled: !!activeLibrary,
}); });
@@ -97,57 +97,69 @@ const AssetLibrary: React.FC = () => {
const createLibMutation = useMutation({ const createLibMutation = useMutation({
mutationFn: createAssetLibrary, mutationFn: createAssetLibrary,
onSuccess: () => { onSuccess: () => {
message.success('素材库创建成功'); message.success("素材库创建成功");
setCreateModalOpen(false); setCreateModalOpen(false);
setNewLibName(''); setNewLibName("");
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('创建失败') },
}); });
// 上传素材(小文件表单上传) // 上传素材(小文件表单上传)
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: uploadAsset, mutationFn: uploadAsset,
onSuccess: () => { onSuccess: () => {
message.success('上传成功'); message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] }); queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('上传失败') },
}); });
// 大文件直传(OSS 预签名) // 大文件直传(OSS 预签名)
const directUploadMutation = useMutation({ const directUploadMutation = useMutation({
mutationFn: uploadAssetDirect, mutationFn: uploadAssetDirect,
onSuccess: () => { onSuccess: () => {
message.success('上传成功'); message.success("上传成功");
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] }); queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('上传失败') },
}); });
// 删除素材 // 删除素材
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteAsset, mutationFn: deleteAsset,
onSuccess: () => { onSuccess: () => {
message.success('已删除'); message.success("已删除");
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] }); queryClient.invalidateQueries({ queryKey: ["assets", activeLibrary] });
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] }); queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败') },
}); });
/** 处理上传 */ /** 处理上传 */
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!activeLibrary) { if (!activeLibrary) {
message.warning('请先选择素材库'); message.warning("请先选择素材库");
return false; return false;
} }
// 文件大小校验:上限 2GB // 文件大小校验:上限 2GB
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 2GB'); message.error("文件大小不能超过 2GB");
return false; return false;
} }
@@ -163,9 +175,9 @@ const AssetLibrary: React.FC = () => {
// 小文件走表单上传 // 小文件走表单上传
const project = await getOrCreateDefaultProject(); const project = await getOrCreateDefaultProject();
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append("file", file);
formData.append('library_id', activeLibrary); formData.append("library_id", activeLibrary);
formData.append('project_id', project.id); formData.append("project_id", project.id);
await uploadMutation.mutateAsync(formData); await uploadMutation.mutateAsync(formData);
} }
} catch { } catch {
@@ -180,14 +192,14 @@ const AssetLibrary: React.FC = () => {
const currentLib = libraries.find((l) => l.id === activeLibrary); const currentLib = libraries.find((l) => l.id === activeLibrary);
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
marginBottom: 24, marginBottom: 24,
flexWrap: 'wrap', flexWrap: "wrap",
gap: 12, gap: 12,
}} }}
> >
@@ -204,7 +216,7 @@ const AssetLibrary: React.FC = () => {
</div> </div>
{libsLoading ? ( {libsLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : libraries.length === 0 ? ( ) : libraries.length === 0 ? (
@@ -240,33 +252,40 @@ const AssetLibrary: React.FC = () => {
{activeLibrary && ( {activeLibrary && (
<Dragger <Dragger
accept={ accept={
currentLib?.kind === 'video' currentLib?.kind === "video"
? 'video/*' ? "video/*"
: currentLib?.kind === 'voice' : currentLib?.kind === "voice"
? 'audio/*' ? "audio/*"
: 'image/*' : "image/*"
} }
beforeUpload={handleUpload} beforeUpload={handleUpload}
showUploadList={false} showUploadList={false}
multiple multiple
disabled={uploading || uploadMutation.isPending || directUploadMutation.isPending} disabled={
uploading ||
uploadMutation.isPending ||
directUploadMutation.isPending
}
style={{ marginBottom: 24 }} style={{ marginBottom: 24 }}
> >
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
{uploading ? <Spin /> : <InboxOutlined />} {uploading ? <Spin /> : <InboxOutlined />}
</p> </p>
<p className="ant-upload-text"> <p className="ant-upload-text">
{uploading ? '正在上传,请稍候...' : '点击或拖拽文件到此区域上传'} {uploading
? "正在上传,请稍候..."
: "点击或拖拽文件到此区域上传"}
</p> </p>
<p className="ant-upload-hint"> <p className="ant-upload-hint">
{kindLabel[currentLib?.kind || 'video']} 2GB {kindLabel[currentLib?.kind || "video"]}{" "}
2GB
</p> </p>
</Dragger> </Dragger>
)} )}
{/* 素材列表 */} {/* 素材列表 */}
{assetsLoading ? ( {assetsLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}> <div style={{ textAlign: "center", padding: 40 }}>
<Spin /> <Spin />
</div> </div>
) : assets.length === 0 ? ( ) : assets.length === 0 ? (
@@ -279,30 +298,30 @@ const AssetLibrary: React.FC = () => {
hoverable hoverable
size="small" size="small"
cover={ cover={
currentLib?.kind === 'image' ? ( currentLib?.kind === "image" ? (
<Image <Image
src={`/api/v1/assets/${asset.id}/thumbnail`} src={`/api/v1/assets/${asset.id}/thumbnail`}
alt={asset.name} alt={asset.name}
style={{ height: 150, objectFit: 'cover' }} style={{ height: 150, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+" fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+"
/> />
) : ( ) : (
<div <div
style={{ style={{
height: 150, height: 150,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
background: '#f5f5f5', background: "#f5f5f5",
}} }}
> >
{currentLib?.kind === 'video' ? ( {currentLib?.kind === "video" ? (
<VideoCameraOutlined <VideoCameraOutlined
style={{ fontSize: 40, color: '#bbb' }} style={{ fontSize: 40, color: "#bbb" }}
/> />
) : ( ) : (
<AudioOutlined <AudioOutlined
style={{ fontSize: 40, color: '#bbb' }} style={{ fontSize: 40, color: "#bbb" }}
/> />
)} )}
</div> </div>
@@ -320,7 +339,7 @@ const AssetLibrary: React.FC = () => {
> >
<Card.Meta <Card.Meta
title={ title={
<Text ellipsis style={{ maxWidth: '100%' }}> <Text ellipsis style={{ maxWidth: "100%" }}>
{asset.name} {asset.name}
</Text> </Text>
} }
@@ -329,16 +348,16 @@ const AssetLibrary: React.FC = () => {
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{asset.file_size {asset.file_size
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
: '-'} : "-"}
</Text> </Text>
{asset.status && ( {asset.status && (
<Tag <Tag
color={ color={
asset.status === 'completed' asset.status === "completed"
? 'success' ? "success"
: asset.status === 'failed' : asset.status === "failed"
? 'error' ? "error"
: 'processing' : "processing"
} }
style={{ marginTop: 4 }} style={{ marginTop: 4 }}
> >
@@ -366,7 +385,7 @@ const AssetLibrary: React.FC = () => {
} }
confirmLoading={createLibMutation.isPending} confirmLoading={createLibMutation.isPending}
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
<Input <Input
placeholder="素材库名称" placeholder="素材库名称"
value={newLibName} value={newLibName}
@@ -375,11 +394,11 @@ const AssetLibrary: React.FC = () => {
<Select <Select
value={newLibKind} value={newLibKind}
onChange={setNewLibKind} onChange={setNewLibKind}
style={{ width: '100%' }} style={{ width: "100%" }}
options={[ options={[
{ value: 'video', label: '视频素材' }, { value: "video", label: "视频素材" },
{ value: 'voice', label: '配音素材' }, { value: "voice", label: "配音素材" },
{ value: 'image', label: '图片素材' }, { value: "image", label: "图片素材" },
]} ]}
/> />
</Space> </Space>
+10 -2
View File
@@ -3,7 +3,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
@@ -82,7 +86,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
+28 -15
View File
@@ -1,13 +1,13 @@
/** /**
* 忘记密码页面 - V21 完全对标 * 忘记密码页面 - V21 完全对标
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { Form, Input, Button, Result, message } from 'antd'; import { Form, Input, Button, Result, message } from "antd";
import { MailOutlined } from '@ant-design/icons'; import { MailOutlined } from "@ant-design/icons";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import { requestPasswordReset } from '@/api/auth'; import { requestPasswordReset } from "@/api/auth";
import './ForgotPassword.css'; import "./ForgotPassword.css";
const ForgotPassword: React.FC = () => { const ForgotPassword: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -17,10 +17,11 @@ const ForgotPassword: React.FC = () => {
mutationFn: (email: string) => requestPasswordReset(email), mutationFn: (email: string) => requestPasswordReset(email),
onSuccess: () => { onSuccess: () => {
setEmailSent(true); setEmailSent(true);
message.success('重置邮件已发送!'); message.success("重置邮件已发送!");
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error('发送失败,请重试'); if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("发送失败,请重试");
}, },
}); });
@@ -56,17 +57,29 @@ const ForgotPassword: React.FC = () => {
<p></p> <p></p>
</div> </div>
<p style={{ marginBottom: '24px', color: '#64748b', fontSize: '14px', textAlign: 'center' }}> <p
style={{
marginBottom: "24px",
color: "#64748b",
fontSize: "14px",
textAlign: "center",
}}
>
</p> </p>
<Form form={form} name="forgot-password" onFinish={onFinish} layout="vertical"> <Form
form={form}
name="forgot-password"
onFinish={onFinish}
layout="vertical"
>
<Form.Item <Form.Item
name="email" name="email"
label="邮箱" label="邮箱"
rules={[ rules={[
{ required: true, message: '请输入邮箱' }, { required: true, message: "请输入邮箱" },
{ type: 'email', message: '请输入有效的邮箱地址' }, { type: "email", message: "请输入有效的邮箱地址" },
]} ]}
> >
<Input <Input
@@ -80,10 +93,10 @@ const ForgotPassword: React.FC = () => {
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
style={{ width: '100%' }} style={{ width: "100%" }}
disabled={resetMutation.isPending} disabled={resetMutation.isPending}
> >
{resetMutation.isPending ? '发送中...' : '发送重置邮件'} {resetMutation.isPending ? "发送中..." : "发送重置邮件"}
</button> </button>
</Form.Item> </Form.Item>
+5 -1
View File
@@ -3,7 +3,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
+18 -14
View File
@@ -1,11 +1,11 @@
/** /**
* 登录页面 - V21 完全对标 * 登录页面 - V21 完全对标
*/ */
import React from 'react'; import React from "react";
import { Form, Input, Checkbox, message } from 'antd'; import { Form, Input, Checkbox, message } from "antd";
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from "react-router-dom";
import { useLogin } from '@/hooks/useAuth'; import { useLogin } from "@/hooks/useAuth";
import './Login.css'; import "./Login.css";
interface LoginFormValues { interface LoginFormValues {
email: string; email: string;
@@ -24,10 +24,11 @@ const Login: React.FC = () => {
email: values.email, email: values.email,
password: values.password, password: values.password,
}); });
message.success('登录成功!'); message.success("登录成功!");
navigate('/'); navigate("/");
} catch (error: unknown) { } catch (error: unknown) {
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error('登录失败,请检查邮箱和密码'); if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("登录失败,请检查邮箱和密码");
} }
}; };
@@ -51,8 +52,8 @@ const Login: React.FC = () => {
name="email" name="email"
label="邮箱" label="邮箱"
rules={[ rules={[
{ required: true, message: '请输入邮箱' }, { required: true, message: "请输入邮箱" },
{ type: 'email', message: '请输入有效的邮箱' }, { type: "email", message: "请输入有效的邮箱" },
]} ]}
> >
<Input placeholder="name@example.com" size="large" /> <Input placeholder="name@example.com" size="large" />
@@ -61,7 +62,7 @@ const Login: React.FC = () => {
<Form.Item <Form.Item
name="password" name="password"
label="密码" label="密码"
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: "请输入密码" }]}
> >
<Input.Password placeholder="•••••••••" size="large" /> <Input.Password placeholder="•••••••••" size="large" />
</Form.Item> </Form.Item>
@@ -70,7 +71,10 @@ const Login: React.FC = () => {
<Form.Item name="remember" valuePropName="checked" noStyle> <Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>
<Link to="/forgot-password" style={{ float: 'right', fontSize: '12px' }}> <Link
to="/forgot-password"
style={{ float: "right", fontSize: "12px" }}
>
</Link> </Link>
</Form.Item> </Form.Item>
@@ -79,10 +83,10 @@ const Login: React.FC = () => {
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
style={{ width: '100%' }} style={{ width: "100%" }}
disabled={loginMutation.isPending} disabled={loginMutation.isPending}
> >
{loginMutation.isPending ? '登录中...' : '登录'} {loginMutation.isPending ? "登录中..." : "登录"}
</button> </button>
</Form.Item> </Form.Item>
+5 -1
View File
@@ -3,7 +3,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
+24 -23
View File
@@ -1,12 +1,12 @@
/** /**
* 注册页面 - V21 完全对标 * 注册页面 - V21 完全对标
*/ */
import React from 'react'; import React from "react";
import { Form, Input, message } from 'antd'; import { Form, Input, message } from "antd";
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { useRegister } from '@/hooks/useAuth'; import { useRegister } from "@/hooks/useAuth";
import './Register.css'; import "./Register.css";
interface RegisterFormValues { interface RegisterFormValues {
email: string; email: string;
@@ -27,9 +27,10 @@ const Register: React.FC = () => {
password: values.password, password: values.password,
display_name: values.username, display_name: values.username,
}); });
message.success('注册成功!请查收验证邮件。'); message.success("注册成功!请查收验证邮件。");
} catch (error: unknown) { } catch (error: unknown) {
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error('注册失败,请重试'); if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("注册失败,请重试");
} }
}; };
@@ -53,8 +54,8 @@ const Register: React.FC = () => {
name="email" name="email"
label="邮箱" label="邮箱"
rules={[ rules={[
{ required: true, message: '请输入邮箱' }, { required: true, message: "请输入邮箱" },
{ type: 'email', message: '请输入有效的邮箱地址' }, { type: "email", message: "请输入有效的邮箱地址" },
]} ]}
> >
<Input <Input
@@ -68,12 +69,12 @@ const Register: React.FC = () => {
name="username" name="username"
label="用户名" label="用户名"
rules={[ rules={[
{ required: true, message: '请输入用户名' }, { required: true, message: "请输入用户名" },
{ min: 3, message: '用户名至少 3 个字符' }, { min: 3, message: "用户名至少 3 个字符" },
{ max: 20, message: '用户名最多 20 个字符' }, { max: 20, message: "用户名最多 20 个字符" },
{ {
pattern: /^[a-zA-Z0-9_]+$/, pattern: /^[a-zA-Z0-9_]+$/,
message: '用户名只能包含字母、数字和下划线', message: "用户名只能包含字母、数字和下划线",
}, },
]} ]}
> >
@@ -88,11 +89,11 @@ const Register: React.FC = () => {
name="password" name="password"
label="密码" label="密码"
rules={[ rules={[
{ required: true, message: '请输入密码' }, { required: true, message: "请输入密码" },
{ min: 8, message: '密码至少 8 个字符' }, { min: 8, message: "密码至少 8 个字符" },
{ {
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: '密码必须包含大小写字母和数字', message: "密码必须包含大小写字母和数字",
}, },
]} ]}
> >
@@ -106,15 +107,15 @@ const Register: React.FC = () => {
<Form.Item <Form.Item
name="confirmPassword" name="confirmPassword"
label="确认密码" label="确认密码"
dependencies={['password']} dependencies={["password"]}
rules={[ rules={[
{ required: true, message: '请确认密码' }, { required: true, message: "请确认密码" },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('password') === value) { if (!value || getFieldValue("password") === value) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(new Error("两次输入的密码不一致"));
}, },
}), }),
]} ]}
@@ -130,10 +131,10 @@ const Register: React.FC = () => {
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
style={{ width: '100%' }} style={{ width: "100%" }}
disabled={registerMutation.isPending} disabled={registerMutation.isPending}
> >
{registerMutation.isPending ? '注册中...' : '注册'} {registerMutation.isPending ? "注册中..." : "注册"}
</button> </button>
</Form.Item> </Form.Item>
+10 -2
View File
@@ -3,7 +3,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
@@ -82,7 +86,11 @@
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.08),
rgba(16, 185, 129, 0.06)
);
padding: 24px; padding: 24px;
} }
+27 -21
View File
@@ -1,28 +1,29 @@
/** /**
* 重置密码页面 - V21 完全对标 * 重置密码页面 - V21 完全对标
*/ */
import React from 'react'; import React from "react";
import { Form, Input, Button, Result, message } from 'antd'; import { Form, Input, Button, Result, message } from "antd";
import { LockOutlined } from '@ant-design/icons'; import { LockOutlined } from "@ant-design/icons";
import { Link, useSearchParams, useNavigate } from 'react-router-dom'; import { Link, useSearchParams, useNavigate } from "react-router-dom";
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import { resetPassword } from '@/api/auth'; import { resetPassword } from "@/api/auth";
import './ResetPassword.css'; import "./ResetPassword.css";
const ResetPassword: React.FC = () => { const ResetPassword: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const token = searchParams.get('token'); const token = searchParams.get("token");
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: (password: string) => resetPassword(token!, password), mutationFn: (password: string) => resetPassword(token!, password),
onSuccess: () => { onSuccess: () => {
message.success('密码重置成功!'); message.success("密码重置成功!");
setTimeout(() => navigate('/login'), 2000); setTimeout(() => navigate("/login"), 2000);
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error('重置失败,请重试'); if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("重置失败,请重试");
}, },
}); });
@@ -77,16 +78,21 @@ const ResetPassword: React.FC = () => {
<p></p> <p></p>
</div> </div>
<Form form={form} name="reset-password" onFinish={onFinish} layout="vertical"> <Form
form={form}
name="reset-password"
onFinish={onFinish}
layout="vertical"
>
<Form.Item <Form.Item
name="password" name="password"
label="新密码" label="新密码"
rules={[ rules={[
{ required: true, message: '请输入新密码' }, { required: true, message: "请输入新密码" },
{ min: 8, message: '密码至少 8 个字符' }, { min: 8, message: "密码至少 8 个字符" },
{ {
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: '密码必须包含大小写字母和数字', message: "密码必须包含大小写字母和数字",
}, },
]} ]}
> >
@@ -100,15 +106,15 @@ const ResetPassword: React.FC = () => {
<Form.Item <Form.Item
name="confirmPassword" name="confirmPassword"
label="确认新密码" label="确认新密码"
dependencies={['password']} dependencies={["password"]}
rules={[ rules={[
{ required: true, message: '请确认新密码' }, { required: true, message: "请确认新密码" },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('password') === value) { if (!value || getFieldValue("password") === value) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(new Error("两次输入的密码不一致"));
}, },
}), }),
]} ]}
@@ -124,10 +130,10 @@ const ResetPassword: React.FC = () => {
<button <button
type="submit" type="submit"
className="btn primary" className="btn primary"
style={{ width: '100%' }} style={{ width: "100%" }}
disabled={resetMutation.isPending} disabled={resetMutation.isPending}
> >
{resetMutation.isPending ? '重置中...' : '重置密码'} {resetMutation.isPending ? "重置中..." : "重置密码"}
</button> </button>
</Form.Item> </Form.Item>
+72 -68
View File
@@ -2,8 +2,8 @@
* 仪表盘页面 * 仪表盘页面
* 展示用户用量总览和最近生成记录 * 展示用户用量总览和最近生成记录
*/ */
import React from 'react'; import React from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { import {
Card, Card,
Col, Col,
@@ -17,7 +17,7 @@ import {
Progress, Progress,
Space, Space,
Alert, Alert,
} from 'antd'; } from "antd";
import { import {
FileOutlined, FileOutlined,
VideoCameraOutlined, VideoCameraOutlined,
@@ -27,17 +27,17 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { getDashboardOverview } from '@/api/dashboard'; import { getDashboardOverview } from "@/api/dashboard";
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from "antd/es/table";
const { Title } = Typography; const { Title } = Typography;
/** 格式化文件大小 */ /** 格式化文件大小 */
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return "0 B";
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}; };
@@ -45,10 +45,10 @@ const formatFileSize = (bytes: number): string => {
/** 任务状态标签 */ /** 任务状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => { const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<string, { color: string; icon: React.ReactNode }> = { const config: Record<string, { color: string; icon: React.ReactNode }> = {
completed: { color: 'success', icon: <CheckCircleOutlined /> }, completed: { color: "success", icon: <CheckCircleOutlined /> },
processing: { color: 'processing', icon: <ClockCircleOutlined /> }, processing: { color: "processing", icon: <ClockCircleOutlined /> },
pending: { color: 'default', icon: <ClockCircleOutlined /> }, pending: { color: "default", icon: <ClockCircleOutlined /> },
failed: { color: 'error', icon: <CloseCircleOutlined /> }, failed: { color: "error", icon: <CloseCircleOutlined /> },
}; };
const c = config[status] || config.pending; const c = config[status] || config.pending;
return ( return (
@@ -62,51 +62,50 @@ const Dashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['dashboard-overview'], queryKey: ["dashboard-overview"],
queryFn: getDashboardOverview, queryFn: getDashboardOverview,
}); });
const taskColumns: ColumnsType<NonNullable<typeof data>['recent_tasks'][number]> = const taskColumns: ColumnsType<
[ NonNullable<typeof data>["recent_tasks"][number]
{ > = [
title: '任务类型', {
dataIndex: 'task_type', title: "任务类型",
key: 'task_type', dataIndex: "task_type",
render: (type: string) => key: "task_type",
type === 'generation' ? '视频生成' : type, render: (type: string) => (type === "generation" ? "视频生成" : type),
}, },
{ {
title: '状态', title: "状态",
dataIndex: 'status', dataIndex: "status",
key: 'status', key: "status",
render: (status: string) => <StatusTag status={status} />, render: (status: string) => <StatusTag status={status} />,
}, },
{ {
title: '进度', title: "进度",
dataIndex: 'progress', dataIndex: "progress",
key: 'progress', key: "progress",
render: (progress: number) => ( render: (progress: number) => (
<Progress percent={Math.round(progress * 100)} size="small" /> <Progress percent={Math.round(progress * 100)} size="small" />
), ),
}, },
{ {
title: '信息', title: "信息",
dataIndex: 'user_message', dataIndex: "user_message",
key: 'user_message', key: "user_message",
ellipsis: true, ellipsis: true,
}, },
{ {
title: '创建时间', title: "创建时间",
dataIndex: 'created_at', dataIndex: "created_at",
key: 'created_at', key: "created_at",
render: (t: string) => render: (t: string) => (t ? new Date(t).toLocaleString("zh-CN") : "-"),
t ? new Date(t).toLocaleString('zh-CN') : '-', },
}, ];
];
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@@ -114,14 +113,19 @@ const Dashboard: React.FC = () => {
if (isError) { if (isError) {
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Alert type="error" message="加载数据失败" description="仪表盘数据获取失败,请刷新页面重试。" showIcon /> <Alert
type="error"
message="加载数据失败"
description="仪表盘数据获取失败,请刷新页面重试。"
showIcon
/>
</div> </div>
); );
} }
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}> <Title level={3} style={{ marginBottom: 24 }}>
</Title> </Title>
@@ -129,7 +133,7 @@ const Dashboard: React.FC = () => {
{/* 用量统计卡片 */} {/* 用量统计卡片 */}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/assets')}> <Card hoverable onClick={() => navigate("/assets")}>
<Statistic <Statistic
title="素材" title="素材"
value={data?.total_assets ?? 0} value={data?.total_assets ?? 0}
@@ -139,7 +143,7 @@ const Dashboard: React.FC = () => {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/assets')}> <Card hoverable onClick={() => navigate("/assets")}>
<Statistic <Statistic
title="存储空间" title="存储空间"
value={formatFileSize(data?.used_storage_bytes ?? 0)} value={formatFileSize(data?.used_storage_bytes ?? 0)}
@@ -148,7 +152,7 @@ const Dashboard: React.FC = () => {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/titles')}> <Card hoverable onClick={() => navigate("/titles")}>
<Statistic <Statistic
title="标题" title="标题"
value={data?.total_titles ?? 0} value={data?.total_titles ?? 0}
@@ -158,7 +162,7 @@ const Dashboard: React.FC = () => {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/voices')}> <Card hoverable onClick={() => navigate("/voices")}>
<Statistic <Statistic
title="配音" title="配音"
value={data?.total_voices ?? 0} value={data?.total_voices ?? 0}
@@ -168,7 +172,7 @@ const Dashboard: React.FC = () => {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/history')}> <Card hoverable onClick={() => navigate("/history")}>
<Statistic <Statistic
title="生成任务" title="生成任务"
value={data?.total_tasks ?? 0} value={data?.total_tasks ?? 0}
@@ -178,7 +182,7 @@ const Dashboard: React.FC = () => {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Card hoverable onClick={() => navigate('/products')}> <Card hoverable onClick={() => navigate("/products")}>
<Statistic <Statistic
title="成品" title="成品"
value={data?.total_products ?? 0} value={data?.total_products ?? 0}
@@ -192,12 +196,12 @@ const Dashboard: React.FC = () => {
{/* 快捷操作 */} {/* 快捷操作 */}
<Card style={{ marginTop: 24 }}> <Card style={{ marginTop: 24 }}>
<Space wrap> <Space wrap>
<Button type="primary" onClick={() => navigate('/generate')}> <Button type="primary" onClick={() => navigate("/generate")}>
</Button> </Button>
<Button onClick={() => navigate('/assets')}></Button> <Button onClick={() => navigate("/assets")}></Button>
<Button onClick={() => navigate('/templates')}></Button> <Button onClick={() => navigate("/templates")}></Button>
<Button onClick={() => navigate('/subscription')}></Button> <Button onClick={() => navigate("/subscription")}></Button>
</Space> </Space>
</Card> </Card>
@@ -209,12 +213,12 @@ const Dashboard: React.FC = () => {
rowKey="id" rowKey="id"
pagination={false} pagination={false}
size="small" size="small"
locale={{ emptyText: '暂无生成记录' }} locale={{ emptyText: "暂无生成记录" }}
scroll={{ x: 600 }} scroll={{ x: 600 }}
/> />
{(data?.recent_tasks?.length ?? 0) > 0 && ( {(data?.recent_tasks?.length ?? 0) > 0 && (
<div style={{ textAlign: 'center', marginTop: 12 }}> <div style={{ textAlign: "center", marginTop: 12 }}>
<Button type="link" onClick={() => navigate('/history')}> <Button type="link" onClick={() => navigate("/history")}>
</Button> </Button>
</div> </div>
@@ -2,8 +2,8 @@
* 重复视频对比详情页面 * 重复视频对比详情页面
* 展示查重结果中的重复片段详情,支持时间线对比 * 展示查重结果中的重复片段详情,支持时间线对比
*/ */
import React from 'react'; import React from "react";
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { import {
Typography, Typography,
Card, Card,
@@ -18,18 +18,15 @@ import {
Col, Col,
Tooltip, Tooltip,
Divider, Divider,
} from 'antd'; } from "antd";
import { import {
ArrowLeftOutlined, ArrowLeftOutlined,
VideoCameraOutlined, VideoCameraOutlined,
ClockCircleOutlined, ClockCircleOutlined,
WarningOutlined, WarningOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from "react-router-dom";
import { import { getDuplicationDetail, type DuplicateSegment } from "@/api/duplication";
getDuplicationDetail,
type DuplicateSegment,
} from '@/api/duplication';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@@ -37,7 +34,7 @@ const { Title, Text, Paragraph } = Typography;
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}; };
/** 格式化文件大小 */ /** 格式化文件大小 */
@@ -50,16 +47,16 @@ const formatSize = (bytes: number) => {
/** 查重率颜色 */ /** 查重率颜色 */
const getRateColor = (rate: number) => { const getRateColor = (rate: number) => {
if (rate <= 10) return '#52c41a'; if (rate <= 10) return "#52c41a";
if (rate <= 30) return '#faad14'; if (rate <= 30) return "#faad14";
return '#ff4d4f'; return "#ff4d4f";
}; };
/** 相似度颜色 */ /** 相似度颜色 */
const getSimilarityColor = (similarity: number) => { const getSimilarityColor = (similarity: number) => {
if (similarity >= 90) return '#ff4d4f'; if (similarity >= 90) return "#ff4d4f";
if (similarity >= 70) return '#faad14'; if (similarity >= 70) return "#faad14";
return '#52c41a'; return "#52c41a";
}; };
/** 单个重复片段卡片 */ /** 单个重复片段卡片 */
@@ -78,7 +75,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
<Tag color="blue"> {index + 1}</Tag> <Tag color="blue"> {index + 1}</Tag>
<Tag <Tag
color={getSimilarityColor(segment.similarity)} color={getSimilarityColor(segment.similarity)}
style={{ fontWeight: 'bold' }} style={{ fontWeight: "bold" }}
> >
{segment.similarity.toFixed(1)}% {segment.similarity.toFixed(1)}%
</Tag> </Tag>
@@ -102,7 +99,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="时间范围"> <Descriptions.Item label="时间范围">
<Tag color="blue"> <Tag color="blue">
{formatTime(segment.source_start)} -{' '} {formatTime(segment.source_start)} -{" "}
{formatTime(segment.source_end)} {formatTime(segment.source_end)}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
@@ -115,32 +112,32 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
<div <div
style={{ style={{
marginTop: 12, marginTop: 12,
background: '#f5f5f5', background: "#f5f5f5",
borderRadius: 4, borderRadius: 4,
height: 24, height: 24,
position: 'relative', position: "relative",
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<div <div
style={{ style={{
position: 'absolute', position: "absolute",
left: `${(segment.source_start / (segment.source_end + 30)) * 100}%`, left: `${(segment.source_start / (segment.source_end + 30)) * 100}%`,
width: `${(sourceDuration / (segment.source_end + 30)) * 100}%`, width: `${(sourceDuration / (segment.source_end + 30)) * 100}%`,
height: '100%', height: "100%",
background: 'rgba(24, 144, 255, 0.4)', background: "rgba(24, 144, 255, 0.4)",
border: '1px solid #1890ff', border: "1px solid #1890ff",
borderRadius: 2, borderRadius: 2,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}} }}
> >
<Text <Text
style={{ style={{
fontSize: 10, fontSize: 10,
color: '#1890ff', color: "#1890ff",
fontWeight: 'bold', fontWeight: "bold",
}} }}
> >
{sourceDuration.toFixed(0)}s {sourceDuration.toFixed(0)}s
@@ -157,7 +154,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
type="inner" type="inner"
title={ title={
<Space> <Space>
<WarningOutlined style={{ color: '#faad14' }} /> <WarningOutlined style={{ color: "#faad14" }} />
<Text strong></Text> <Text strong></Text>
</Space> </Space>
} }
@@ -172,7 +169,7 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="时间范围"> <Descriptions.Item label="时间范围">
<Tag color="orange"> <Tag color="orange">
{formatTime(segment.matched_start)} -{' '} {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)} {formatTime(segment.matched_end)}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
@@ -185,32 +182,32 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
<div <div
style={{ style={{
marginTop: 12, marginTop: 12,
background: '#f5f5f5', background: "#f5f5f5",
borderRadius: 4, borderRadius: 4,
height: 24, height: 24,
position: 'relative', position: "relative",
overflow: 'hidden', overflow: "hidden",
}} }}
> >
<div <div
style={{ style={{
position: 'absolute', position: "absolute",
left: `${(segment.matched_start / (segment.matched_end + 30)) * 100}%`, left: `${(segment.matched_start / (segment.matched_end + 30)) * 100}%`,
width: `${(matchedDuration / (segment.matched_end + 30)) * 100}%`, width: `${(matchedDuration / (segment.matched_end + 30)) * 100}%`,
height: '100%', height: "100%",
background: 'rgba(250, 173, 20, 0.4)', background: "rgba(250, 173, 20, 0.4)",
border: '1px solid #faad14', border: "1px solid #faad14",
borderRadius: 2, borderRadius: 2,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
}} }}
> >
<Text <Text
style={{ style={{
fontSize: 10, fontSize: 10,
color: '#faad14', color: "#faad14",
fontWeight: 'bold', fontWeight: "bold",
}} }}
> >
{matchedDuration.toFixed(0)}s {matchedDuration.toFixed(0)}s
@@ -228,15 +225,19 @@ const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: detail, isLoading, isError } = useQuery({ const {
queryKey: ['duplication-detail', id], data: detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!), queryFn: () => getDuplicationDetail(id!),
enabled: !!id, enabled: !!id,
}); });
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@@ -246,7 +247,7 @@ const DuplicationDetail: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Empty description="加载查重记录失败"> <Empty description="加载查重记录失败">
<Button onClick={() => navigate('/duplication/results')}> <Button onClick={() => navigate("/duplication/results")}>
</Button> </Button>
</Empty> </Empty>
@@ -258,7 +259,7 @@ const DuplicationDetail: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Empty description="未找到查重记录"> <Empty description="未找到查重记录">
<Button onClick={() => navigate('/duplication/results')}> <Button onClick={() => navigate("/duplication/results")}>
</Button> </Button>
</Empty> </Empty>
@@ -267,12 +268,12 @@ const DuplicationDetail: React.FC = () => {
} }
return ( return (
<div style={{ padding: '24px', maxWidth: 1000, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1000, margin: "0 auto" }}>
{/* 顶部导航 */} {/* 顶部导航 */}
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button <Button
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
onClick={() => navigate('/duplication/results')} onClick={() => navigate("/duplication/results")}
> >
</Button> </Button>
@@ -298,14 +299,14 @@ const DuplicationDetail: React.FC = () => {
<Descriptions.Item label="视频时长"> <Descriptions.Item label="视频时长">
{detail.duration_seconds {detail.duration_seconds
? `${Math.floor(detail.duration_seconds / 60)}${detail.duration_seconds % 60}` ? `${Math.floor(detail.duration_seconds / 60)}${detail.duration_seconds % 60}`
: '-'} : "-"}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="提交时间"> <Descriptions.Item label="提交时间">
{new Date(detail.created_at).toLocaleString('zh-CN')} {new Date(detail.created_at).toLocaleString("zh-CN")}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Col> </Col>
@@ -325,7 +326,7 @@ const DuplicationDetail: React.FC = () => {
</Text> </Text>
</Space> </Space>
) : ( ) : (
'-' "-"
)} )}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
@@ -343,10 +344,10 @@ const DuplicationDetail: React.FC = () => {
strokeColor={getRateColor(detail.duplicate_rate)} strokeColor={getRateColor(detail.duplicate_rate)}
status={ status={
detail.duplicate_rate <= 10 detail.duplicate_rate <= 10
? 'success' ? "success"
: detail.duplicate_rate <= 30 : detail.duplicate_rate <= 30
? 'normal' ? "normal"
: 'exception' : "exception"
} }
/> />
</div> </div>
@@ -368,7 +369,7 @@ const DuplicationDetail: React.FC = () => {
<Paragraph type="secondary" style={{ marginBottom: 16 }}> <Paragraph type="secondary" style={{ marginBottom: 16 }}>
</Paragraph> </Paragraph>
<Divider style={{ margin: '0 0 16px 0' }} /> <Divider style={{ margin: "0 0 16px 0" }} />
{detail.segments.map((segment, index) => ( {detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} /> <SegmentCard key={segment.id} segment={segment} index={index} />
))} ))}
@@ -2,8 +2,8 @@
* 查重结果列表页面 * 查重结果列表页面
* 展示所有查重记录,支持查看详情、删除、重新查重 * 展示所有查重记录,支持查看详情、删除、重新查重
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Typography, Typography,
Table, Table,
@@ -14,8 +14,8 @@ import {
message, message,
Progress, Progress,
Tooltip, Tooltip,
} from 'antd'; } from "antd";
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from "antd/es/table";
import { import {
EyeOutlined, EyeOutlined,
DeleteOutlined, DeleteOutlined,
@@ -25,15 +25,15 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
SyncOutlined, SyncOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { import {
getDuplicationRecords, getDuplicationRecords,
deleteDuplicationRecord, deleteDuplicationRecord,
retryDuplication, retryDuplication,
type DuplicationRecord, type DuplicationRecord,
type DuplicationStatus, type DuplicationStatus,
} from '@/api/duplication'; } from "@/api/duplication";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -43,32 +43,32 @@ const STATUS_CONFIG: Record<
{ color: string; text: string; icon: React.ReactNode } { color: string; text: string; icon: React.ReactNode }
> = { > = {
pending: { pending: {
color: 'default', color: "default",
text: '等待中', text: "等待中",
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined />,
}, },
processing: { processing: {
color: 'processing', color: "processing",
text: '查重中', text: "查重中",
icon: <SyncOutlined spin />, icon: <SyncOutlined spin />,
}, },
completed: { completed: {
color: 'success', color: "success",
text: '已完成', text: "已完成",
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
}, },
failed: { failed: {
color: 'error', color: "error",
text: '失败', text: "失败",
icon: <CloseCircleOutlined />, icon: <CloseCircleOutlined />,
}, },
}; };
/** 查重率颜色 */ /** 查重率颜色 */
const getRateColor = (rate: number) => { const getRateColor = (rate: number) => {
if (rate <= 10) return '#52c41a'; if (rate <= 10) return "#52c41a";
if (rate <= 30) return '#faad14'; if (rate <= 30) return "#faad14";
return '#ff4d4f'; return "#ff4d4f";
}; };
/** 格式化文件大小 */ /** 格式化文件大小 */
@@ -81,7 +81,7 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return '-'; if (!seconds) return "-";
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;
return m > 0 ? `${m}${s}` : `${s}`; return m > 0 ? `${m}${s}` : `${s}`;
@@ -94,7 +94,7 @@ const DuplicationResults: React.FC = () => {
// 获取查重记录 // 获取查重记录
const { data: records = [], isLoading } = useQuery({ const { data: records = [], isLoading } = useQuery({
queryKey: ['duplication-records'], queryKey: ["duplication-records"],
queryFn: getDuplicationRecords, queryFn: getDuplicationRecords,
}); });
@@ -102,28 +102,34 @@ const DuplicationResults: React.FC = () => {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteDuplicationRecord, mutationFn: deleteDuplicationRecord,
onSuccess: () => { onSuccess: () => {
message.success('已删除'); message.success("已删除");
queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败') },
}); });
// 重新查重 // 重新查重
const retryMutation = useMutation({ const retryMutation = useMutation({
mutationFn: retryDuplication, mutationFn: retryDuplication,
onSuccess: () => { onSuccess: () => {
message.success('已重新提交查重'); message.success("已重新提交查重");
queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("重新查重失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('重新查重失败') },
}); });
/** 批量删除 */ /** 批量删除 */
const handleBatchDelete = async () => { const handleBatchDelete = async () => {
const results = await Promise.allSettled( const results = await Promise.allSettled(
selectedRowKeys.map((key) => deleteDuplicationRecord(String(key))) selectedRowKeys.map((key) => deleteDuplicationRecord(String(key))),
); );
const succeeded = results.filter((r) => r.status === 'fulfilled').length; const succeeded = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - succeeded; const failed = results.length - succeeded;
if (failed === 0) { if (failed === 0) {
message.success(`已删除 ${succeeded} 条记录`); message.success(`已删除 ${succeeded} 条记录`);
@@ -131,14 +137,14 @@ const DuplicationResults: React.FC = () => {
message.warning(`删除完成:${succeeded} 条成功,${failed} 条失败`); message.warning(`删除完成:${succeeded} 条成功,${failed} 条失败`);
} }
setSelectedRowKeys([]); setSelectedRowKeys([]);
queryClient.invalidateQueries({ queryKey: ['duplication-records'] }); queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
}; };
const columns: ColumnsType<DuplicationRecord> = [ const columns: ColumnsType<DuplicationRecord> = [
{ {
title: '文件名', title: "文件名",
dataIndex: 'filename', dataIndex: "filename",
key: 'filename', key: "filename",
ellipsis: true, ellipsis: true,
width: 200, width: 200,
render: (text: string) => ( render: (text: string) => (
@@ -148,23 +154,23 @@ const DuplicationResults: React.FC = () => {
), ),
}, },
{ {
title: '文件大小', title: "文件大小",
dataIndex: 'file_size', dataIndex: "file_size",
key: 'file_size', key: "file_size",
width: 100, width: 100,
render: (size: number) => formatSize(size), render: (size: number) => formatSize(size),
}, },
{ {
title: '时长', title: "时长",
dataIndex: 'duration_seconds', dataIndex: "duration_seconds",
key: 'duration_seconds', key: "duration_seconds",
width: 80, width: 80,
render: (seconds?: number) => formatDuration(seconds), render: (seconds?: number) => formatDuration(seconds),
}, },
{ {
title: '状态', title: "状态",
dataIndex: 'status', dataIndex: "status",
key: 'status', key: "status",
width: 100, width: 100,
render: (status: DuplicationStatus) => { render: (status: DuplicationStatus) => {
const cfg = STATUS_CONFIG[status]; const cfg = STATUS_CONFIG[status];
@@ -176,12 +182,12 @@ const DuplicationResults: React.FC = () => {
}, },
}, },
{ {
title: '查重率', title: "查重率",
dataIndex: 'duplicate_rate', dataIndex: "duplicate_rate",
key: 'duplicate_rate', key: "duplicate_rate",
width: 140, width: 140,
render: (rate?: number, record?: DuplicationRecord) => { render: (rate?: number, record?: DuplicationRecord) => {
if (record?.status !== 'completed' || rate === undefined) return '-'; if (record?.status !== "completed" || rate === undefined) return "-";
return ( return (
<Space> <Space>
<Progress <Progress
@@ -198,31 +204,31 @@ const DuplicationResults: React.FC = () => {
}, },
}, },
{ {
title: '重复片段', title: "重复片段",
dataIndex: 'duplicate_count', dataIndex: "duplicate_count",
key: 'duplicate_count', key: "duplicate_count",
width: 80, width: 80,
align: 'center', align: "center",
render: (count?: number, record?: DuplicationRecord) => { render: (count?: number, record?: DuplicationRecord) => {
if (record?.status !== 'completed') return '-'; if (record?.status !== "completed") return "-";
return <Text>{count ?? 0}</Text>; return <Text>{count ?? 0}</Text>;
}, },
}, },
{ {
title: '提交时间', title: "提交时间",
dataIndex: 'created_at', dataIndex: "created_at",
key: 'created_at', key: "created_at",
width: 160, width: 160,
render: (time: string) => new Date(time).toLocaleString('zh-CN'), render: (time: string) => new Date(time).toLocaleString("zh-CN"),
}, },
{ {
title: '操作', title: "操作",
key: 'action', key: "action",
width: 160, width: 160,
fixed: 'right', fixed: "right",
render: (_: unknown, record: DuplicationRecord) => ( render: (_: unknown, record: DuplicationRecord) => (
<Space size="small"> <Space size="small">
{record.status === 'completed' && ( {record.status === "completed" && (
<Tooltip title="查看详情"> <Tooltip title="查看详情">
<Button <Button
type="text" type="text"
@@ -232,7 +238,7 @@ const DuplicationResults: React.FC = () => {
/> />
</Tooltip> </Tooltip>
)} )}
{record.status === 'failed' && ( {record.status === "failed" && (
<Tooltip title="重新查重"> <Tooltip title="重新查重">
<Button <Button
type="text" type="text"
@@ -254,14 +260,14 @@ const DuplicationResults: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
marginBottom: 24, marginBottom: 24,
flexWrap: 'wrap', flexWrap: "wrap",
gap: 12, gap: 12,
}} }}
> >
@@ -282,7 +288,7 @@ const DuplicationResults: React.FC = () => {
<Button <Button
type="primary" type="primary"
icon={<UploadOutlined />} icon={<UploadOutlined />}
onClick={() => navigate('/duplication')} onClick={() => navigate("/duplication")}
> >
</Button> </Button>
@@ -2,8 +2,8 @@
* 上传查重页面 * 上传查重页面
* 用户上传视频文件,系统进行查重检测 * 用户上传视频文件,系统进行查重检测
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import { import {
Button, Button,
Typography, Typography,
@@ -14,22 +14,22 @@ import {
Alert, Alert,
Space, Space,
Result, Result,
} from 'antd'; } from "antd";
import { import {
InboxOutlined, InboxOutlined,
VideoCameraOutlined, VideoCameraOutlined,
CheckCircleOutlined, CheckCircleOutlined,
LoadingOutlined, LoadingOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { uploadForDuplication } from '@/api/duplication'; import { uploadForDuplication } from "@/api/duplication";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from "antd/es/upload";
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
const { Dragger } = Upload; const { Dragger } = Upload;
/** 支持的视频格式 */ /** 支持的视频格式 */
const ACCEPT_FORMATS = '.mp4,.avi,.mov,.mkv,.wmv,.flv,.webm'; const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm";
/** 最大文件大小:2GB */ /** 最大文件大小:2GB */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
@@ -51,11 +51,12 @@ const DuplicationUpload: React.FC = () => {
id: data.id, id: data.id,
message: data.message, message: data.message,
}); });
message.success('查重任务已提交'); message.success("查重任务已提交");
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
setUploading(false); setUploading(false);
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('上传失败,请重试'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("上传失败,请重试");
}, },
}); });
@@ -63,14 +64,14 @@ const DuplicationUpload: React.FC = () => {
const handleUpload = (file: File) => { const handleUpload = (file: File) => {
// 校验文件大小 // 校验文件大小
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 2GB'); message.error("文件大小不能超过 2GB");
return false; return false;
} }
// 校验文件类型 // 校验文件类型
const ext = file.name.toLowerCase().split('.').pop(); const ext = file.name.toLowerCase().split(".").pop();
const allowedExts = ACCEPT_FORMATS.replace(/\./g, '').split(','); const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",");
if (!allowedExts.includes(ext || '')) { if (!allowedExts.includes(ext || "")) {
message.error(`不支持的文件格式,支持:${ACCEPT_FORMATS}`); message.error(`不支持的文件格式,支持:${ACCEPT_FORMATS}`);
return false; return false;
} }
@@ -89,7 +90,7 @@ const DuplicationUpload: React.FC = () => {
}; };
return ( return (
<div style={{ padding: '24px', maxWidth: 800, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 800, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 8 }}> <Title level={3} style={{ marginBottom: 8 }}>
<VideoCameraOutlined style={{ marginRight: 8 }} /> <VideoCameraOutlined style={{ marginRight: 8 }} />
@@ -107,17 +108,17 @@ const DuplicationUpload: React.FC = () => {
beforeUpload={handleUpload} beforeUpload={handleUpload}
onChange={({ fileList: newFileList }) => setFileList(newFileList)} onChange={({ fileList: newFileList }) => setFileList(newFileList)}
disabled={uploading} disabled={uploading}
style={{ padding: '20px 0' }} style={{ padding: "20px 0" }}
> >
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
{uploading ? ( {uploading ? (
<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} /> <LoadingOutlined style={{ fontSize: 48, color: "#1890ff" }} />
) : ( ) : (
<InboxOutlined style={{ fontSize: 48 }} /> <InboxOutlined style={{ fontSize: 48 }} />
)} )}
</p> </p>
<p className="ant-upload-text"> <p className="ant-upload-text">
{uploading ? '正在上传并查重...' : '点击或拖拽视频文件到此区域'} {uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}
</p> </p>
<p className="ant-upload-hint"> <p className="ant-upload-hint">
MP4AVIMOVMKV 2GB MP4AVIMOVMKV 2GB
@@ -126,12 +127,12 @@ const DuplicationUpload: React.FC = () => {
{/* 上传进度 */} {/* 上传进度 */}
{uploading && ( {uploading && (
<div style={{ marginTop: 24, textAlign: 'center' }}> <div style={{ marginTop: 24, textAlign: "center" }}>
<Progress <Progress
type="circle" type="circle"
percent={99} percent={99}
status="active" status="active"
format={() => '查重中...'} format={() => "查重中..."}
size={120} size={120}
/> />
<Paragraph type="secondary" style={{ marginTop: 16 }}> <Paragraph type="secondary" style={{ marginTop: 16 }}>
@@ -152,7 +153,7 @@ const DuplicationUpload: React.FC = () => {
type="primary" type="primary"
key="view" key="view"
icon={<CheckCircleOutlined />} icon={<CheckCircleOutlined />}
onClick={() => navigate('/duplication/results')} onClick={() => navigate("/duplication/results")}
> >
</Button>, </Button>,
@@ -42,7 +42,9 @@
.ep-tpl-card { .ep-tpl-card {
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s; transition:
border-color 0.2s,
box-shadow 0.2s;
} }
.ep-tpl-card-active { .ep-tpl-card-active {
@@ -103,7 +105,9 @@
} }
.ep-seg-card { .ep-seg-card {
transition: box-shadow 0.2s, border-color 0.2s; transition:
box-shadow 0.2s,
border-color 0.2s;
cursor: grab; cursor: grab;
} }
@@ -9,18 +9,18 @@
* P1-5: 分类 Input → Select(在 SaveModal 中实现) * P1-5: 分类 Input → Select(在 SaveModal 中实现)
* P1-6: SaveTemplatePayload 补充 estimated_duration * P1-6: SaveTemplatePayload 补充 estimated_duration
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import './EditingPlanner.css'; import "./EditingPlanner.css";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Space, message } from 'antd'; import { Button, Space, message } from "antd";
import { import {
SaveOutlined, SaveOutlined,
VideoCameraOutlined, VideoCameraOutlined,
AppstoreOutlined, AppstoreOutlined,
UserOutlined, UserOutlined,
DashboardOutlined, DashboardOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from "react-router-dom";
import { import {
getEditingTemplates, getEditingTemplates,
getTemplateCategories, getTemplateCategories,
@@ -35,45 +35,47 @@ import {
type SubtitleConfig, type SubtitleConfig,
type BgmConfig, type BgmConfig,
type SaveTemplatePayload, type SaveTemplatePayload,
} from '@/api/editingPlanner'; } from "@/api/editingPlanner";
/* ── 子组件 ── */ /* ── 子组件 ── */
import TemplatePanel from './components/TemplatePanel'; import TemplatePanel from "./components/TemplatePanel";
import TimelinePanel from './components/TimelinePanel'; import TimelinePanel from "./components/TimelinePanel";
import SettingsPanel from './components/SettingsPanel'; import SettingsPanel from "./components/SettingsPanel";
import SaveModal from './components/SaveModal'; import SaveModal from "./components/SaveModal";
import GenerateModal from './components/GenerateModal'; import GenerateModal from "./components/GenerateModal";
/* ──────────── 常量 ──────────── */ /* ──────────── 常量 ──────────── */
const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [ const MODES: { key: TemplateMode; icon: React.ReactNode; desc: string }[] = [
{ key: 'pip', icon: <AppstoreOutlined />, desc: '多画面叠加' }, { key: "pip", icon: <AppstoreOutlined />, desc: "多画面叠加" },
{ key: 'voice_over', icon: <UserOutlined />, desc: '人物讲解为主' }, { key: "voice_over", icon: <UserOutlined />, desc: "人物讲解为主" },
{ key: 'one_take', icon: <VideoCameraOutlined />, desc: '连续不中断' }, { key: "one_take", icon: <VideoCameraOutlined />, desc: "连续不中断" },
{ key: 'voice_pip', icon: <DashboardOutlined />, desc: '口播搭配混剪素材' }, { key: "voice_pip", icon: <DashboardOutlined />, desc: "口播搭配混剪素材" },
]; ];
const DEFAULT_TITLE: TitleConfig = { const DEFAULT_TITLE: TitleConfig = {
ai_auto_select: true, ai_auto_select: true,
content: '', content: "",
font_preset: '思源黑体', font_preset: "思源黑体",
font_color: '#ffffff', font_color: "#ffffff",
font_size: 32, font_size: 32,
position: 'top', position: "top",
}; };
const DEFAULT_SUBTITLE: SubtitleConfig = { const DEFAULT_SUBTITLE: SubtitleConfig = {
enabled: true, enabled: true,
position: 'bottom', position: "bottom",
font: '思源黑体', font: "思源黑体",
color: '#ffffff', color: "#ffffff",
size: 24, size: 24,
animation: 'fade', animation: "fade",
}; };
const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: '' }; const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: "" };
/** 计算预估时长 = Σ 片段时长范围中值 */ /** 计算预估时长 = Σ 片段时长范围中值 */
const calcEstimatedDuration = (segs: TemplateSegment[]) => const calcEstimatedDuration = (segs: TemplateSegment[]) =>
Math.round(segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)); Math.round(
segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0),
);
let _segId = 0; let _segId = 0;
const newSegId = () => `seg-new-${++_segId}`; const newSegId = () => `seg-new-${++_segId}`;
@@ -85,15 +87,15 @@ const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
/* ── P0-2: URL 参数 ── */ /* ── P0-2: URL 参数 ── */
const urlTemplateId = searchParams.get('template'); const urlTemplateId = searchParams.get("template");
const urlGenerate = searchParams.get('generate'); const urlGenerate = searchParams.get("generate");
/* ── 数据查询 ── */ /* ── 数据查询 ── */
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState(''); const [filterCategory, setFilterCategory] = useState("");
const { data: templates = [], isLoading: tplLoading } = useQuery({ const { data: templates = [], isLoading: tplLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText], queryKey: ["editing-templates", filterCategory, searchText],
queryFn: () => queryFn: () =>
getEditingTemplates({ getEditingTemplates({
category: filterCategory || undefined, category: filterCategory || undefined,
@@ -102,28 +104,40 @@ const EditingPlanner: React.FC = () => {
}); });
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['template-categories'], queryKey: ["template-categories"],
queryFn: getTemplateCategories, queryFn: getTemplateCategories,
}); });
/* ── 编辑器状态 ── */ /* ── 编辑器状态 ── */
const [currentMode, setCurrentMode] = useState<TemplateMode>('pip'); const [currentMode, setCurrentMode] = useState<TemplateMode>("pip");
const [segments, setSegments] = useState<TemplateSegment[]>([ const [segments, setSegments] = useState<TemplateSegment[]>([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, {
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
},
]); ]);
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null); const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(null);
const [titleConfig, setTitleConfig] = useState<TitleConfig>({ ...DEFAULT_TITLE }); const [titleConfig, setTitleConfig] = useState<TitleConfig>({
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({ ...DEFAULT_SUBTITLE }); ...DEFAULT_TITLE,
});
const [subtitleConfig, setSubtitleConfig] = useState<SubtitleConfig>({
...DEFAULT_SUBTITLE,
});
const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM }); const [bgmConfig, setBgmConfig] = useState<BgmConfig>({ ...DEFAULT_BGM });
/* ── UI 状态 ── */ /* ── UI 状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveModalOpen, setSaveModalOpen] = useState(false);
const [generateModalOpen, setGenerateModalOpen] = useState(false); const [generateModalOpen, setGenerateModalOpen] = useState(false);
const [draftName, setDraftName] = useState(''); const [draftName, setDraftName] = useState("");
const [draftCategory, setDraftCategory] = useState(''); const [draftCategory, setDraftCategory] = useState("");
const [draftTags, setDraftTags] = useState(''); const [draftTags, setDraftTags] = useState("");
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(null); const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
null,
);
const [dragIdx, setDragIdx] = useState<number | null>(null); const [dragIdx, setDragIdx] = useState<number | null>(null);
/* ── P0-2: 自动加载 URL 指定的模板 ── */ /* ── P0-2: 自动加载 URL 指定的模板 ── */
@@ -133,7 +147,7 @@ const EditingPlanner: React.FC = () => {
if (tpl) { if (tpl) {
loadTemplate(tpl); loadTemplate(tpl);
// 如果 URL 有 generate=1,自动打开发成弹窗 // 如果 URL 有 generate=1,自动打开发成弹窗
if (urlGenerate === '1') { if (urlGenerate === "1") {
setGenerateModalOpen(true); setGenerateModalOpen(true);
} }
} }
@@ -144,37 +158,49 @@ const EditingPlanner: React.FC = () => {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createEditingTemplate, mutationFn: createEditingTemplate,
onSuccess: () => { onSuccess: () => {
message.success('模板已保存'); message.success("模板已保存");
queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
setSaveModalOpen(false); setSaveModalOpen(false);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('保存失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("保存失败");
}, },
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: SaveTemplatePayload }) => updateEditingTemplate(id, data), mutationFn: ({ id, data }: { id: string; data: SaveTemplatePayload }) =>
updateEditingTemplate(id, data),
onSuccess: () => { onSuccess: () => {
message.success('模板已更新'); message.success("模板已更新");
queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
setSaveModalOpen(false); setSaveModalOpen(false);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('保存失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("保存失败");
}, },
}); });
const generateMutation = useMutation({ const generateMutation = useMutation({
mutationFn: ({ templateId, duration }: { templateId: string; duration: number }) => mutationFn: ({
generateFromTemplate(templateId, { voiceover_duration: duration }), templateId,
duration,
}: {
templateId: string;
duration: number;
}) => generateFromTemplate(templateId, { voiceover_duration: duration }),
onSuccess: (data) => { onSuccess: (data) => {
const msg = data.warnings && data.warnings.length > 0 ? `生成任务已提交(${data.warnings.map(w => w.message).join('; ')}` : '生成任务已提交'; const msg =
data.warnings && data.warnings.length > 0
? `生成任务已提交(${data.warnings.map((w) => w.message).join("; ")}`
: "生成任务已提交";
message.success(msg); message.success(msg);
setGenerateModalOpen(false); setGenerateModalOpen(false);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('生成失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("生成失败");
}, },
}); });
@@ -183,7 +209,7 @@ const EditingPlanner: React.FC = () => {
/* ──────────── 片段操作 ──────────── */ /* ──────────── 片段操作 ──────────── */
const addSegment = () => { const addSegment = () => {
if (currentMode === 'one_take') return; if (currentMode === "one_take") return;
setSegments((prev) => [ setSegments((prev) => [
...prev, ...prev,
{ {
@@ -191,20 +217,24 @@ const EditingPlanner: React.FC = () => {
segment_order: prev.length + 1, segment_order: prev.length + 1,
duration_min: 5, duration_min: 5,
duration_max: 15, duration_max: 15,
material_type: currentMode === 'voice_pip' ? '人物' : null, material_type: currentMode === "voice_pip" ? "人物" : null,
}, },
]); ]);
}; };
const removeSegment = (id: string) => { const removeSegment = (id: string) => {
if (currentMode === 'one_take') return; if (currentMode === "one_take") return;
setSegments((prev) => setSegments((prev) =>
prev.filter((s) => s.id !== id).map((s, i) => ({ ...s, segment_order: i + 1 })), prev
.filter((s) => s.id !== id)
.map((s, i) => ({ ...s, segment_order: i + 1 })),
); );
}; };
const updateSegment = (id: string, patch: Partial<TemplateSegment>) => { const updateSegment = (id: string, patch: Partial<TemplateSegment>) => {
setSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))); setSegments((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
);
}; };
const handleDragStart = (idx: number) => setDragIdx(idx); const handleDragStart = (idx: number) => setDragIdx(idx);
@@ -227,7 +257,7 @@ const EditingPlanner: React.FC = () => {
const handleModeChange = (mode: TemplateMode) => { const handleModeChange = (mode: TemplateMode) => {
setCurrentMode(mode); setCurrentMode(mode);
if (mode === 'one_take') { if (mode === "one_take") {
// 锁定为 1 个片段 // 锁定为 1 个片段
setSegments([ setSegments([
{ {
@@ -238,12 +268,12 @@ const EditingPlanner: React.FC = () => {
material_type: null, material_type: null,
}, },
]); ]);
} else if (mode === 'voice_pip') { } else if (mode === "voice_pip") {
// 确保每个片段有 material_type // 确保每个片段有 material_type
setSegments((prev) => setSegments((prev) =>
prev.map((s) => ({ prev.map((s) => ({
...s, ...s,
material_type: s.material_type || '人物', material_type: s.material_type || "人物",
})), })),
); );
} }
@@ -262,9 +292,15 @@ const EditingPlanner: React.FC = () => {
const resetEditor = () => { const resetEditor = () => {
setLoadedTemplateId(null); setLoadedTemplateId(null);
setCurrentMode('pip'); setCurrentMode("pip");
setSegments([ setSegments([
{ id: newSegId(), segment_order: 1, duration_min: 5, duration_max: 15, material_type: null }, {
id: newSegId(),
segment_order: 1,
duration_min: 5,
duration_max: 15,
material_type: null,
},
]); ]);
setTitleConfig({ ...DEFAULT_TITLE }); setTitleConfig({ ...DEFAULT_TITLE });
setSubtitleConfig({ ...DEFAULT_SUBTITLE }); setSubtitleConfig({ ...DEFAULT_SUBTITLE });
@@ -273,22 +309,31 @@ const EditingPlanner: React.FC = () => {
const openSaveModal = () => { const openSaveModal = () => {
if (segments.length === 0) { if (segments.length === 0) {
message.warning('请至少添加一个片段'); message.warning("请至少添加一个片段");
return; return;
} }
setDraftName(loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.name || '' : ''); setDraftName(
loadedTemplateId
? templates.find((t) => t.id === loadedTemplateId)?.name || ""
: "",
);
setDraftCategory( setDraftCategory(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.category || '' : '', loadedTemplateId
? templates.find((t) => t.id === loadedTemplateId)?.category || ""
: "",
); );
setDraftTags( setDraftTags(
loadedTemplateId ? templates.find((t) => t.id === loadedTemplateId)?.tags.join(', ') || '' : '', loadedTemplateId
? templates.find((t) => t.id === loadedTemplateId)?.tags.join(", ") ||
""
: "",
); );
setSaveModalOpen(true); setSaveModalOpen(true);
}; };
const handleSave = () => { const handleSave = () => {
if (!draftName.trim()) { if (!draftName.trim()) {
message.warning('请输入模板名称'); message.warning("请输入模板名称");
return; return;
} }
const estimatedDuration = calcEstimatedDuration(segments); const estimatedDuration = calcEstimatedDuration(segments);
@@ -316,7 +361,7 @@ const EditingPlanner: React.FC = () => {
const handleGenerate = () => { const handleGenerate = () => {
if (!loadedTemplateId) { if (!loadedTemplateId) {
message.warning('请先保存模板'); message.warning("请先保存模板");
return; return;
} }
setGenerateModalOpen(true); setGenerateModalOpen(true);
@@ -324,10 +369,13 @@ const EditingPlanner: React.FC = () => {
const doGenerate = () => { const doGenerate = () => {
if (!voiceoverDuration || voiceoverDuration <= 0) { if (!voiceoverDuration || voiceoverDuration <= 0) {
message.warning('请输入配音时长'); message.warning("请输入配音时长");
return; return;
} }
generateMutation.mutate({ templateId: loadedTemplateId!, duration: voiceoverDuration }); generateMutation.mutate({
templateId: loadedTemplateId!,
duration: voiceoverDuration,
});
}; };
const estimatedDuration = calcEstimatedDuration(segments); const estimatedDuration = calcEstimatedDuration(segments);
@@ -342,7 +390,7 @@ const EditingPlanner: React.FC = () => {
{MODES.map((m) => ( {MODES.map((m) => (
<Button <Button
key={m.key} key={m.key}
type={currentMode === m.key ? 'primary' : 'default'} type={currentMode === m.key ? "primary" : "default"}
icon={m.icon} icon={m.icon}
onClick={() => handleModeChange(m.key)} onClick={() => handleModeChange(m.key)}
> >
@@ -2,8 +2,8 @@
* 使用模板生成视频弹窗 * 使用模板生成视频弹窗
* P1-4: voiceover_id → voiceover_duration (number) * P1-4: voiceover_id → voiceover_duration (number)
*/ */
import React from 'react'; import React from "react";
import { Modal, InputNumber, Space, Typography } from 'antd'; import { Modal, InputNumber, Space, Typography } from "antd";
const { Text } = Typography; const { Text } = Typography;
@@ -35,7 +35,7 @@ const GenerateModal: React.FC<GenerateModalProps> = ({
confirmLoading={loading} confirmLoading={loading}
okText="开始生成" okText="开始生成"
> >
<Space direction="vertical" style={{ width: '100%' }} size={12}> <Space direction="vertical" style={{ width: "100%" }} size={12}>
<div> <div>
<Text style={{ fontSize: 13 }}>*</Text> <Text style={{ fontSize: 13 }}>*</Text>
<InputNumber <InputNumber
@@ -44,7 +44,7 @@ const GenerateModal: React.FC<GenerateModalProps> = ({
onChange={onDurationChange} onChange={onDurationChange}
min={1} min={1}
max={600} max={600}
style={{ width: '100%' }} style={{ width: "100%" }}
/> />
</div> </div>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
@@ -2,9 +2,9 @@
* 保存/更新模板弹窗 * 保存/更新模板弹窗
* 分类使用 Select 关联后端分类 APIP1-5 * 分类使用 Select 关联后端分类 APIP1-5
*/ */
import React from 'react'; import React from "react";
import { Modal, Input, Select, Space, Typography } from 'antd'; import { Modal, Input, Select, Space, Typography } from "antd";
import type { TemplateCategory } from '@/api/editingPlanner'; import type { TemplateCategory } from "@/api/editingPlanner";
const { Text } = Typography; const { Text } = Typography;
@@ -41,14 +41,14 @@ const SaveModal: React.FC<SaveModalProps> = ({
}) => { }) => {
return ( return (
<Modal <Modal
title={isUpdate ? '更新模板' : '保存模板'} title={isUpdate ? "更新模板" : "保存模板"}
open={open} open={open}
onCancel={onCancel} onCancel={onCancel}
onOk={onSave} onOk={onSave}
confirmLoading={loading} confirmLoading={loading}
okText="保存" okText="保存"
> >
<Space direction="vertical" style={{ width: '100%' }} size={12}> <Space direction="vertical" style={{ width: "100%" }} size={12}>
<div> <div>
<Text style={{ fontSize: 13 }}> *</Text> <Text style={{ fontSize: 13 }}> *</Text>
<Input <Input
@@ -62,10 +62,10 @@ const SaveModal: React.FC<SaveModalProps> = ({
<Select <Select
placeholder="选择分类" placeholder="选择分类"
value={draftCategory || undefined} value={draftCategory || undefined}
onChange={(v) => onCategoryChange(v || '')} onChange={(v) => onCategoryChange(v || "")}
allowClear allowClear
showSearch showSearch
style={{ width: '100%' }} style={{ width: "100%" }}
options={categories.map((c) => ({ value: c.name, label: c.name }))} options={categories.map((c) => ({ value: c.name, label: c.name }))}
/> />
</div> </div>
@@ -2,26 +2,30 @@
* 右侧设置面板 * 右侧设置面板
* 标题设置 / 字幕设置 / BGM 设置 * 标题设置 / 字幕设置 / BGM 设置
*/ */
import React from 'react'; import React from "react";
import { Typography, Input, Switch, Select, Slider, Tag } from 'antd'; import { Typography, Input, Switch, Select, Slider, Tag } from "antd";
import { SoundOutlined, FontSizeOutlined } from '@ant-design/icons'; import { SoundOutlined, FontSizeOutlined } from "@ant-design/icons";
import type { TitleConfig, SubtitleConfig, BgmConfig } from '@/api/editingPlanner'; import type {
TitleConfig,
SubtitleConfig,
BgmConfig,
} from "@/api/editingPlanner";
const { Text } = Typography; const { Text } = Typography;
/* ── 常量 ── */ /* ── 常量 ── */
const FONT_PRESETS = ['思源黑体', '站酷快乐体', '方正兰亭', '汉仪旗黑']; const FONT_PRESETS = ["思源黑体", "站酷快乐体", "方正兰亭", "汉仪旗黑"];
const POSITIONS = [ const POSITIONS = [
{ value: 'top', label: '顶部' }, { value: "top", label: "顶部" },
{ value: 'center', label: '居中' }, { value: "center", label: "居中" },
{ value: 'bottom', label: '底部' }, { value: "bottom", label: "底部" },
]; ];
const SUBTITLE_FONTS = ['思源黑体', '微软雅黑', '苹方']; const SUBTITLE_FONTS = ["思源黑体", "微软雅黑", "苹方"];
const SUBTITLE_ANIMATIONS = [ const SUBTITLE_ANIMATIONS = [
{ value: 'none', label: '无' }, { value: "none", label: "无" },
{ value: 'fade', label: '淡入' }, { value: "fade", label: "淡入" },
{ value: 'typewriter', label: '打字机' }, { value: "typewriter", label: "打字机" },
{ value: 'slide', label: '滑动' }, { value: "slide", label: "滑动" },
]; ];
interface SettingsPanelProps { interface SettingsPanelProps {
@@ -45,17 +49,26 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<div className="ep-right"> <div className="ep-right">
{/* 标题设置 */} {/* 标题设置 */}
<div className="ep-settings-group"> <div className="ep-settings-group">
<Text strong style={{ display: 'block', marginBottom: 12 }}> <Text strong style={{ display: "block", marginBottom: 12 }}>
<FontSizeOutlined style={{ marginRight: 6 }} /> <FontSizeOutlined style={{ marginRight: 6 }} />
</Text> </Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text style={{ fontSize: 13 }}>AI </Text> <Text style={{ fontSize: 13 }}>AI </Text>
<Switch <Switch
size="small" size="small"
checked={titleConfig.ai_auto_select} checked={titleConfig.ai_auto_select}
onChange={(checked) => onTitleChange({ ...titleConfig, ai_auto_select: checked })} onChange={(checked) =>
onTitleChange({ ...titleConfig, ai_auto_select: checked })
}
checkedChildren="ON" checkedChildren="ON"
unCheckedChildren="OFF" unCheckedChildren="OFF"
/> />
@@ -65,7 +78,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Input.TextArea <Input.TextArea
placeholder="手动输入标题内容" placeholder="手动输入标题内容"
value={titleConfig.content} value={titleConfig.content}
onChange={(e) => onTitleChange({ ...titleConfig, content: e.target.value })} onChange={(e) =>
onTitleChange({ ...titleConfig, content: e.target.value })
}
rows={2} rows={2}
size="small" size="small"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
@@ -74,13 +89,17 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}></Text> <Text style={{ fontSize: 12 }}></Text>
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}> <div
style={{ display: "flex", gap: 4, marginTop: 4, flexWrap: "wrap" }}
>
{FONT_PRESETS.map((font) => ( {FONT_PRESETS.map((font) => (
<Tag <Tag
key={font} key={font}
color={titleConfig.font_preset === font ? 'blue' : 'default'} color={titleConfig.font_preset === font ? "blue" : "default"}
style={{ cursor: 'pointer' }} style={{ cursor: "pointer" }}
onClick={() => onTitleChange({ ...titleConfig, font_preset: font })} onClick={() =>
onTitleChange({ ...titleConfig, font_preset: font })
}
> >
{font} {font}
</Tag> </Tag>
@@ -88,13 +107,15 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text> <Text style={{ fontSize: 12 }}></Text>
<Input <Input
size="small" size="small"
value={titleConfig.font_color} value={titleConfig.font_color}
onChange={(e) => onTitleChange({ ...titleConfig, font_color: e.target.value })} onChange={(e) =>
onTitleChange({ ...titleConfig, font_color: e.target.value })
}
style={{ marginTop: 4 }} style={{ marginTop: 4 }}
/> />
</div> </div>
@@ -105,7 +126,7 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
value={titleConfig.position} value={titleConfig.position}
onChange={(v) => onTitleChange({ ...titleConfig, position: v })} onChange={(v) => onTitleChange({ ...titleConfig, position: v })}
options={POSITIONS} options={POSITIONS}
style={{ width: '100%', marginTop: 4 }} style={{ width: "100%", marginTop: 4 }}
/> />
</div> </div>
</div> </div>
@@ -123,7 +144,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
{/* 字幕设置 */} {/* 字幕设置 */}
<div className="ep-settings-group"> <div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong> <Text strong>
<FontSizeOutlined style={{ marginRight: 6 }} /> <FontSizeOutlined style={{ marginRight: 6 }} />
@@ -131,7 +159,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Switch <Switch
size="small" size="small"
checked={subtitleConfig.enabled} checked={subtitleConfig.enabled}
onChange={(checked) => onSubtitleChange({ ...subtitleConfig, enabled: checked })} onChange={(checked) =>
onSubtitleChange({ ...subtitleConfig, enabled: checked })
}
/> />
</div> </div>
@@ -142,9 +172,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Select <Select
size="small" size="small"
value={subtitleConfig.position} value={subtitleConfig.position}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, position: v })} onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, position: v })
}
options={POSITIONS} options={POSITIONS}
style={{ width: '100%', marginTop: 4 }} style={{ width: "100%", marginTop: 4 }}
/> />
</div> </div>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
@@ -152,18 +184,25 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Select <Select
size="small" size="small"
value={subtitleConfig.font} value={subtitleConfig.font}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, font: v })} onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, font: v })
}
options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))} options={SUBTITLE_FONTS.map((f) => ({ value: f, label: f }))}
style={{ width: '100%', marginTop: 4 }} style={{ width: "100%", marginTop: 4 }}
/> />
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text style={{ fontSize: 12 }}></Text> <Text style={{ fontSize: 12 }}></Text>
<Input <Input
size="small" size="small"
value={subtitleConfig.color} value={subtitleConfig.color}
onChange={(e) => onSubtitleChange({ ...subtitleConfig, color: e.target.value })} onChange={(e) =>
onSubtitleChange({
...subtitleConfig,
color: e.target.value,
})
}
style={{ marginTop: 4 }} style={{ marginTop: 4 }}
/> />
</div> </div>
@@ -172,9 +211,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Select <Select
size="small" size="small"
value={subtitleConfig.animation} value={subtitleConfig.animation}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, animation: v })} onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, animation: v })
}
options={SUBTITLE_ANIMATIONS} options={SUBTITLE_ANIMATIONS}
style={{ width: '100%', marginTop: 4 }} style={{ width: "100%", marginTop: 4 }}
/> />
</div> </div>
</div> </div>
@@ -184,7 +225,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
min={12} min={12}
max={48} max={48}
value={subtitleConfig.size} value={subtitleConfig.size}
onChange={(v) => onSubtitleChange({ ...subtitleConfig, size: v })} onChange={(v) =>
onSubtitleChange({ ...subtitleConfig, size: v })
}
/> />
</div> </div>
</> </>
@@ -193,7 +236,14 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
{/* BGM 设置 */} {/* BGM 设置 */}
<div className="ep-settings-group"> <div className="ep-settings-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong> <Text strong>
<SoundOutlined style={{ marginRight: 6 }} /> <SoundOutlined style={{ marginRight: 6 }} />
BGM BGM
@@ -201,7 +251,9 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
<Switch <Switch
size="small" size="small"
checked={bgmConfig.enabled} checked={bgmConfig.enabled}
onChange={(checked) => onBgmChange({ ...bgmConfig, enabled: checked })} onChange={(checked) =>
onBgmChange({ ...bgmConfig, enabled: checked })
}
/> />
</div> </div>
{bgmConfig.enabled && ( {bgmConfig.enabled && (
@@ -212,11 +264,11 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({
placeholder="选择背景音乐" placeholder="选择背景音乐"
value={bgmConfig.music_id || undefined} value={bgmConfig.music_id || undefined}
onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })} onChange={(v) => onBgmChange({ ...bgmConfig, music_id: v })}
style={{ width: '100%', marginTop: 4 }} style={{ width: "100%", marginTop: 4 }}
options={[ options={[
{ value: 'bgm-1', label: '轻快节奏' }, { value: "bgm-1", label: "轻快节奏" },
{ value: 'bgm-2', label: '舒缓氛围' }, { value: "bgm-2", label: "舒缓氛围" },
{ value: 'bgm-3', label: '动感活力' }, { value: "bgm-3", label: "动感活力" },
]} ]}
/> />
</div> </div>
@@ -2,18 +2,25 @@
* 左侧模板面板 * 左侧模板面板
* 搜索、分类筛选、模板卡片列表 * 搜索、分类筛选、模板卡片列表
*/ */
import React from 'react'; import React from "react";
import { Input, Select, Card, Tag, Empty, Spin, Button, Typography } from 'antd';
import { import {
SearchOutlined, Input,
} from '@ant-design/icons'; Select,
Card,
Tag,
Empty,
Spin,
Button,
Typography,
} from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { import {
MODE_LABELS, MODE_LABELS,
MODE_COLORS, MODE_COLORS,
type EditingTemplate, type EditingTemplate,
type TemplateCategory, type TemplateCategory,
type TemplateMode, type TemplateMode,
} from '@/api/editingPlanner'; } from "@/api/editingPlanner";
const { Text } = Typography; const { Text } = Typography;
@@ -44,8 +51,11 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
}) => { }) => {
return ( return (
<div className="ep-left"> <div className="ep-left">
<div style={{ padding: '0 12px', marginBottom: 12 }}> <div style={{ padding: "0 12px", marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 8 }}> <Text
strong
style={{ fontSize: 14, display: "block", marginBottom: 8 }}
>
</Text> </Text>
<Input <Input
@@ -60,17 +70,17 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
<Select <Select
placeholder="按分类筛选" placeholder="按分类筛选"
value={filterCategory || undefined} value={filterCategory || undefined}
onChange={(v) => onCategoryChange(v || '')} onChange={(v) => onCategoryChange(v || "")}
allowClear allowClear
size="small" size="small"
style={{ width: '100%' }} style={{ width: "100%" }}
options={categories.map((c) => ({ value: c.name, label: c.name }))} options={categories.map((c) => ({ value: c.name, label: c.name }))}
/> />
</div> </div>
<div style={{ padding: '0 12px', flex: 1, overflowY: 'auto' }}> <div style={{ padding: "0 12px", flex: 1, overflowY: "auto" }}>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}> <div style={{ textAlign: "center", padding: 40 }}>
<Spin /> <Spin />
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
@@ -85,15 +95,24 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
key={tpl.id} key={tpl.id}
size="small" size="small"
hoverable hoverable
className={`ep-tpl-card ${loadedTemplateId === tpl.id ? 'ep-tpl-card-active' : ''}`} className={`ep-tpl-card ${loadedTemplateId === tpl.id ? "ep-tpl-card-active" : ""}`}
onClick={() => onTemplateSelect(tpl)} onClick={() => onTemplateSelect(tpl)}
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text strong ellipsis style={{ maxWidth: 140 }}> <Text strong ellipsis style={{ maxWidth: 140 }}>
{tpl.name} {tpl.name}
</Text> </Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'blue'} style={{ marginRight: 0 }}> <Tag
color={MODE_COLORS[tpl.mode as TemplateMode] || "blue"}
style={{ marginRight: 0 }}
>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag> </Tag>
</div> </div>
@@ -117,7 +136,7 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
</div> </div>
{loadedTemplateId && ( {loadedTemplateId && (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}> <div style={{ padding: 12, borderTop: "1px solid #f0f0f0" }}>
<Button size="small" block onClick={onNewTemplate}> <Button size="small" block onClick={onNewTemplate}>
</Button> </Button>
@@ -2,16 +2,16 @@
* 中间预览 + 时间线面板 * 中间预览 + 时间线面板
* 视频/封面预览区 + 片段卡片时间线 * 视频/封面预览区 + 片段卡片时间线
*/ */
import React from 'react'; import React from "react";
import { Card, Button, Tag, Typography, Select, Slider } from 'antd'; import { Card, Button, Tag, Typography, Select, Slider } from "antd";
import { import {
PlusOutlined, PlusOutlined,
DeleteOutlined, DeleteOutlined,
DragOutlined, DragOutlined,
VideoCameraOutlined, VideoCameraOutlined,
PictureOutlined, PictureOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import type { TemplateSegment, TemplateMode } from '@/api/editingPlanner'; import type { TemplateSegment, TemplateMode } from "@/api/editingPlanner";
const { Text } = Typography; const { Text } = Typography;
@@ -38,8 +38,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onDragOver, onDragOver,
onDragEnd, onDragEnd,
}) => { }) => {
const isOneShot = currentMode === 'one_take'; const isOneShot = currentMode === "one_take";
const isMixedCut = currentMode === 'voice_pip'; const isMixedCut = currentMode === "voice_pip";
const handleDragOver = (e: React.DragEvent, idx: number) => { const handleDragOver = (e: React.DragEvent, idx: number) => {
onDragOver(e, idx); onDragOver(e, idx);
@@ -52,7 +52,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 视频预览 */} {/* 视频预览 */}
<div className="ep-preview-box"> <div className="ep-preview-box">
<div className="ep-preview-frame"> <div className="ep-preview-frame">
<VideoCameraOutlined style={{ fontSize: 40, color: '#bbb' }} /> <VideoCameraOutlined style={{ fontSize: 40, color: "#bbb" }} />
<Text type="secondary" style={{ marginTop: 8 }}> <Text type="secondary" style={{ marginTop: 8 }}>
</Text> </Text>
@@ -63,10 +63,10 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div> </div>
{/* 封面预览 + 方案按钮 */} {/* 封面预览 + 方案按钮 */}
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}> <div style={{ display: "flex", gap: 12, flex: "0 0 auto" }}>
<div className="ep-preview-box"> <div className="ep-preview-box">
<div className="ep-preview-frame"> <div className="ep-preview-frame">
<PictureOutlined style={{ fontSize: 40, color: '#bbb' }} /> <PictureOutlined style={{ fontSize: 40, color: "#bbb" }} />
<Text type="secondary" style={{ marginTop: 8 }}> <Text type="secondary" style={{ marginTop: 8 }}>
</Text> </Text>
@@ -94,10 +94,20 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间线 */} {/* 时间线 */}
<div className="ep-timeline"> <div className="ep-timeline">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<Text strong> <Text strong>
线{' '} 线{" "}
<Text type="secondary" style={{ fontWeight: 'normal', fontSize: 12 }}> <Text
type="secondary"
style={{ fontWeight: "normal", fontSize: 12 }}
>
~{estimatedDuration}s ~{estimatedDuration}s
</Text> </Text>
</Text> </Text>
@@ -112,7 +122,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</Button> </Button>
</div> </div>
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', paddingBottom: 8 }}> <div
style={{
display: "flex",
gap: 12,
overflowX: "auto",
paddingBottom: 8,
}}
>
{segments.map((seg, idx) => ( {segments.map((seg, idx) => (
<Card <Card
key={seg.id} key={seg.id}
@@ -124,9 +141,19 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }} style={{ minWidth: 180, maxWidth: 220, flexShrink: 0 }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<span <span
style={{ cursor: isOneShot ? 'default' : 'grab', color: '#999' }} style={{
cursor: isOneShot ? "default" : "grab",
color: "#999",
}}
> >
<DragOutlined /> <DragOutlined />
</span> </span>
@@ -138,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => onRemoveSegment(seg.id!)} onClick={() => onRemoveSegment(seg.id!)}
disabled={isOneShot} disabled={isOneShot}
style={{ marginLeft: 'auto' }} style={{ marginLeft: "auto" }}
/> />
</div> </div>
@@ -154,7 +181,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
min={1} min={1}
max={seg.duration_max} max={seg.duration_max}
value={seg.duration_min} value={seg.duration_min}
onChange={(v) => onUpdateSegment(seg.id!, { duration_min: v })} onChange={(v) =>
onUpdateSegment(seg.id!, { duration_min: v })
}
/> />
</div> </div>
<div> <div>
@@ -163,7 +192,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
min={seg.duration_min} min={seg.duration_min}
max={60} max={60}
value={seg.duration_max} value={seg.duration_max}
onChange={(v) => onUpdateSegment(seg.id!, { duration_max: v })} onChange={(v) =>
onUpdateSegment(seg.id!, { duration_max: v })
}
/> />
</div> </div>
</> </>
@@ -174,12 +205,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<Text style={{ fontSize: 12 }}></Text> <Text style={{ fontSize: 12 }}></Text>
<Select <Select
size="small" size="small"
value={seg.material_type || '人物'} value={seg.material_type || "人物"}
onChange={(v) => onUpdateSegment(seg.id!, { material_type: v })} onChange={(v) =>
style={{ width: '100%', marginTop: 4 }} onUpdateSegment(seg.id!, { material_type: v })
}
style={{ width: "100%", marginTop: 4 }}
options={[ options={[
{ value: '人物', label: '人物' }, { value: "人物", label: "人物" },
{ value: '场景', label: '场景' }, { value: "场景", label: "场景" },
]} ]}
/> />
</div> </div>
+73 -50
View File
@@ -2,9 +2,9 @@
* 一键生成页面 * 一键生成页面
* 流程:选择模板 → 选择素材 → 选择标题 → 选择配音 → 批量生成 * 流程:选择模板 → 选择素材 → 选择标题 → 选择配音 → 批量生成
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from "@tanstack/react-query";
import { import {
Card, Card,
Button, Button,
@@ -19,26 +19,26 @@ import {
Col, Col,
Tag, Tag,
Alert, Alert,
} from 'antd'; } from "antd";
import { import {
CheckCircleOutlined, CheckCircleOutlined,
VideoCameraOutlined, VideoCameraOutlined,
FileTextOutlined, FileTextOutlined,
AudioOutlined, AudioOutlined,
PictureOutlined, PictureOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { getTemplates } from '@/api/templates'; import { getTemplates } from "@/api/templates";
import { getAssetLibraries, getAssets, type AssetItem } from '@/api/assets'; import { getAssetLibraries, getAssets, type AssetItem } from "@/api/assets";
import { getTitles } from '@/api/titles'; import { getTitles } from "@/api/titles";
import { getVoices } from '@/api/voices'; import { getVoices } from "@/api/voices";
import { createGenerationTask } from '@/api/tasks'; import { createGenerationTask } from "@/api/tasks";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const GeneratePage: React.FC = () => { const GeneratePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [selectedTemplate, setSelectedTemplate] = useState<string>(''); const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const [selectedAssets, setSelectedAssets] = useState<string[]>([]); const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
const [selectedTitles, setSelectedTitles] = useState<string[]>([]); const [selectedTitles, setSelectedTitles] = useState<string[]>([]);
const [selectedVoices, setSelectedVoices] = useState<string[]>([]); const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
@@ -46,32 +46,52 @@ const GeneratePage: React.FC = () => {
const [generated, setGenerated] = useState(false); const [generated, setGenerated] = useState(false);
// 获取模板列表 // 获取模板列表
const { data: templates = [], isLoading: tplLoading, isError: tplError } = useQuery({ const {
queryKey: ['templates'], data: templates = [],
isLoading: tplLoading,
isError: tplError,
} = useQuery({
queryKey: ["templates"],
queryFn: getTemplates, queryFn: getTemplates,
}); });
// 获取素材库和素材 // 获取素材库和素材
const { data: libraries = [], isLoading: libLoading, isError: libError } = useQuery({ const {
queryKey: ['asset-libraries'], data: libraries = [],
isLoading: libLoading,
isError: libError,
} = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries, queryFn: getAssetLibraries,
}); });
// 获取标题 // 获取标题
const { data: titles = [], isLoading: titleLoading, isError: titleError } = useQuery({ const {
queryKey: ['titles'], data: titles = [],
isLoading: titleLoading,
isError: titleError,
} = useQuery({
queryKey: ["titles"],
queryFn: getTitles, queryFn: getTitles,
}); });
// 获取配音 // 获取配音
const { data: voices = [], isLoading: voiceLoading, isError: voiceError } = useQuery({ const {
queryKey: ['voices'], data: voices = [],
isLoading: voiceLoading,
isError: voiceError,
} = useQuery({
queryKey: ["voices"],
queryFn: getVoices, queryFn: getVoices,
}); });
// 获取所有素材(跨库) // 获取所有素材(跨库)
const { data: allAssets = [], isLoading: assetsLoading, isError: assetsError } = useQuery({ const {
queryKey: ['all-assets'], data: allAssets = [],
isLoading: assetsLoading,
isError: assetsError,
} = useQuery({
queryKey: ["all-assets"],
queryFn: async () => { queryFn: async () => {
const all: AssetItem[] = []; const all: AssetItem[] = [];
for (const lib of libraries) { for (const lib of libraries) {
@@ -83,19 +103,22 @@ const GeneratePage: React.FC = () => {
enabled: libraries.length > 0, enabled: libraries.length > 0,
}); });
const pageLoading = tplLoading || libLoading || titleLoading || voiceLoading || assetsLoading; const pageLoading =
const pageError = tplError || libError || titleError || voiceError || assetsError; tplLoading || libLoading || titleLoading || voiceLoading || assetsLoading;
const pageError =
tplError || libError || titleError || voiceError || assetsError;
// 创建生成任务 // 创建生成任务
const generateMutation = useMutation({ const generateMutation = useMutation({
mutationFn: createGenerationTask, mutationFn: createGenerationTask,
onSuccess: () => { onSuccess: () => {
message.success('生成任务已提交'); message.success("生成任务已提交");
setGenerated(true); setGenerated(true);
setGenerating(false); setGenerating(false);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('生成失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("生成失败");
setGenerating(false); setGenerating(false);
}, },
}); });
@@ -103,7 +126,7 @@ const GeneratePage: React.FC = () => {
/** 开始生成 */ /** 开始生成 */
const handleGenerate = async () => { const handleGenerate = async () => {
if (!selectedTemplate) { if (!selectedTemplate) {
message.warning('请选择模板'); message.warning("请选择模板");
return; return;
} }
setGenerating(true); setGenerating(true);
@@ -117,7 +140,7 @@ const GeneratePage: React.FC = () => {
const steps = [ const steps = [
{ {
title: '选择模板', title: "选择模板",
icon: <VideoCameraOutlined />, icon: <VideoCameraOutlined />,
content: ( content: (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -135,7 +158,7 @@ const GeneratePage: React.FC = () => {
style={{ style={{
border: border:
selectedTemplate === t.id selectedTemplate === t.id
? '2px solid #1890ff' ? "2px solid #1890ff"
: undefined, : undefined,
}} }}
> >
@@ -144,9 +167,7 @@ const GeneratePage: React.FC = () => {
description={ description={
<Space> <Space>
{t.category && <Tag>{t.category}</Tag>} {t.category && <Tag>{t.category}</Tag>}
{t.target_duration && ( {t.target_duration && <Tag>{t.target_duration}s</Tag>}
<Tag>{t.target_duration}s</Tag>
)}
</Space> </Space>
} }
/> />
@@ -158,7 +179,7 @@ const GeneratePage: React.FC = () => {
), ),
}, },
{ {
title: '选择素材', title: "选择素材",
icon: <PictureOutlined />, icon: <PictureOutlined />,
content: ( content: (
<div> <div>
@@ -168,12 +189,12 @@ const GeneratePage: React.FC = () => {
<Checkbox.Group <Checkbox.Group
value={selectedAssets} value={selectedAssets}
onChange={(vals) => setSelectedAssets(vals as string[])} onChange={(vals) => setSelectedAssets(vals as string[])}
style={{ width: '100%' }} style={{ width: "100%" }}
> >
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
{allAssets.map((a) => ( {allAssets.map((a) => (
<Col xs={24} sm={12} md={8} key={a.id}> <Col xs={24} sm={12} md={8} key={a.id}>
<Checkbox value={a.id} style={{ width: '100%' }}> <Checkbox value={a.id} style={{ width: "100%" }}>
{a.name} {a.name}
</Checkbox> </Checkbox>
</Col> </Col>
@@ -185,7 +206,7 @@ const GeneratePage: React.FC = () => {
), ),
}, },
{ {
title: '选择标题', title: "选择标题",
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
content: ( content: (
<div> <div>
@@ -195,12 +216,12 @@ const GeneratePage: React.FC = () => {
<Checkbox.Group <Checkbox.Group
value={selectedTitles} value={selectedTitles}
onChange={(vals) => setSelectedTitles(vals as string[])} onChange={(vals) => setSelectedTitles(vals as string[])}
style={{ width: '100%' }} style={{ width: "100%" }}
> >
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
{titles.map((t) => ( {titles.map((t) => (
<Col xs={24} sm={12} md={8} key={t.id}> <Col xs={24} sm={12} md={8} key={t.id}>
<Checkbox value={t.id} style={{ width: '100%' }}> <Checkbox value={t.id} style={{ width: "100%" }}>
<Text ellipsis>{t.content}</Text> <Text ellipsis>{t.content}</Text>
</Checkbox> </Checkbox>
</Col> </Col>
@@ -212,7 +233,7 @@ const GeneratePage: React.FC = () => {
), ),
}, },
{ {
title: '选择配音', title: "选择配音",
icon: <AudioOutlined />, icon: <AudioOutlined />,
content: ( content: (
<div> <div>
@@ -222,12 +243,12 @@ const GeneratePage: React.FC = () => {
<Checkbox.Group <Checkbox.Group
value={selectedVoices} value={selectedVoices}
onChange={(vals) => setSelectedVoices(vals as string[])} onChange={(vals) => setSelectedVoices(vals as string[])}
style={{ width: '100%' }} style={{ width: "100%" }}
> >
<Row gutter={[12, 12]}> <Row gutter={[12, 12]}>
{voices.map((v) => ( {voices.map((v) => (
<Col xs={24} sm={12} md={8} key={v.id}> <Col xs={24} sm={12} md={8} key={v.id}>
<Checkbox value={v.id} style={{ width: '100%' }}> <Checkbox value={v.id} style={{ width: "100%" }}>
{v.name} {v.name}
</Checkbox> </Checkbox>
</Col> </Col>
@@ -239,7 +260,7 @@ const GeneratePage: React.FC = () => {
), ),
}, },
{ {
title: '生成', title: "生成",
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
content: generated ? ( content: generated ? (
<Result <Result
@@ -247,19 +268,19 @@ const GeneratePage: React.FC = () => {
title="生成任务已提交" title="生成任务已提交"
subTitle="您可以在任务历史中查看生成进度" subTitle="您可以在任务历史中查看生成进度"
extra={ extra={
<Button type="primary" onClick={() => navigate('/history')}> <Button type="primary" onClick={() => navigate("/history")}>
</Button> </Button>
} }
/> />
) : ( ) : (
<div style={{ textAlign: 'center', padding: 40 }}> <div style={{ textAlign: "center", padding: 40 }}>
{generating ? ( {generating ? (
<Spin size="large" tip="正在生成..." /> <Spin size="large" tip="正在生成..." />
) : ( ) : (
<Space direction="vertical" size={16}> <Space direction="vertical" size={16}>
<Text> <Text>
{selectedAssets.length} {selectedTitles.length}{' '} {selectedAssets.length} {selectedTitles.length}{" "}
{selectedVoices.length} {selectedVoices.length}
</Text> </Text>
<Button <Button
@@ -280,7 +301,7 @@ const GeneratePage: React.FC = () => {
if (pageLoading) { if (pageLoading) {
return ( return (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
); );
@@ -288,20 +309,22 @@ const GeneratePage: React.FC = () => {
if (pageError) { if (pageError) {
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Alert <Alert
type="error" type="error"
message="加载数据失败" message="加载数据失败"
description="部分数据获取失败,请刷新页面重试。" description="部分数据获取失败,请刷新页面重试。"
showIcon showIcon
action={<Button onClick={() => window.location.reload()}></Button>} action={
<Button onClick={() => window.location.reload()}></Button>
}
/> />
</div> </div>
); );
} }
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}> <Title level={3} style={{ marginBottom: 24 }}>
</Title> </Title>
@@ -320,8 +343,8 @@ const GeneratePage: React.FC = () => {
{!generated && ( {!generated && (
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
marginTop: 24, marginTop: 24,
}} }}
> >
+69 -55
View File
@@ -2,8 +2,8 @@
* 任务历史页面 * 任务历史页面
* 展示用户所有生成任务,支持筛选和重试 * 展示用户所有生成任务,支持筛选和重试
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Typography, Typography,
@@ -15,26 +15,41 @@ import {
Progress, Progress,
Popconfirm, Popconfirm,
message, message,
} from 'antd'; } from "antd";
import { import {
ReloadOutlined, ReloadOutlined,
CheckCircleOutlined, CheckCircleOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
SyncOutlined, SyncOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { getUserTasks, retryTask, type TaskItem } from '@/api/tasks'; import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks";
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from "antd/es/table";
const { Title } = Typography; const { Title } = Typography;
/** 任务状态标签 */ /** 任务状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => { const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<string, { color: string; icon: React.ReactNode; text: string }> = { const config: Record<
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' }, string,
processing: { color: 'processing', icon: <SyncOutlined spin />, text: '处理中' }, { color: string; icon: React.ReactNode; text: string }
pending: { color: 'default', icon: <ClockCircleOutlined />, text: '等待中' }, > = {
failed: { color: 'error', icon: <CloseCircleOutlined />, text: '失败' }, completed: {
color: "success",
icon: <CheckCircleOutlined />,
text: "已完成",
},
processing: {
color: "processing",
icon: <SyncOutlined spin />,
text: "处理中",
},
pending: {
color: "default",
icon: <ClockCircleOutlined />,
text: "等待中",
},
failed: { color: "error", icon: <CloseCircleOutlined />, text: "失败" },
}; };
const c = config[status] || config.pending; const c = config[status] || config.pending;
return ( return (
@@ -46,11 +61,11 @@ const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const TaskHistory: React.FC = () => { const TaskHistory: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>("");
// 获取任务列表 // 获取任务列表
const { data: tasks = [], isLoading } = useQuery({ const { data: tasks = [], isLoading } = useQuery({
queryKey: ['user-tasks'], queryKey: ["user-tasks"],
queryFn: getUserTasks, queryFn: getUserTasks,
}); });
@@ -58,10 +73,13 @@ const TaskHistory: React.FC = () => {
const retryMutation = useMutation({ const retryMutation = useMutation({
mutationFn: retryTask, mutationFn: retryTask,
onSuccess: () => { onSuccess: () => {
message.success('任务已重新提交'); message.success("任务已重新提交");
queryClient.invalidateQueries({ queryKey: ['user-tasks'] }); queryClient.invalidateQueries({ queryKey: ["user-tasks"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("重试失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('重试失败') },
}); });
/** 过滤后的任务 */ /** 过滤后的任务 */
@@ -71,64 +89,60 @@ const TaskHistory: React.FC = () => {
const columns: ColumnsType<TaskItem> = [ const columns: ColumnsType<TaskItem> = [
{ {
title: '任务类型', title: "任务类型",
dataIndex: 'task_type', dataIndex: "task_type",
key: 'task_type', key: "task_type",
width: 120, width: 120,
render: (type: string) => { render: (type: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
generation: '视频生成', generation: "视频生成",
ingest: '素材入库', ingest: "素材入库",
classification: '素材分类', classification: "素材分类",
voice_generate: '配音生成', voice_generate: "配音生成",
}; };
return map[type] || type; return map[type] || type;
}, },
}, },
{ {
title: '状态', title: "状态",
dataIndex: 'status', dataIndex: "status",
key: 'status', key: "status",
width: 100, width: 100,
render: (status: string) => <StatusTag status={status} />, render: (status: string) => <StatusTag status={status} />,
}, },
{ {
title: '进度', title: "进度",
dataIndex: 'progress', dataIndex: "progress",
key: 'progress', key: "progress",
width: 120, width: 120,
render: (progress: number) => ( render: (progress: number) => (
<Progress percent={Math.round((progress || 0) * 100)} size="small" /> <Progress percent={Math.round((progress || 0) * 100)} size="small" />
), ),
}, },
{ {
title: '信息', title: "信息",
dataIndex: 'user_message', dataIndex: "user_message",
key: 'user_message', key: "user_message",
ellipsis: true, ellipsis: true,
}, },
{ {
title: '创建时间', title: "创建时间",
dataIndex: 'created_at', dataIndex: "created_at",
key: 'created_at', key: "created_at",
width: 180, width: 180,
render: (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'), render: (t: string) => (t ? new Date(t).toLocaleString("zh-CN") : "-"),
}, },
{ {
title: '操作', title: "操作",
key: 'actions', key: "actions",
width: 80, width: 80,
render: (_, record) => render: (_, record) =>
record.status === 'failed' ? ( record.status === "failed" ? (
<Popconfirm <Popconfirm
title="确定重试此任务?" title="确定重试此任务?"
onConfirm={() => retryMutation.mutate(record.id)} onConfirm={() => retryMutation.mutate(record.id)}
> >
<Button <Button type="text" size="small" icon={<ReloadOutlined />}>
type="text"
size="small"
icon={<ReloadOutlined />}
>
</Button> </Button>
</Popconfirm> </Popconfirm>
@@ -137,14 +151,14 @@ const TaskHistory: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
marginBottom: 24, marginBottom: 24,
flexWrap: 'wrap', flexWrap: "wrap",
gap: 12, gap: 12,
}} }}
> >
@@ -158,20 +172,20 @@ const TaskHistory: React.FC = () => {
allowClear allowClear
style={{ width: 150 }} style={{ width: 150 }}
options={[ options={[
{ value: 'pending', label: '等待中' }, { value: "pending", label: "等待中" },
{ value: 'processing', label: '处理中' }, { value: "processing", label: "处理中" },
{ value: 'completed', label: '已完成' }, { value: "completed", label: "已完成" },
{ value: 'failed', label: '失败' }, { value: "failed", label: "失败" },
]} ]}
/> />
</div> </div>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : filteredTasks.length === 0 ? ( ) : filteredTasks.length === 0 ? (
<Empty description={statusFilter ? '没有匹配的任务' : '暂无任务记录'} /> <Empty description={statusFilter ? "没有匹配的任务" : "暂无任务记录"} />
) : ( ) : (
<Table <Table
columns={columns} columns={columns}
@@ -29,7 +29,9 @@
.mt-card { .mt-card {
height: 100%; height: 100%;
border-radius: 12px; border-radius: 12px;
transition: box-shadow 0.2s, transform 0.2s; transition:
box-shadow 0.2s,
transform 0.2s;
} }
.mt-card:hover { .mt-card:hover {
+51 -29
View File
@@ -3,8 +3,8 @@
* 卡片视图展示用户已保存的剪辑模板 * 卡片视图展示用户已保存的剪辑模板
* 支持搜索、分类筛选、编辑/复制/删除/使用模板生成 * 支持搜索、分类筛选、编辑/复制/删除/使用模板生成
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Typography, Typography,
Card, Card,
@@ -20,7 +20,7 @@ import {
Popconfirm, Popconfirm,
Row, Row,
Col, Col,
} from 'antd'; } from "antd";
import { import {
SearchOutlined, SearchOutlined,
EditOutlined, EditOutlined,
@@ -29,8 +29,8 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
AppstoreOutlined, AppstoreOutlined,
PlusOutlined, PlusOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { import {
getEditingTemplates, getEditingTemplates,
getTemplateCategories, getTemplateCategories,
@@ -40,8 +40,8 @@ import {
MODE_COLORS, MODE_COLORS,
type EditingTemplate, type EditingTemplate,
type TemplateMode, type TemplateMode,
} from '@/api/editingPlanner'; } from "@/api/editingPlanner";
import './MyTemplates.css'; import "./MyTemplates.css";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -49,12 +49,12 @@ const MyTemplates: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState("");
const [filterCategory, setFilterCategory] = useState(''); const [filterCategory, setFilterCategory] = useState("");
/* ── 数据查询 ── */ /* ── 数据查询 ── */
const { data: templates = [], isLoading } = useQuery({ const { data: templates = [], isLoading } = useQuery({
queryKey: ['editing-templates', filterCategory, searchText], queryKey: ["editing-templates", filterCategory, searchText],
queryFn: () => queryFn: () =>
getEditingTemplates({ getEditingTemplates({
category: filterCategory || undefined, category: filterCategory || undefined,
@@ -63,7 +63,7 @@ const MyTemplates: React.FC = () => {
}); });
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['template-categories'], queryKey: ["template-categories"],
queryFn: getTemplateCategories, queryFn: getTemplateCategories,
}); });
@@ -71,11 +71,12 @@ const MyTemplates: React.FC = () => {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteEditingTemplate, mutationFn: deleteEditingTemplate,
onSuccess: () => { onSuccess: () => {
message.success('模板已删除'); message.success("模板已删除");
queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
}); });
@@ -89,15 +90,23 @@ const MyTemplates: React.FC = () => {
title_config: tpl.title_config, title_config: tpl.title_config,
subtitle_config: tpl.subtitle_config, subtitle_config: tpl.subtitle_config,
bgm_config: tpl.bgm_config, bgm_config: tpl.bgm_config,
estimated_duration: tpl.estimated_duration ?? Math.round(tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0)), estimated_duration:
tpl.estimated_duration ??
Math.round(
tpl.segments.reduce(
(s, seg) => s + (seg.duration_min + seg.duration_max) / 2,
0,
),
),
segments: tpl.segments.map(({ id: _id, ...rest }) => rest), segments: tpl.segments.map(({ id: _id, ...rest }) => rest),
}), }),
onSuccess: () => { onSuccess: () => {
message.success('模板已复制'); message.success("模板已复制");
queryClient.invalidateQueries({ queryKey: ['editing-templates'] }); queryClient.invalidateQueries({ queryKey: ["editing-templates"] });
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('复制失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("复制失败");
}, },
}); });
@@ -132,7 +141,7 @@ const MyTemplates: React.FC = () => {
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => navigate('/editing-planner')} onClick={() => navigate("/editing-planner")}
> >
</Button> </Button>
@@ -151,7 +160,7 @@ const MyTemplates: React.FC = () => {
<Select <Select
placeholder="按分类筛选" placeholder="按分类筛选"
value={filterCategory || undefined} value={filterCategory || undefined}
onChange={(v) => setFilterCategory(v || '')} onChange={(v) => setFilterCategory(v || "")}
allowClear allowClear
style={{ width: 160 }} style={{ width: 160 }}
options={categories.map((c) => ({ value: c.name, label: c.name }))} options={categories.map((c) => ({ value: c.name, label: c.name }))}
@@ -161,7 +170,7 @@ const MyTemplates: React.FC = () => {
{/* 模板卡片列表 */} {/* 模板卡片列表 */}
<div className="mt-content"> <div className="mt-content">
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 80 }}> <div style={{ textAlign: "center", padding: 80 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
@@ -169,7 +178,7 @@ const MyTemplates: React.FC = () => {
description="还没有模板,点击右上角「新建模板」开始创建" description="还没有模板,点击右上角「新建模板」开始创建"
style={{ padding: 80 }} style={{ padding: 80 }}
> >
<Button type="primary" onClick={() => navigate('/editing-planner')}> <Button type="primary" onClick={() => navigate("/editing-planner")}>
</Button> </Button>
</Empty> </Empty>
@@ -188,7 +197,9 @@ const MyTemplates: React.FC = () => {
<CopyOutlined onClick={() => handleCopy(tpl)} /> <CopyOutlined onClick={() => handleCopy(tpl)} />
</Tooltip>, </Tooltip>,
<Tooltip title="使用模板生成" key="generate"> <Tooltip title="使用模板生成" key="generate">
<VideoCameraOutlined onClick={() => handleGenerate(tpl)} /> <VideoCameraOutlined
onClick={() => handleGenerate(tpl)}
/>
</Tooltip>, </Tooltip>,
<Popconfirm <Popconfirm
key="delete" key="delete"
@@ -198,7 +209,7 @@ const MyTemplates: React.FC = () => {
cancelText="取消" cancelText="取消"
> >
<Tooltip title="删除"> <Tooltip title="删除">
<DeleteOutlined style={{ color: '#ff4d4f' }} /> <DeleteOutlined style={{ color: "#ff4d4f" }} />
</Tooltip> </Tooltip>
</Popconfirm>, </Popconfirm>,
]} ]}
@@ -207,15 +218,22 @@ const MyTemplates: React.FC = () => {
<Text strong ellipsis style={{ fontSize: 15 }}> <Text strong ellipsis style={{ fontSize: 15 }}>
{tpl.name} {tpl.name}
</Text> </Text>
<Tag color={MODE_COLORS[tpl.mode as TemplateMode] || 'default'}>{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}</Tag> <Tag
color={MODE_COLORS[tpl.mode as TemplateMode] || "default"}
>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
</Tag>
</div> </div>
<div className="mt-card-meta"> <div className="mt-card-meta">
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{tpl.segments.length} · ~{tpl.estimated_duration}s {tpl.segments.length} · ~
{tpl.estimated_duration}s
</Text> </Text>
{tpl.category && ( {tpl.category && (
<Tag style={{ fontSize: 11, marginTop: 4 }}>{tpl.category}</Tag> <Tag style={{ fontSize: 11, marginTop: 4 }}>
{tpl.category}
</Tag>
)} )}
</div> </div>
@@ -231,8 +249,12 @@ const MyTemplates: React.FC = () => {
<div className="mt-card-config"> <div className="mt-card-config">
<Space size={4} wrap> <Space size={4} wrap>
{tpl.title_config.ai_auto_select && <Tag color="cyan">AI标题</Tag>} {tpl.title_config.ai_auto_select && (
{tpl.subtitle_config.enabled && <Tag color="geekblue"></Tag>} <Tag color="cyan">AI标题</Tag>
)}
{tpl.subtitle_config.enabled && (
<Tag color="geekblue"></Tag>
)}
{tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>} {tpl.bgm_config.enabled && <Tag color="pink">BGM</Tag>}
</Space> </Space>
</div> </div>
+63 -47
View File
@@ -2,8 +2,8 @@
* 成品库页面 * 成品库页面
* 展示用户生成的成品视频,支持查重率显示 * 展示用户生成的成品视频,支持查重率显示
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Card, Card,
Button, Button,
@@ -19,41 +19,42 @@ import {
message, message,
Progress, Progress,
Image, Image,
} from 'antd'; } from "antd";
import { import {
DeleteOutlined, DeleteOutlined,
DownloadOutlined, DownloadOutlined,
PlayCircleOutlined, PlayCircleOutlined,
EyeOutlined, EyeOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { import {
getProducts, getProducts,
deleteProduct, deleteProduct,
getProductDownloadUrl, getProductDownloadUrl,
type ProductItem, type ProductItem,
} from '@/api/products'; } from "@/api/products";
const { Title, Text } = Typography; const { Title, Text } = Typography;
/** 状态标签 */ /** 状态标签 */
const StatusTag: React.FC<{ status: string }> = ({ status }) => { const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const config: Record<string, { color: string; text: string }> = { const config: Record<string, { color: string; text: string }> = {
completed: { color: 'success', text: '已完成' }, completed: { color: "success", text: "已完成" },
processing: { color: 'processing', text: '处理中' }, processing: { color: "processing", text: "处理中" },
failed: { color: 'error', text: '失败' }, failed: { color: "error", text: "失败" },
}; };
const c = config[status] || config.processing; const c = config[status] || config.processing;
return <Tag color={c.color}>{c.text}</Tag>; return <Tag color={c.color}>{c.text}</Tag>;
}; };
const ProductLibrary: React.FC = () => { const ProductLibrary: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [previewProduct, setPreviewProduct] = useState<ProductItem | null>(null); const [previewProduct, setPreviewProduct] = useState<ProductItem | null>(
null,
);
// 获取成品列表 // 获取成品列表
const { data: products = [], isLoading } = useQuery({ const { data: products = [], isLoading } = useQuery({
queryKey: ['products'], queryKey: ["products"],
queryFn: getProducts, queryFn: getProducts,
}); });
@@ -61,35 +62,42 @@ const ProductLibrary: React.FC = () => {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteProduct, mutationFn: deleteProduct,
onSuccess: () => { onSuccess: () => {
message.success('已删除'); message.success("已删除");
queryClient.invalidateQueries({ queryKey: ['products'] }); queryClient.invalidateQueries({ queryKey: ["products"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败') },
}); });
// 下载 // 下载
const handleDownload = async (productId: string) => { const handleDownload = async (productId: string) => {
try { try {
const { url } = await getProductDownloadUrl(productId); const { url } = await getProductDownloadUrl(productId);
window.open(url, '_blank'); window.open(url, "_blank");
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('获取下载链接失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("获取下载链接失败");
} }
}; };
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}> <Title level={3} style={{ marginBottom: 24 }}>
</Title> </Title>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : products.length === 0 ? ( ) : products.length === 0 ? (
<Empty description="暂无成品,去一键生成吧"> <Empty description="暂无成品,去一键生成吧">
<Button type="primary" onClick={() => (window.location.href = '/generate')}> <Button
type="primary"
onClick={() => (window.location.href = "/generate")}
>
</Button> </Button>
</Empty> </Empty>
@@ -105,20 +113,22 @@ const ProductLibrary: React.FC = () => {
<Image <Image
src={product.thumbnail_url} src={product.thumbnail_url}
alt={product.title} alt={product.title}
style={{ height: 180, objectFit: 'cover' }} style={{ height: 180, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+" fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
/> />
) : ( ) : (
<div <div
style={{ style={{
height: 180, height: 180,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
background: '#f5f5f5', background: "#f5f5f5",
}} }}
> >
<PlayCircleOutlined style={{ fontSize: 48, color: '#bbb' }} /> <PlayCircleOutlined
style={{ fontSize: 48, color: "#bbb" }}
/>
</div> </div>
) )
} }
@@ -136,25 +146,34 @@ const ProductLibrary: React.FC = () => {
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
onClick={() => handleDownload(product.id)} onClick={() => handleDownload(product.id)}
disabled={product.status !== 'completed'} disabled={product.status !== "completed"}
/>, />,
<Popconfirm <Popconfirm
key="delete" key="delete"
title="确定删除此成品?" title="确定删除此成品?"
onConfirm={() => deleteMutation.mutate(product.id)} onConfirm={() => deleteMutation.mutate(product.id)}
> >
<Button type="text" size="small" danger icon={<DeleteOutlined />} /> <Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>, </Popconfirm>,
]} ]}
> >
<Card.Meta <Card.Meta
title={ title={
<Text ellipsis style={{ maxWidth: '100%' }}> <Text ellipsis style={{ maxWidth: "100%" }}>
{product.title} {product.title}
</Text> </Text>
} }
description={ description={
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space
direction="vertical"
size={4}
style={{ width: "100%" }}
>
<Space> <Space>
<StatusTag status={product.status} /> <StatusTag status={product.status} />
{product.duration_seconds && ( {product.duration_seconds && (
@@ -173,10 +192,10 @@ const ProductLibrary: React.FC = () => {
size="small" size="small"
status={ status={
product.duplicate_rate <= 10 product.duplicate_rate <= 10
? 'success' ? "success"
: product.duplicate_rate <= 30 : product.duplicate_rate <= 30
? 'normal' ? "normal"
: 'exception' : "exception"
} }
/> />
</div> </div>
@@ -202,35 +221,32 @@ const ProductLibrary: React.FC = () => {
onClick={() => onClick={() =>
previewProduct && handleDownload(previewProduct.id) previewProduct && handleDownload(previewProduct.id)
} }
disabled={previewProduct?.status !== 'completed'} disabled={previewProduct?.status !== "completed"}
> >
</Button> </Button>
<Button <Button type="primary" onClick={() => setPreviewProduct(null)}>
type="primary"
onClick={() => setPreviewProduct(null)}
>
</Button> </Button>
</Space> </Space>
} }
> >
{previewProduct && ( {previewProduct && (
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
{previewProduct.video_url ? ( {previewProduct.video_url ? (
<video <video
src={previewProduct.video_url} src={previewProduct.video_url}
controls controls
style={{ width: '100%', maxHeight: 400 }} style={{ width: "100%", maxHeight: 400 }}
/> />
) : ( ) : (
<div <div
style={{ style={{
height: 200, height: 200,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
background: '#f5f5f5', background: "#f5f5f5",
}} }}
> >
<Text type="secondary"></Text> <Text type="secondary"></Text>
@@ -239,14 +255,14 @@ const ProductLibrary: React.FC = () => {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Text type="secondary"></Text> <Text type="secondary"></Text>
<Text>{previewProduct.resolution || '-'}</Text> <Text>{previewProduct.resolution || "-"}</Text>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Text type="secondary"></Text> <Text type="secondary"></Text>
<Text> <Text>
{previewProduct.duration_seconds {previewProduct.duration_seconds
? `${previewProduct.duration_seconds}s` ? `${previewProduct.duration_seconds}s`
: '-'} : "-"}
</Text> </Text>
</Col> </Col>
<Col span={12}> <Col span={12}>
@@ -254,7 +270,7 @@ const ProductLibrary: React.FC = () => {
<Text> <Text>
{previewProduct.file_size {previewProduct.file_size
? `${(previewProduct.file_size / 1024 / 1024).toFixed(1)} MB` ? `${(previewProduct.file_size / 1024 / 1024).toFixed(1)} MB`
: '-'} : "-"}
</Text> </Text>
</Col> </Col>
<Col span={12}> <Col span={12}>
@@ -262,7 +278,7 @@ const ProductLibrary: React.FC = () => {
<Text> <Text>
{previewProduct.duplicate_rate !== undefined {previewProduct.duplicate_rate !== undefined
? `${previewProduct.duplicate_rate.toFixed(1)}%` ? `${previewProduct.duplicate_rate.toFixed(1)}%`
: '-'} : "-"}
</Text> </Text>
</Col> </Col>
</Row> </Row>
+13 -13
View File
@@ -11,10 +11,10 @@
/* 页面头部 */ /* 页面头部 */
.xx-settings-head { .xx-settings-head {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 28px; border-radius: 28px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 36px; padding: 32px 36px;
margin-bottom: 28px; margin-bottom: 28px;
} }
@@ -35,17 +35,17 @@
/* 内容卡片 */ /* 内容卡片 */
.xx-settings-card { .xx-settings-card {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 28px; border-radius: 28px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 36px; padding: 32px 36px;
margin-bottom: 28px; margin-bottom: 28px;
transition: all 0.25s; transition: all 0.25s;
} }
.xx-settings-card:hover { .xx-settings-card:hover {
box-shadow: 0 26px 64px rgba(15,23,42,0.14); box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -61,10 +61,10 @@
/* 结果页面容器 */ /* 结果页面容器 */
.xx-result-page { .xx-result-page {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 28px; border-radius: 28px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 60px 40px; padding: 60px 40px;
margin-bottom: 28px; margin-bottom: 28px;
text-align: center; text-align: center;
@@ -72,7 +72,7 @@
} }
.xx-result-page:hover { .xx-result-page:hover {
box-shadow: 0 26px 64px rgba(15,23,42,0.14); box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14);
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -122,7 +122,7 @@
.ant-input-affix-wrapper:focus, .ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused { .ant-input-affix-wrapper-focused {
border-color: var(--indigo, #4f46e5) !important; border-color: var(--indigo, #4f46e5) !important;
box-shadow: 0 0 0 2px rgba(79,70,229,0.1) !important; box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1) !important;
} }
/* Switch 样式 */ /* Switch 样式 */
@@ -143,7 +143,7 @@
.ant-alert-info { .ant-alert-info {
background: var(--indigo-soft, #eef2ff); background: var(--indigo-soft, #eef2ff);
border: 1px solid rgba(79,70,229,0.2); border: 1px solid rgba(79, 70, 229, 0.2);
} }
.ant-alert-message { .ant-alert-message {
+4 -5
View File
@@ -1,10 +1,10 @@
/** /**
* 个人设置页面 * 个人设置页面
*/ */
import React from 'react'; import React from "react";
import { Form, Input, Button, Alert } from 'antd'; import { Form, Input, Button, Alert } from "antd";
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from "@/store/authStore";
import './ProfileSettings.css'; import "./ProfileSettings.css";
const Settings: React.FC = () => { const Settings: React.FC = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
@@ -12,7 +12,6 @@ const Settings: React.FC = () => {
const onFinish = () => undefined; const onFinish = () => undefined;
return ( return (
<div className="xx-settings-page"> <div className="xx-settings-page">
<div className="xx-settings-card"> <div className="xx-settings-card">
+21 -12
View File
@@ -2,20 +2,25 @@
* 账单管理页面 * 账单管理页面
* 展示当前订阅信息 + 自动续费开关 * 展示当前订阅信息 + 自动续费开关
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { Switch, message, Spin } from 'antd'; import { Switch, message, Spin } from "antd";
import { getCurrentSubscription, toggleAutoRenew } from '@/api/subscription'; import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription";
import type { SubscriptionInfo } from '@/api/subscription'; import type { SubscriptionInfo } from "@/api/subscription";
import './Billing.css'; import "./Billing.css";
const formatDate = (iso: string): string => { const formatDate = (iso: string): string => {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}; };
const Billing: React.FC = () => { const Billing: React.FC = () => {
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null); const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [autoRenewChecked, setAutoRenewChecked] = useState(false); const [autoRenewChecked, setAutoRenewChecked] = useState(false);
const [autoRenewLoading, setAutoRenewLoading] = useState(false); const [autoRenewLoading, setAutoRenewLoading] = useState(false);
@@ -30,7 +35,8 @@ const Billing: React.FC = () => {
setSubscription(data); setSubscription(data);
setAutoRenewChecked(data.auto_renew); setAutoRenewChecked(data.auto_renew);
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('加载订阅数据失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("加载订阅数据失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -46,7 +52,8 @@ const Billing: React.FC = () => {
setSubscription({ ...subscription, auto_renew: checked }); setSubscription({ ...subscription, auto_renew: checked });
} }
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('操作失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("操作失败");
} finally { } finally {
setAutoRenewLoading(false); setAutoRenewLoading(false);
} }
@@ -75,12 +82,14 @@ const Billing: React.FC = () => {
<div className="xx-overview-item"> <div className="xx-overview-item">
<span className="xx-label"></span> <span className="xx-label"></span>
<span className="xx-value"> <span className="xx-value">
{subscription.billing_cycle === 'monthly' ? '月付' : '年付'} {subscription.billing_cycle === "monthly" ? "月付" : "年付"}
</span> </span>
</div> </div>
<div className="xx-overview-item"> <div className="xx-overview-item">
<span className="xx-label"></span> <span className="xx-label"></span>
<span className="xx-value">{formatDate(subscription.current_period_end)}</span> <span className="xx-value">
{formatDate(subscription.current_period_end)}
</span>
</div> </div>
</div> </div>
</div> </div>
+10 -8
View File
@@ -53,10 +53,10 @@
/* V21 定价卡片 */ /* V21 定价卡片 */
.xx-plan-card { .xx-plan-card {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(226,232,240,0.95); border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 28px; border-radius: 28px;
box-shadow: 0 24px 70px rgba(15,23,42,0.09); box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09);
padding: 32px 28px; padding: 32px 28px;
position: relative; position: relative;
transition: all 0.3s; transition: all 0.3s;
@@ -66,13 +66,15 @@
.xx-plan-card:hover { .xx-plan-card:hover {
border-color: var(--indigo, #4f46e5); border-color: var(--indigo, #4f46e5);
box-shadow: 0 32px 90px rgba(79,70,229,0.15); box-shadow: 0 32px 90px rgba(79, 70, 229, 0.15);
transform: translateY(-4px); transform: translateY(-4px);
} }
.xx-plan-card.featured { .xx-plan-card.featured {
border: 2px solid var(--indigo, #4f46e5); border: 2px solid var(--indigo, #4f46e5);
box-shadow: 0 0 0 4px rgba(79,70,229,0.1), 0 32px 90px rgba(79,70,229,0.15); box-shadow:
0 0 0 4px rgba(79, 70, 229, 0.1),
0 32px 90px rgba(79, 70, 229, 0.15);
transform: scale(1.02); transform: scale(1.02);
} }
@@ -188,11 +190,11 @@
background: linear-gradient(135deg, #6366f1, #4f46e5) !important; background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
color: white !important; color: white !important;
border: none !important; border: none !important;
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important; box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important;
} }
.xx-subscribe-btn.primary:hover { .xx-subscribe-btn.primary:hover {
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important; box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important;
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -212,5 +214,5 @@
background: linear-gradient(135deg, #f59e0b, #d97706) !important; background: linear-gradient(135deg, #f59e0b, #d97706) !important;
color: white !important; color: white !important;
border: none !important; border: none !important;
box-shadow: 0 14px 26px rgba(245,158,11,0.22) !important; box-shadow: 0 14px 26px rgba(245, 158, 11, 0.22) !important;
} }
+72 -69
View File
@@ -1,67 +1,62 @@
/** /**
* 定价页面 - V21 UI 四级定价 * 定价页面 - V21 UI 四级定价
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { message } from 'antd'; import { message } from "antd";
import './Plans.css'; import "./Plans.css";
const PLANS = [ const PLANS = [
{ {
id: 'free', id: "free",
name: '体验版', name: "体验版",
price: 0, price: 0,
description: '适合个人体验', description: "适合个人体验",
recommended: false, recommended: false,
features: [ features: ["3个视频成片", "基础标题模板", "基础配音音色", "720p导出"],
'3个视频成片',
'基础标题模板',
'基础配音音色',
'720p导出'
]
}, },
{ {
id: 'standard', id: "standard",
name: '标准版', name: "标准版",
price: 99, price: 99,
description: '适合内容创作者', description: "适合内容创作者",
recommended: false, recommended: false,
features: [ features: [
'无限视频成片', "无限视频成片",
'高级标题模板', "高级标题模板",
'全部配音音色', "全部配音音色",
'1080p导出', "1080p导出",
'API接口' "API接口",
] ],
}, },
{ {
id: 'pro', id: "pro",
name: '专业版', name: "专业版",
price: 299, price: 299,
description: '适合专业团队', description: "适合专业团队",
recommended: true, recommended: true,
features: [ features: [
'无限视频成片', "无限视频成片",
'自定义标题', "自定义标题",
'全部配音音色', "全部配音音色",
'4K导出', "4K导出",
'优先渲染队列', "优先渲染队列",
'专属客服' "专属客服",
] ],
}, },
{ {
id: 'enterprise', id: "enterprise",
name: '企业版', name: "企业版",
price: null, price: null,
description: '适合企业用户', description: "适合企业用户",
recommended: false, recommended: false,
features: [ features: [
'私有化部署', "私有化部署",
'自定义品牌形象', "自定义品牌形象",
' SLA服务保障', " SLA服务保障",
'技术支持', "技术支持",
'团队协作', "团队协作",
'数据报表' "数据报表",
] ],
}, },
]; ];
@@ -71,9 +66,9 @@ const Plans: React.FC = () => {
const handleSubscribe = async (_planId: string) => { const handleSubscribe = async (_planId: string) => {
try { try {
setSubscribing(true); setSubscribing(true);
message.success('订阅成功'); message.success("订阅成功");
} catch (error: unknown) { } catch (error: unknown) {
message.error('订阅失败'); message.error("订阅失败");
} finally { } finally {
setSubscribing(false); setSubscribing(false);
} }
@@ -88,16 +83,20 @@ const Plans: React.FC = () => {
<div className="xx-plans-grid"> <div className="xx-plans-grid">
{PLANS.map((plan) => ( {PLANS.map((plan) => (
<div <div
key={plan.id} key={plan.id}
className={`xx-plan-card ${plan.recommended ? 'featured' : ''}`} className={`xx-plan-card ${plan.recommended ? "featured" : ""}`}
> >
{plan.recommended && <div className="xx-badge"></div>} {plan.recommended && <div className="xx-badge"></div>}
{plan.id === 'enterprise' && <div className="xx-badge enterprise"></div>} {plan.id === "enterprise" && (
<div className="xx-badge enterprise"></div>
)}
<h3>{plan.name}</h3> <h3>{plan.name}</h3>
<div className={`xx-plan-price ${plan.price === null ? 'custom' : ''}`}> <div
className={`xx-plan-price ${plan.price === null ? "custom" : ""}`}
>
{plan.price !== null ? ( {plan.price !== null ? (
<> <>
<span className="currency">¥</span> <span className="currency">¥</span>
@@ -108,7 +107,7 @@ const Plans: React.FC = () => {
<span className="amount"></span> <span className="amount"></span>
)} )}
</div> </div>
<p className="xx-plan-description">{plan.description}</p> <p className="xx-plan-description">{plan.description}</p>
<div className="xx-features"> <div className="xx-features">
@@ -122,16 +121,20 @@ const Plans: React.FC = () => {
<button <button
className={`xx-subscribe-btn ${ className={`xx-subscribe-btn ${
plan.id === 'enterprise' plan.id === "enterprise"
? 'enterprise' ? "enterprise"
: plan.recommended : plan.recommended
? 'primary' ? "primary"
: 'ghost' : "ghost"
}`} }`}
onClick={() => handleSubscribe(plan.id)} onClick={() => handleSubscribe(plan.id)}
disabled={subscribing} disabled={subscribing}
> >
{subscribing ? '处理中...' : plan.price === null ? '联系我们' : '立即订阅'} {subscribing
? "处理中..."
: plan.price === null
? "联系我们"
: "立即订阅"}
</button> </button>
</div> </div>
))} ))}
@@ -55,7 +55,9 @@
.xx-upgrade-card.selected { .xx-upgrade-card.selected {
border-color: var(--indigo, #4f46e5); border-color: var(--indigo, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12), 0 8px 30px rgba(79, 70, 229, 0.1); box-shadow:
0 0 0 3px rgba(79, 70, 229, 0.12),
0 8px 30px rgba(79, 70, 229, 0.1);
} }
.xx-upgrade-card.current { .xx-upgrade-card.current {
@@ -1,27 +1,41 @@
/** /**
* 升级/降级/续费页面 * 升级/降级/续费页面
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { Button, Radio, message, Spin, Modal } from 'antd'; import { Button, Radio, message, Spin, Modal } from "antd";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { getCurrentSubscription, changePlan, toggleAutoRenew, cancelSubscription } from '@/api/subscription'; import {
import type { SubscriptionInfo, PlanType, BillingCycle } from '@/api/subscription'; getCurrentSubscription,
import './UpgradeSubscription.css'; changePlan,
toggleAutoRenew,
cancelSubscription,
} from "@/api/subscription";
import type {
SubscriptionInfo,
PlanType,
BillingCycle,
} from "@/api/subscription";
import "./UpgradeSubscription.css";
const PLANS_META: Record<string, { name: string; price: number; yearlyPrice: number }> = { const PLANS_META: Record<
free: { name: '体验版', price: 0, yearlyPrice: 0 }, string,
standard: { name: '标准版', price: 99, yearlyPrice: 990 }, { name: string; price: number; yearlyPrice: number }
pro: { name: '专业版', price: 299, yearlyPrice: 2990 }, > = {
enterprise: { name: '企业版', price: 0, yearlyPrice: 0 }, free: { name: "体验版", price: 0, yearlyPrice: 0 },
standard: { name: "标准版", price: 99, yearlyPrice: 990 },
pro: { name: "专业版", price: 299, yearlyPrice: 2990 },
enterprise: { name: "企业版", price: 0, yearlyPrice: 0 },
}; };
const UpgradeSubscription: React.FC = () => { const UpgradeSubscription: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null); const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
null,
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanType>('standard'); const [selectedPlan, setSelectedPlan] = useState<PlanType>("standard");
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly'); const [billingCycle, setBillingCycle] = useState<BillingCycle>("monthly");
useEffect(() => { useEffect(() => {
loadSubscription(); loadSubscription();
@@ -33,7 +47,8 @@ const UpgradeSubscription: React.FC = () => {
setSubscription(data); setSubscription(data);
setSelectedPlan(data.plan_id); setSelectedPlan(data.plan_id);
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('获取订阅信息失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("获取订阅信息失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -41,23 +56,29 @@ const UpgradeSubscription: React.FC = () => {
const handleUpgrade = async () => { const handleUpgrade = async () => {
if (!subscription) return; if (!subscription) return;
if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) { if (
message.info('当前已是该套餐'); selectedPlan === subscription.plan_id &&
billingCycle === subscription.billing_cycle
) {
message.info("当前已是该套餐");
return; return;
} }
const plan = PLANS_META[selectedPlan]; const plan = PLANS_META[selectedPlan];
const price = billingCycle === 'yearly' ? plan.yearlyPrice : plan.price; const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price;
Modal.confirm({ Modal.confirm({
title: '确认变更套餐', title: "确认变更套餐",
content: `即将变更为「${plan.name}」(${billingCycle === 'monthly' ? '月付' : '年付'}),${price > 0 ? `费用 ¥${price}${billingCycle === 'monthly' ? '/月' : '/年'}` : '免费'}。变更立即生效。`, content: `即将变更为「${plan.name}」(${billingCycle === "monthly" ? "月付" : "年付"}),${price > 0 ? `费用 ¥${price}${billingCycle === "monthly" ? "/月" : "/年"}` : "免费"}。变更立即生效。`,
okText: '确认变更', okText: "确认变更",
cancelText: '取消', cancelText: "取消",
onOk: async () => { onOk: async () => {
try { try {
setSubmitting(true); setSubmitting(true);
const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle }); const res = await changePlan({
target_plan_id: selectedPlan,
billing_cycle: billingCycle,
});
if (res.success) { if (res.success) {
message.success(res.message); message.success(res.message);
setSubscription(res.new_subscription ?? null); setSubscription(res.new_subscription ?? null);
@@ -65,7 +86,8 @@ const UpgradeSubscription: React.FC = () => {
message.error(res.message); message.error(res.message);
} }
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('套餐变更失败,请重试'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("套餐变更失败,请重试");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -81,50 +103,56 @@ const UpgradeSubscription: React.FC = () => {
setSubscription({ ...subscription, auto_renew: enabled }); setSubscription({ ...subscription, auto_renew: enabled });
} }
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('操作失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("操作失败");
} }
}; };
const handleCancel = () => { const handleCancel = () => {
Modal.confirm({ Modal.confirm({
title: '确认取消订阅', title: "确认取消订阅",
content: '取消后,当前周期结束前仍可正常使用,到期后降级为体验版。', content: "取消后,当前周期结束前仍可正常使用,到期后降级为体验版。",
okText: '确认取消', okText: "确认取消",
okType: 'danger', okType: "danger",
cancelText: '再想想', cancelText: "再想想",
onOk: async () => { onOk: async () => {
try { try {
const res = await cancelSubscription(); const res = await cancelSubscription();
message.success(res.message); message.success(res.message);
navigate('/subscription'); navigate("/subscription");
} catch (err: unknown) { } catch (err: unknown) {
if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('取消失败'); if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("取消失败");
} }
}, },
}); });
}; };
if (loading) { if (loading) {
return <div className="xx-upgrade-page"><Spin size="large" /></div>; return (
<div className="xx-upgrade-page">
<Spin size="large" />
</div>
);
} }
const currentPlan = subscription?.plan_id ?? 'free'; const currentPlan = subscription?.plan_id ?? "free";
return ( return (
<div className="xx-upgrade-page"> <div className="xx-upgrade-page">
<div className="xx-upgrade-header"> <div className="xx-upgrade-header">
<h2></h2> <h2></h2>
<p>{PLANS_META[currentPlan]?.name ?? '体验版'}</p> <p>{PLANS_META[currentPlan]?.name ?? "体验版"}</p>
</div> </div>
<div className="xx-upgrade-plans"> <div className="xx-upgrade-plans">
{(['standard', 'pro', 'enterprise'] as PlanType[]).map((planId) => { {(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
const plan = PLANS_META[planId]; const plan = PLANS_META[planId];
const isCurrent = planId === currentPlan; const isCurrent = planId === currentPlan;
return ( return (
<div <div
key={planId} key={planId}
className={`xx-upgrade-card ${isCurrent ? 'current' : ''} ${selectedPlan === planId ? 'selected' : ''}`} className={`xx-upgrade-card ${isCurrent ? "current" : ""} ${selectedPlan === planId ? "selected" : ""}`}
onClick={() => setSelectedPlan(planId)} onClick={() => setSelectedPlan(planId)}
> >
{isCurrent && <div className="xx-current-badge"></div>} {isCurrent && <div className="xx-current-badge"></div>}
@@ -135,13 +163,13 @@ const UpgradeSubscription: React.FC = () => {
onChange={(e) => setBillingCycle(e.target.value)} onChange={(e) => setBillingCycle(e.target.value)}
size="small" size="small"
> >
<Radio.Button value="monthly"> <Radio.Button value="monthly">¥{plan.price}/</Radio.Button>
¥{plan.price}/
</Radio.Button>
<Radio.Button value="yearly"> <Radio.Button value="yearly">
¥{plan.yearlyPrice}/ ¥{plan.yearlyPrice}/
{plan.yearlyPrice > 0 && plan.price > 0 && ( {plan.yearlyPrice > 0 && plan.price > 0 && (
<span className="xx-save"> ¥{plan.price * 12 - plan.yearlyPrice}</span> <span className="xx-save">
¥{plan.price * 12 - plan.yearlyPrice}
</span>
)} )}
</Radio.Button> </Radio.Button>
</Radio.Group> </Radio.Group>
@@ -162,23 +190,32 @@ const UpgradeSubscription: React.FC = () => {
</Button> </Button>
{subscription && subscription.status === 'active' && ( {subscription && subscription.status === "active" && (
<div className="xx-auto-renew-section"> <div className="xx-auto-renew-section">
<span>{subscription.auto_renew ? '已开启' : '已关闭'}</span> <span>
{subscription.auto_renew ? "已开启" : "已关闭"}
</span>
<Button <Button
type="link" type="link"
onClick={() => handleToggleAutoRenew(!subscription.auto_renew)} onClick={() => handleToggleAutoRenew(!subscription.auto_renew)}
> >
{subscription.auto_renew ? '关闭' : '开启'} {subscription.auto_renew ? "关闭" : "开启"}
</Button> </Button>
</div> </div>
)} )}
{subscription && subscription.status === 'active' && currentPlan !== 'free' && ( {subscription &&
<Button type="link" danger onClick={handleCancel} className="xx-cancel-btn"> subscription.status === "active" &&
currentPlan !== "free" && (
</Button> <Button
)} type="link"
danger
onClick={handleCancel}
className="xx-cancel-btn"
>
</Button>
)}
</div> </div>
</div> </div>
); );
@@ -2,8 +2,8 @@
* 模板库页面 * 模板库页面
* 展示系统模板,支持预览和筛选 * 展示系统模板,支持预览和筛选
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Card, Card,
Button, Button,
@@ -19,32 +19,32 @@ import {
Modal, Modal,
Image, Image,
message, message,
} from 'antd'; } from "antd";
import { import {
PlayCircleOutlined, PlayCircleOutlined,
StarOutlined, StarOutlined,
StarFilled, StarFilled,
SearchOutlined, SearchOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { import {
getTemplates, getTemplates,
toggleFavoriteTemplate, toggleFavoriteTemplate,
type TemplateItem, type TemplateItem,
} from '@/api/templates'; } from "@/api/templates";
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
const TemplateLibrary: React.FC = () => { const TemplateLibrary: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState("");
const [categoryFilter, setCategoryFilter] = useState(''); const [categoryFilter, setCategoryFilter] = useState("");
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>( const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
null null,
); );
// 获取模板列表 // 获取模板列表
const { data: templates = [], isLoading } = useQuery({ const { data: templates = [], isLoading } = useQuery({
queryKey: ['templates'], queryKey: ["templates"],
queryFn: getTemplates, queryFn: getTemplates,
}); });
@@ -52,15 +52,18 @@ const TemplateLibrary: React.FC = () => {
const favMutation = useMutation({ const favMutation = useMutation({
mutationFn: toggleFavoriteTemplate, mutationFn: toggleFavoriteTemplate,
onSuccess: (data) => { onSuccess: (data) => {
message.success(data.is_favorite ? '已收藏' : '已取消收藏'); message.success(data.is_favorite ? "已收藏" : "已取消收藏");
queryClient.invalidateQueries({ queryKey: ['templates'] }); queryClient.invalidateQueries({ queryKey: ["templates"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("操作失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('操作失败') },
}); });
/** 提取所有分类 */ /** 提取所有分类 */
const categories = Array.from( const categories = Array.from(
new Set(templates.map((t) => t.category).filter(Boolean)) new Set(templates.map((t) => t.category).filter(Boolean)),
); );
/** 过滤后的模板 */ /** 过滤后的模板 */
@@ -68,13 +71,13 @@ const TemplateLibrary: React.FC = () => {
const matchSearch = const matchSearch =
!searchText || !searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) || t.name.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description || '').toLowerCase().includes(searchText.toLowerCase()); (t.description || "").toLowerCase().includes(searchText.toLowerCase());
const matchCategory = !categoryFilter || t.category === categoryFilter; const matchCategory = !categoryFilter || t.category === categoryFilter;
return matchSearch && matchCategory; return matchSearch && matchCategory;
}); });
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<Title level={3} style={{ marginBottom: 24 }}> <Title level={3} style={{ marginBottom: 24 }}>
</Title> </Title>
@@ -96,18 +99,22 @@ const TemplateLibrary: React.FC = () => {
value={categoryFilter || undefined} value={categoryFilter || undefined}
onChange={setCategoryFilter} onChange={setCategoryFilter}
allowClear allowClear
style={{ width: '100%' }} style={{ width: "100%" }}
options={categories.map((c) => ({ value: c, label: c }))} options={categories.map((c) => ({ value: c, label: c }))}
/> />
</Col> </Col>
</Row> </Row>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : filteredTemplates.length === 0 ? ( ) : filteredTemplates.length === 0 ? (
<Empty description={searchText || categoryFilter ? '未找到匹配的模板' : '暂无模板'} /> <Empty
description={
searchText || categoryFilter ? "未找到匹配的模板" : "暂无模板"
}
/>
) : ( ) : (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{filteredTemplates.map((template) => ( {filteredTemplates.map((template) => (
@@ -120,20 +127,22 @@ const TemplateLibrary: React.FC = () => {
<Image <Image
src={template.thumbnail_url} src={template.thumbnail_url}
alt={template.name} alt={template.name}
style={{ height: 180, objectFit: 'cover' }} style={{ height: 180, objectFit: "cover" }}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+" fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
/> />
) : ( ) : (
<div <div
style={{ style={{
height: 180, height: 180,
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
background: '#f5f5f5', background: "#f5f5f5",
}} }}
> >
<PlayCircleOutlined style={{ fontSize: 48, color: '#bbb' }} /> <PlayCircleOutlined
style={{ fontSize: 48, color: "#bbb" }}
/>
</div> </div>
) )
} }
@@ -153,7 +162,7 @@ const TemplateLibrary: React.FC = () => {
size="small" size="small"
icon={ icon={
template.is_favorite ? ( template.is_favorite ? (
<StarFilled style={{ color: '#faad14' }} /> <StarFilled style={{ color: "#faad14" }} />
) : ( ) : (
<StarOutlined /> <StarOutlined />
) )
@@ -171,7 +180,7 @@ const TemplateLibrary: React.FC = () => {
ellipsis={{ rows: 2 }} ellipsis={{ rows: 2 }}
style={{ marginBottom: 0, fontSize: 12 }} style={{ marginBottom: 0, fontSize: 12 }}
> >
{template.description || '暂无描述'} {template.description || "暂无描述"}
</Paragraph> </Paragraph>
<Space> <Space>
{template.category && ( {template.category && (
@@ -208,12 +217,12 @@ const TemplateLibrary: React.FC = () => {
} }
> >
{previewTemplate && ( {previewTemplate && (
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
{previewTemplate.preview_url && ( {previewTemplate.preview_url && (
<video <video
src={previewTemplate.preview_url} src={previewTemplate.preview_url}
controls controls
style={{ width: '100%', maxHeight: 400 }} style={{ width: "100%", maxHeight: 400 }}
/> />
)} )}
<Paragraph>{previewTemplate.description}</Paragraph> <Paragraph>{previewTemplate.description}</Paragraph>
+72 -57
View File
@@ -2,8 +2,8 @@
* 标题库页面 * 标题库页面
* 管理用户标题,支持 CRUD 和批量导入 * 管理用户标题,支持 CRUD 和批量导入
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Typography, Typography,
@@ -16,14 +16,14 @@ import {
Tag, Tag,
Popconfirm, Popconfirm,
message, message,
} from 'antd'; } from "antd";
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
ImportOutlined, ImportOutlined,
SearchOutlined, SearchOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { import {
getTitles, getTitles,
createTitle, createTitle,
@@ -31,8 +31,8 @@ import {
deleteTitle, deleteTitle,
batchImportTitles, batchImportTitles,
type TitleItem, type TitleItem,
} from '@/api/titles'; } from "@/api/titles";
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from "antd/es/table";
const { Title } = Typography; const { Title } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -42,14 +42,14 @@ const TitleLibrary: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [importModalOpen, setImportModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState<TitleItem | null>(null); const [editingTitle, setEditingTitle] = useState<TitleItem | null>(null);
const [formContent, setFormContent] = useState(''); const [formContent, setFormContent] = useState("");
const [formCategory, setFormCategory] = useState(''); const [formCategory, setFormCategory] = useState("");
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState("");
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState("");
// 获取标题列表 // 获取标题列表
const { data: titles = [], isLoading } = useQuery({ const { data: titles = [], isLoading } = useQuery({
queryKey: ['titles'], queryKey: ["titles"],
queryFn: getTitles, queryFn: getTitles,
}); });
@@ -57,54 +57,70 @@ const TitleLibrary: React.FC = () => {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createTitle, mutationFn: createTitle,
onSuccess: () => { onSuccess: () => {
message.success('标题创建成功'); message.success("标题创建成功");
setModalOpen(false); setModalOpen(false);
resetForm(); resetForm();
queryClient.invalidateQueries({ queryKey: ['titles'] }); queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('创建失败') },
}); });
// 更新标题 // 更新标题
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<{ content: string; category: string }> }) => mutationFn: ({
updateTitle(id, data), id,
data,
}: {
id: string;
data: Partial<{ content: string; category: string }>;
}) => updateTitle(id, data),
onSuccess: () => { onSuccess: () => {
message.success('标题更新成功'); message.success("标题更新成功");
setModalOpen(false); setModalOpen(false);
resetForm(); resetForm();
queryClient.invalidateQueries({ queryKey: ['titles'] }); queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("更新失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('更新失败') },
}); });
// 删除标题 // 删除标题
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteTitle, mutationFn: deleteTitle,
onSuccess: () => { onSuccess: () => {
message.success('已删除'); message.success("已删除");
queryClient.invalidateQueries({ queryKey: ['titles'] }); queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败') },
}); });
// 批量导入 // 批量导入
const importMutation = useMutation({ const importMutation = useMutation({
mutationFn: (titles: string[]) => mutationFn: (titles: string[]) => batchImportTitles(titles),
batchImportTitles(titles),
onSuccess: () => { onSuccess: () => {
message.success('批量导入成功'); message.success("批量导入成功");
setImportModalOpen(false); setImportModalOpen(false);
setImportText(''); setImportText("");
queryClient.invalidateQueries({ queryKey: ['titles'] }); queryClient.invalidateQueries({ queryKey: ["titles"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("导入失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('导入失败') },
}); });
const resetForm = () => { const resetForm = () => {
setEditingTitle(null); setEditingTitle(null);
setFormContent(''); setFormContent("");
setFormCategory(''); setFormCategory("");
}; };
const openCreate = () => { const openCreate = () => {
@@ -115,13 +131,13 @@ const TitleLibrary: React.FC = () => {
const openEdit = (title: TitleItem) => { const openEdit = (title: TitleItem) => {
setEditingTitle(title); setEditingTitle(title);
setFormContent(title.content); setFormContent(title.content);
setFormCategory(title.category || ''); setFormCategory(title.category || "");
setModalOpen(true); setModalOpen(true);
}; };
const handleSave = () => { const handleSave = () => {
if (!formContent.trim()) { if (!formContent.trim()) {
message.warning('请输入标题内容'); message.warning("请输入标题内容");
return; return;
} }
if (editingTitle) { if (editingTitle) {
@@ -137,35 +153,34 @@ const TitleLibrary: React.FC = () => {
/** 过滤后的标题 */ /** 过滤后的标题 */
const filteredTitles = titles.filter( const filteredTitles = titles.filter(
(t) => (t) =>
!searchText || !searchText || t.content.toLowerCase().includes(searchText.toLowerCase()),
t.content.toLowerCase().includes(searchText.toLowerCase())
); );
const columns: ColumnsType<TitleItem> = [ const columns: ColumnsType<TitleItem> = [
{ {
title: '标题内容', title: "标题内容",
dataIndex: 'content', dataIndex: "content",
key: 'content', key: "content",
ellipsis: true, ellipsis: true,
}, },
{ {
title: '分类', title: "分类",
dataIndex: 'category', dataIndex: "category",
key: 'category', key: "category",
width: 120, width: 120,
render: (cat: string) => render: (cat: string) =>
cat ? <Tag>{cat}</Tag> : <Tag color="default"></Tag>, cat ? <Tag>{cat}</Tag> : <Tag color="default"></Tag>,
}, },
{ {
title: '字数', title: "字数",
dataIndex: 'word_count', dataIndex: "word_count",
key: 'word_count', key: "word_count",
width: 80, width: 80,
render: (count: number) => count ?? '-', render: (count: number) => count ?? "-",
}, },
{ {
title: '操作', title: "操作",
key: 'actions', key: "actions",
width: 120, width: 120,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
@@ -187,14 +202,14 @@ const TitleLibrary: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
marginBottom: 24, marginBottom: 24,
flexWrap: 'wrap', flexWrap: "wrap",
gap: 12, gap: 12,
}} }}
> >
@@ -225,11 +240,11 @@ const TitleLibrary: React.FC = () => {
/> />
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : filteredTitles.length === 0 ? ( ) : filteredTitles.length === 0 ? (
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'}> <Empty description={searchText ? "未找到匹配的标题" : "暂无标题"}>
{!searchText && ( {!searchText && (
<Space> <Space>
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
@@ -257,7 +272,7 @@ const TitleLibrary: React.FC = () => {
{/* 新建/编辑弹窗 */} {/* 新建/编辑弹窗 */}
<Modal <Modal
title={editingTitle ? '编辑标题' : '新建标题'} title={editingTitle ? "编辑标题" : "新建标题"}
open={modalOpen} open={modalOpen}
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
@@ -266,7 +281,7 @@ const TitleLibrary: React.FC = () => {
onOk={handleSave} onOk={handleSave}
confirmLoading={createMutation.isPending || updateMutation.isPending} confirmLoading={createMutation.isPending || updateMutation.isPending}
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
<TextArea <TextArea
placeholder="输入标题内容" placeholder="输入标题内容"
value={formContent} value={formContent}
@@ -288,11 +303,11 @@ const TitleLibrary: React.FC = () => {
onCancel={() => setImportModalOpen(false)} onCancel={() => setImportModalOpen(false)}
onOk={() => { onOk={() => {
const lines = importText const lines = importText
.split('\n') .split("\n")
.map((l) => l.trim()) .map((l) => l.trim())
.filter(Boolean); .filter(Boolean);
if (lines.length === 0) { if (lines.length === 0) {
message.warning('请输入至少一条标题'); message.warning("请输入至少一条标题");
return; return;
} }
importMutation.mutate(lines); importMutation.mutate(lines);
+96 -83
View File
@@ -2,8 +2,8 @@
* 配音库页面 * 配音库页面
* 管理用户配音,支持手动创建和 AI 生成 * 管理用户配音,支持手动创建和 AI 生成
*/ */
import React, { useState } from 'react'; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Button, Button,
Typography, Typography,
@@ -18,13 +18,13 @@ import {
Popconfirm, Popconfirm,
message, message,
Slider, Slider,
} from 'antd'; } from "antd";
import { import {
PlusOutlined, PlusOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
RobotOutlined, RobotOutlined,
} from '@ant-design/icons'; } from "@ant-design/icons";
import { import {
getVoices, getVoices,
createVoice, createVoice,
@@ -32,8 +32,8 @@ import {
deleteVoice, deleteVoice,
generateAIVoice, generateAIVoice,
type VoiceItem, type VoiceItem,
} from '@/api/voices'; } from "@/api/voices";
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from "antd/es/table";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -41,16 +41,12 @@ const { Title, Text } = Typography;
const StatusTag: React.FC<{ status?: string }> = ({ status }) => { const StatusTag: React.FC<{ status?: string }> = ({ status }) => {
if (!status) return null; if (!status) return null;
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
completed: 'success', completed: "success",
processing: 'processing', processing: "processing",
failed: 'error', failed: "error",
pending: 'default', pending: "default",
}; };
return ( return <Tag color={colorMap[status] || "default"}>{status}</Tag>;
<Tag color={colorMap[status] || 'default'}>
{status}
</Tag>
);
}; };
const VoiceLibrary: React.FC = () => { const VoiceLibrary: React.FC = () => {
@@ -58,16 +54,16 @@ const VoiceLibrary: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [aiModalOpen, setAiModalOpen] = useState(false); const [aiModalOpen, setAiModalOpen] = useState(false);
const [editingVoice, setEditingVoice] = useState<VoiceItem | null>(null); const [editingVoice, setEditingVoice] = useState<VoiceItem | null>(null);
const [formName, setFormName] = useState(''); const [formName, setFormName] = useState("");
const [formText, setFormText] = useState(''); const [formText, setFormText] = useState("");
const [formVoiceType, setFormVoiceType] = useState(''); const [formVoiceType, setFormVoiceType] = useState("");
const [aiText, setAiText] = useState(''); const [aiText, setAiText] = useState("");
const [aiVoiceType, setAiVoiceType] = useState(''); const [aiVoiceType, setAiVoiceType] = useState("");
const [aiSpeed, setAiSpeed] = useState(1.0); const [aiSpeed, setAiSpeed] = useState(1.0);
// 获取配音列表 // 获取配音列表
const { data: voices = [], isLoading } = useQuery({ const { data: voices = [], isLoading } = useQuery({
queryKey: ['voices'], queryKey: ["voices"],
queryFn: getVoices, queryFn: getVoices,
}); });
@@ -75,54 +71,71 @@ const VoiceLibrary: React.FC = () => {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createVoice, mutationFn: createVoice,
onSuccess: () => { onSuccess: () => {
message.success('配音创建成功'); message.success("配音创建成功");
setModalOpen(false); setModalOpen(false);
resetForm(); resetForm();
queryClient.invalidateQueries({ queryKey: ['voices'] }); queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("创建失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('创建失败') },
}); });
// 更新配音 // 更新配音
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<{ name: string; text: string; voice_type: string }> }) => mutationFn: ({
updateVoice(id, data), id,
data,
}: {
id: string;
data: Partial<{ name: string; text: string; voice_type: string }>;
}) => updateVoice(id, data),
onSuccess: () => { onSuccess: () => {
message.success('配音更新成功'); message.success("配音更新成功");
setModalOpen(false); setModalOpen(false);
resetForm(); resetForm();
queryClient.invalidateQueries({ queryKey: ['voices'] }); queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("更新失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('更新失败') },
}); });
// 删除配音 // 删除配音
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteVoice, mutationFn: deleteVoice,
onSuccess: () => { onSuccess: () => {
message.success('已删除'); message.success("已删除");
queryClient.invalidateQueries({ queryKey: ['voices'] }); queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("删除失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('删除失败') },
}); });
// AI 生成配音 // AI 生成配音
const aiMutation = useMutation({ const aiMutation = useMutation({
mutationFn: generateAIVoice, mutationFn: generateAIVoice,
onSuccess: () => { onSuccess: () => {
message.success('AI 配音生成成功'); message.success("AI 配音生成成功");
setAiModalOpen(false); setAiModalOpen(false);
setAiText(''); setAiText("");
queryClient.invalidateQueries({ queryKey: ['voices'] }); queryClient.invalidateQueries({ queryKey: ["voices"] });
},
onError: (err: unknown) => {
if (!(err as { __msgShown?: boolean })?.__msgShown)
message.error("AI 生成失败");
}, },
onError: (err: unknown) => { if (!(err as { __msgShown?: boolean })?.__msgShown) message.error('AI 生成失败') },
}); });
const resetForm = () => { const resetForm = () => {
setEditingVoice(null); setEditingVoice(null);
setFormName(''); setFormName("");
setFormText(''); setFormText("");
setFormVoiceType(''); setFormVoiceType("");
}; };
const openCreate = () => { const openCreate = () => {
@@ -134,13 +147,13 @@ const VoiceLibrary: React.FC = () => {
setEditingVoice(voice); setEditingVoice(voice);
setFormName(voice.name); setFormName(voice.name);
setFormText(voice.text); setFormText(voice.text);
setFormVoiceType(voice.voice_type || ''); setFormVoiceType(voice.voice_type || "");
setModalOpen(true); setModalOpen(true);
}; };
const handleSave = () => { const handleSave = () => {
if (!formName.trim() || !formText.trim()) { if (!formName.trim() || !formText.trim()) {
message.warning('请填写名称和文本'); message.warning("请填写名称和文本");
return; return;
} }
if (editingVoice) { if (editingVoice) {
@@ -159,42 +172,42 @@ const VoiceLibrary: React.FC = () => {
const columns: ColumnsType<VoiceItem> = [ const columns: ColumnsType<VoiceItem> = [
{ {
title: '名称', title: "名称",
dataIndex: 'name', dataIndex: "name",
key: 'name', key: "name",
width: 150, width: 150,
ellipsis: true, ellipsis: true,
}, },
{ {
title: '文本', title: "文本",
dataIndex: 'text', dataIndex: "text",
key: 'text', key: "text",
ellipsis: true, ellipsis: true,
}, },
{ {
title: '类型', title: "类型",
dataIndex: 'voice_type', dataIndex: "voice_type",
key: 'voice_type', key: "voice_type",
width: 100, width: 100,
render: (type: string) => type || '-', render: (type: string) => type || "-",
}, },
{ {
title: '时长', title: "时长",
dataIndex: 'duration_seconds', dataIndex: "duration_seconds",
key: 'duration_seconds', key: "duration_seconds",
width: 80, width: 80,
render: (d: number) => (d ? `${d.toFixed(1)}s` : '-'), render: (d: number) => (d ? `${d.toFixed(1)}s` : "-"),
}, },
{ {
title: '状态', title: "状态",
dataIndex: 'status', dataIndex: "status",
key: 'status', key: "status",
width: 80, width: 80,
render: (status: string) => <StatusTag status={status} />, render: (status: string) => <StatusTag status={status} />,
}, },
{ {
title: '操作', title: "操作",
key: 'actions', key: "actions",
width: 120, width: 120,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
@@ -216,14 +229,14 @@ const VoiceLibrary: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: "24px", maxWidth: 1200, margin: "0 auto" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
justifyContent: 'space-between', justifyContent: "space-between",
alignItems: 'center', alignItems: "center",
marginBottom: 24, marginBottom: 24,
flexWrap: 'wrap', flexWrap: "wrap",
gap: 12, gap: 12,
}} }}
> >
@@ -231,10 +244,7 @@ const VoiceLibrary: React.FC = () => {
</Title> </Title>
<Space wrap> <Space wrap>
<Button <Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
icon={<RobotOutlined />}
onClick={() => setAiModalOpen(true)}
>
AI AI
</Button> </Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
@@ -244,7 +254,7 @@ const VoiceLibrary: React.FC = () => {
</div> </div>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: "center", padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : voices.length === 0 ? ( ) : voices.length === 0 ? (
@@ -253,7 +263,10 @@ const VoiceLibrary: React.FC = () => {
<Button type="primary" onClick={openCreate}> <Button type="primary" onClick={openCreate}>
</Button> </Button>
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}> <Button
icon={<RobotOutlined />}
onClick={() => setAiModalOpen(true)}
>
AI AI
</Button> </Button>
</Space> </Space>
@@ -271,7 +284,7 @@ const VoiceLibrary: React.FC = () => {
{/* 新建/编辑弹窗 */} {/* 新建/编辑弹窗 */}
<Modal <Modal
title={editingVoice ? '编辑配音' : '新建配音'} title={editingVoice ? "编辑配音" : "新建配音"}
open={modalOpen} open={modalOpen}
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
@@ -280,7 +293,7 @@ const VoiceLibrary: React.FC = () => {
onOk={handleSave} onOk={handleSave}
confirmLoading={createMutation.isPending || updateMutation.isPending} confirmLoading={createMutation.isPending || updateMutation.isPending}
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
<Input <Input
placeholder="配音名称" placeholder="配音名称"
value={formName} value={formName}
@@ -297,11 +310,11 @@ const VoiceLibrary: React.FC = () => {
value={formVoiceType || undefined} value={formVoiceType || undefined}
onChange={setFormVoiceType} onChange={setFormVoiceType}
allowClear allowClear
style={{ width: '100%' }} style={{ width: "100%" }}
options={[ options={[
{ value: 'male', label: '男声' }, { value: "male", label: "男声" },
{ value: 'female', label: '女声' }, { value: "female", label: "女声" },
{ value: 'child', label: '童声' }, { value: "child", label: "童声" },
]} ]}
/> />
</Space> </Space>
@@ -314,7 +327,7 @@ const VoiceLibrary: React.FC = () => {
onCancel={() => setAiModalOpen(false)} onCancel={() => setAiModalOpen(false)}
onOk={() => { onOk={() => {
if (!aiText.trim()) { if (!aiText.trim()) {
message.warning('请输入配音文本'); message.warning("请输入配音文本");
return; return;
} }
aiMutation.mutate({ aiMutation.mutate({
@@ -325,7 +338,7 @@ const VoiceLibrary: React.FC = () => {
}} }}
confirmLoading={aiMutation.isPending} confirmLoading={aiMutation.isPending}
> >
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: "100%" }}>
<Input.TextArea <Input.TextArea
placeholder="输入需要配音的文本" placeholder="输入需要配音的文本"
value={aiText} value={aiText}
@@ -337,11 +350,11 @@ const VoiceLibrary: React.FC = () => {
value={aiVoiceType || undefined} value={aiVoiceType || undefined}
onChange={setAiVoiceType} onChange={setAiVoiceType}
allowClear allowClear
style={{ width: '100%' }} style={{ width: "100%" }}
options={[ options={[
{ value: 'male', label: '男声' }, { value: "male", label: "男声" },
{ value: 'female', label: '女声' }, { value: "female", label: "女声" },
{ value: 'child', label: '童声' }, { value: "child", label: "童声" },
]} ]}
/> />
<div> <div>
+125 -59
View File
@@ -2,20 +2,20 @@
* Phase 1 路由重构 * Phase 1 路由重构
* 扁平化路由:去掉 Project 层级,所有资源直接归属用户 * 扁平化路由:去掉 Project 层级,所有资源直接归属用户
*/ */
import { createBrowserRouter, Navigate } from 'react-router-dom'; import { createBrowserRouter, Navigate } from "react-router-dom";
import React from 'react'; import React from "react";
import MainLayout from '@/components/layout/MainLayout'; import MainLayout from "@/components/layout/MainLayout";
import Login from '@/pages/auth/Login'; import Login from "@/pages/auth/Login";
import Register from '@/pages/auth/Register'; import Register from "@/pages/auth/Register";
import ForgotPassword from '@/pages/auth/ForgotPassword'; import ForgotPassword from "@/pages/auth/ForgotPassword";
import ResetPassword from '@/pages/auth/ResetPassword'; import ResetPassword from "@/pages/auth/ResetPassword";
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from "@/store/authStore";
/** 受保护的路由组件 */ /** 受保护的路由组件 */
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const hasAccessToken = Boolean(localStorage.getItem('access_token')); const hasAccessToken = Boolean(localStorage.getItem("access_token"));
if (!isAuthenticated || !hasAccessToken) { if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
@@ -27,23 +27,23 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
/** 路由配置 */ /** 路由配置 */
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: '/login', path: "/login",
element: <Login />, element: <Login />,
}, },
{ {
path: '/register', path: "/register",
element: <Register />, element: <Register />,
}, },
{ {
path: '/forgot-password', path: "/forgot-password",
element: <ForgotPassword />, element: <ForgotPassword />,
}, },
{ {
path: '/reset-password', path: "/reset-password",
element: <ResetPassword />, element: <ResetPassword />,
}, },
{ {
path: '/', path: "/",
element: ( element: (
<ProtectedRoute> <ProtectedRoute>
<MainLayout /> <MainLayout />
@@ -55,102 +55,168 @@ export const router = createBrowserRouter([
element: <Navigate to="/dashboard" replace />, element: <Navigate to="/dashboard" replace />,
}, },
{ {
path: 'dashboard', path: "dashboard",
lazy: () => import('@/pages/dashboard/Dashboard').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/dashboard/Dashboard").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'assets', path: "assets",
lazy: () => import('@/pages/assets/AssetLibrary').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/assets/AssetLibrary").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'titles', path: "titles",
lazy: () => import('@/pages/titles/TitleLibrary').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/titles/TitleLibrary").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'voices', path: "voices",
lazy: () => import('@/pages/voices/VoiceLibrary').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/voices/VoiceLibrary").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'templates', path: "templates",
lazy: () => import('@/pages/templates/TemplateLibrary').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/templates/TemplateLibrary").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'generate', path: "generate",
lazy: () => import('@/pages/generate/GeneratePage').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/generate/GeneratePage").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'history', path: "history",
lazy: () => import('@/pages/history/TaskHistory').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/history/TaskHistory").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'products', path: "products",
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/products/ProductLibrary").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'editing-planner', path: "editing-planner",
lazy: () => import('@/pages/editing-planner/EditingPlanner').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'my-templates', path: "my-templates",
lazy: () => import('@/pages/my-templates/MyTemplates').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/my-templates/MyTemplates").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'duplication', path: "duplication",
lazy: () => import('@/pages/duplication/DuplicationUpload').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/duplication/DuplicationUpload").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'duplication/results', path: "duplication/results",
lazy: () => import('@/pages/duplication/DuplicationResults').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/duplication/DuplicationResults").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'duplication/:id', path: "duplication/:id",
lazy: () => import('@/pages/duplication/DuplicationDetail').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/duplication/DuplicationDetail").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'subscription', path: "subscription",
lazy: () => import('@/pages/subscription/Plans').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/subscription/Plans").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'subscription/upgrade', path: "subscription/upgrade",
lazy: () => import('@/pages/subscription/UpgradeSubscription').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'subscription/billing', path: "subscription/billing",
lazy: () => import('@/pages/subscription/Billing').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/subscription/Billing").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'profile', path: "profile",
lazy: () => import('@/pages/profile/Settings').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/profile/Settings").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'admin', path: "admin",
children: [ children: [
{ {
index: true, index: true,
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'users', path: "users",
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'analytics', path: "analytics",
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'monitor', path: "monitor",
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
}, },
{ {
path: 'logs', path: "logs",
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })), lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
}, },
], ],
}, },
], ],
}, },
{ {
path: '*', path: "*",
element: <Navigate to="/" replace />, element: <Navigate to="/" replace />,
}, },
]); ]);
+15 -11
View File
@@ -2,8 +2,8 @@
* 认证状态管理 * 认证状态管理
* 使用 Zustand 管理全局认证状态 * 使用 Zustand 管理全局认证状态
*/ */
import { create } from 'zustand'; import { create } from "zustand";
import { persist } from 'zustand/middleware'; import { persist } from "zustand/middleware";
interface User { interface User {
id: string; id: string;
@@ -22,7 +22,11 @@ interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
// Actions // Actions
setAuth: (user: User, accessToken: string, refreshToken?: string | null) => void; setAuth: (
user: User,
accessToken: string,
refreshToken?: string | null,
) => void;
clearAuth: () => void; clearAuth: () => void;
setUser: (user: User) => void; setUser: (user: User) => void;
@@ -37,11 +41,11 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
setAuth: (user, accessToken, refreshToken = null) => { setAuth: (user, accessToken, refreshToken = null) => {
localStorage.setItem('access_token', accessToken); localStorage.setItem("access_token", accessToken);
if (refreshToken) { if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken); localStorage.setItem("refresh_token", refreshToken);
} else { } else {
localStorage.removeItem('refresh_token'); localStorage.removeItem("refresh_token");
} }
set({ set({
user, user,
@@ -52,8 +56,8 @@ export const useAuthStore = create<AuthState>()(
}, },
clearAuth: () => { clearAuth: () => {
localStorage.removeItem('access_token'); localStorage.removeItem("access_token");
localStorage.removeItem('refresh_token'); localStorage.removeItem("refresh_token");
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
@@ -67,11 +71,11 @@ export const useAuthStore = create<AuthState>()(
}, },
}), }),
{ {
name: 'auth-storage', name: "auth-storage",
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
}), }),
} },
) ),
); );
+64 -20
View File
@@ -54,7 +54,7 @@
/* ========== 阴影 - V21 柔和大阴影 ========== */ /* ========== 阴影 - V21 柔和大阴影 ========== */
--shadow-xs: 0 4px 12px rgba(15, 23, 42, 0.04); --shadow-xs: 0 4px 12px rgba(15, 23, 42, 0.04);
--shadow-sm: 0 10px 30px rgba(15, 23, 42, 0.06); --shadow-sm: 0 10px 30px rgba(15, 23, 42, 0.06);
--shadow-md: 0 20px 58px rgba(15, 23, 42, 0.10); --shadow-md: 0 20px 58px rgba(15, 23, 42, 0.1);
--shadow-lg: 0 30px 90px rgba(15, 23, 42, 0.15); --shadow-lg: 0 30px 90px rgba(15, 23, 42, 0.15);
--shadow-hover: 0 18px 34px rgba(79, 70, 229, 0.28); --shadow-hover: 0 18px 34px rgba(79, 70, 229, 0.28);
--shadow-card: 0 24px 70px rgba(15, 23, 42, 0.09); --shadow-card: 0 24px 70px rgba(15, 23, 42, 0.09);
@@ -67,25 +67,41 @@ body {
} }
body::before { body::before {
content: ''; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(circle at 18% 20%, rgba(79, 70, 229, 0.11), transparent 30%), radial-gradient(
radial-gradient(circle at 86% 14%, rgba(16, 185, 129, 0.08), transparent 28%), circle at 18% 20%,
radial-gradient(circle at 48% 80%, rgba(99, 102, 241, 0.08), transparent 34%), rgba(79, 70, 229, 0.11),
transparent 30%
),
radial-gradient(
circle at 86% 14%,
rgba(16, 185, 129, 0.08),
transparent 28%
),
radial-gradient(
circle at 48% 80%,
rgba(99, 102, 241, 0.08),
transparent 34%
),
#f8fafc; #f8fafc;
} }
body::after { body::after {
content: ''; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background-image: radial-gradient(circle, rgba(79, 70, 229, 0.1) 1px, transparent 1px); background-image: radial-gradient(
circle,
rgba(79, 70, 229, 0.1) 1px,
transparent 1px
);
background-size: 38px 38px; background-size: 38px 38px;
opacity: 0.32; opacity: 0.32;
} }
@@ -237,17 +253,37 @@ body::after {
} }
/* ========== 工具类 ========== */ /* ========== 工具类 ========== */
.text-center { text-align: center; } .text-center {
.text-right { text-align: right; } text-align: center;
.text-primary { color: var(--primary-color); } }
.text-muted { color: var(--text-secondary); } .text-right {
text-align: right;
}
.text-primary {
color: var(--primary-color);
}
.text-muted {
color: var(--text-secondary);
}
.mt-sm { margin-top: var(--space-sm); } .mt-sm {
.mt-md { margin-top: var(--space-md); } margin-top: var(--space-sm);
.mt-lg { margin-top: var(--space-lg); } }
.mb-sm { margin-bottom: var(--space-sm); } .mt-md {
.mb-md { margin-bottom: var(--space-md); } margin-top: var(--space-md);
.mb-lg { margin-bottom: var(--space-lg); } }
.mt-lg {
margin-top: var(--space-lg);
}
.mb-sm {
margin-bottom: var(--space-sm);
}
.mb-md {
margin-bottom: var(--space-md);
}
.mb-lg {
margin-bottom: var(--space-lg);
}
/* ========== 卡片悬浮效果 ========== */ /* ========== 卡片悬浮效果 ========== */
.card-hover { .card-hover {
@@ -287,10 +323,18 @@ body::after {
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.xx-grid-4 { grid-template-columns: repeat(3, 1fr); } .xx-grid-4 {
.xx-grid-3 { grid-template-columns: repeat(2, 1fr); } grid-template-columns: repeat(3, 1fr);
}
.xx-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.xx-grid-4, .xx-grid-3, .xx-grid-2 { grid-template-columns: 1fr; } .xx-grid-4,
.xx-grid-3,
.xx-grid-2 {
grid-template-columns: 1fr;
}
} }
+17 -17
View File
@@ -1,11 +1,11 @@
/** /**
* Login 组件单元测试 * Login 组件单元测试
*/ */
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Login from '@/pages/auth/Login'; import Login from "@/pages/auth/Login";
const renderLogin = () => { const renderLogin = () => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -20,35 +20,35 @@ const renderLogin = () => {
<BrowserRouter> <BrowserRouter>
<Login /> <Login />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>,
); );
}; };
describe('Login Component', () => { describe("Login Component", () => {
it('should render login form', () => { it("should render login form", () => {
renderLogin(); renderLogin();
expect(screen.getByPlaceholderText('邮箱')).toBeInTheDocument(); expect(screen.getByPlaceholderText("邮箱")).toBeInTheDocument();
expect(screen.getByPlaceholderText('密码')).toBeInTheDocument(); expect(screen.getByPlaceholderText("密码")).toBeInTheDocument();
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "登录" })).toBeInTheDocument();
}); });
it('should show validation errors for empty fields', async () => { it("should show validation errors for empty fields", async () => {
renderLogin(); renderLogin();
const submitButton = screen.getByRole('button', { name: '登录' }); const submitButton = screen.getByRole("button", { name: "登录" });
fireEvent.click(submitButton); fireEvent.click(submitButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('请输入邮箱')).toBeInTheDocument(); expect(screen.getByText("请输入邮箱")).toBeInTheDocument();
expect(screen.getByText('请输入密码')).toBeInTheDocument(); expect(screen.getByText("请输入密码")).toBeInTheDocument();
}); });
}); });
it('should navigate to register page', () => { it("should navigate to register page", () => {
renderLogin(); renderLogin();
const registerLink = screen.getByText('立即注册'); const registerLink = screen.getByText("立即注册");
expect(registerLink).toBeInTheDocument(); expect(registerLink).toBeInTheDocument();
}); });
}); });
@@ -1,14 +1,14 @@
/** /**
* WorkspaceList 组件单元测试 * WorkspaceList 组件单元测试
*/ */
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from "vitest";
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import WorkspaceList from '@/pages/workspace/WorkspaceList'; import WorkspaceList from "@/pages/workspace/WorkspaceList";
import * as workspaceApi from '@/api/workspace'; import * as workspaceApi from "@/api/workspace";
vi.mock('@/api/workspace'); vi.mock("@/api/workspace");
const renderWorkspaceList = () => { const renderWorkspaceList = () => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -22,24 +22,24 @@ const renderWorkspaceList = () => {
<BrowserRouter> <BrowserRouter>
<WorkspaceList /> <WorkspaceList />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>,
); );
}; };
describe('WorkspaceList', () => { describe("WorkspaceList", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render workspace list', async () => { it("should render workspace list", async () => {
const mockWorkspaces: workspaceApi.Workspace[] = [ const mockWorkspaces: workspaceApi.Workspace[] = [
{ {
id: '1', id: "1",
name: 'Test Workspace', name: "Test Workspace",
owner_user_id: 'user-1', owner_user_id: "user-1",
subscription_plan: 'free', subscription_plan: "free",
subscription_status: 'active', subscription_status: "active",
created_at: '2024-01-01', created_at: "2024-01-01",
}, },
]; ];
@@ -48,17 +48,17 @@ describe('WorkspaceList', () => {
renderWorkspaceList(); renderWorkspaceList();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Test Workspace')).toBeInTheDocument(); expect(screen.getByText("Test Workspace")).toBeInTheDocument();
}); });
}); });
it('should show create workspace button', async () => { it("should show create workspace button", async () => {
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue([]); vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue([]);
renderWorkspaceList(); renderWorkspaceList();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('创建工作空间')).toBeInTheDocument(); expect(screen.getByText("创建工作空间")).toBeInTheDocument();
}); });
}); });
}); });
+40 -34
View File
@@ -1,18 +1,18 @@
/** /**
* useAuth Hook 单元测试 * useAuth Hook 单元测试
*/ */
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from "vitest";
import { useLogin, useRegister, useLogout } from '@/hooks/useAuth'; import { useLogin, useRegister, useLogout } from "@/hooks/useAuth";
import * as authApi from '@/api/auth'; import * as authApi from "@/api/auth";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from "react-router-dom";
import React from 'react'; import React from "react";
// Mock API // Mock API
vi.mock('@/api/auth'); vi.mock("@/api/auth");
vi.mock('react-router-dom', async () => { vi.mock("react-router-dom", async () => {
const actual = await vi.importActual('react-router-dom'); const actual = await vi.importActual("react-router-dom");
return { return {
...actual, ...actual,
useNavigate: () => vi.fn(), useNavigate: () => vi.fn(),
@@ -34,32 +34,34 @@ const createWrapper = () => {
); );
}; };
describe('useAuth', () => { describe("useAuth", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
}); });
it('should login successfully', async () => { it("should login successfully", async () => {
const mockResponse = { const mockResponse = {
access_token: 'mock-token', access_token: "mock-token",
refresh_token: 'refresh-token', refresh_token: "refresh-token",
token_type: 'bearer', token_type: "bearer",
user_id: '1', user_id: "1",
email: 'test@example.com', email: "test@example.com",
username: 'testuser', username: "testuser",
display_name: 'Test User', display_name: "Test User",
}; };
vi.mocked(authApi.login).mockResolvedValue(mockResponse); vi.mocked(authApi.login).mockResolvedValue(mockResponse);
vi.mocked(authApi.getCurrentUser).mockResolvedValue({ vi.mocked(authApi.getCurrentUser).mockResolvedValue({
id: '1', id: "1",
email: 'test@example.com', email: "test@example.com",
username: 'testuser', username: "testuser",
display_name: 'Test User', display_name: "Test User",
}); });
const { result } = renderHook(() => useLogin(), { wrapper: createWrapper() }); const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
await waitFor(() => { await waitFor(() => {
expect(result.current).toBeDefined(); expect(result.current).toBeDefined();
@@ -68,18 +70,20 @@ describe('useAuth', () => {
expect(authApi.login).toBeDefined(); expect(authApi.login).toBeDefined();
}); });
it('should register successfully', async () => { it("should register successfully", async () => {
const mockResponse = { const mockResponse = {
user_id: '1', user_id: "1",
email: 'test@example.com', email: "test@example.com",
username: 'testuser', username: "testuser",
display_name: 'Test User', display_name: "Test User",
message: '注册成功', message: "注册成功",
}; };
vi.mocked(authApi.register).mockResolvedValue(mockResponse); vi.mocked(authApi.register).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useRegister(), { wrapper: createWrapper() }); const { result } = renderHook(() => useRegister(), {
wrapper: createWrapper(),
});
await waitFor(() => { await waitFor(() => {
expect(result.current).toBeDefined(); expect(result.current).toBeDefined();
@@ -88,12 +92,14 @@ describe('useAuth', () => {
expect(authApi.register).toBeDefined(); expect(authApi.register).toBeDefined();
}); });
it('should logout successfully', async () => { it("should logout successfully", async () => {
localStorage.setItem('access_token', 'mock-token'); localStorage.setItem("access_token", "mock-token");
vi.mocked(authApi.logout).mockResolvedValue(undefined); vi.mocked(authApi.logout).mockResolvedValue(undefined);
const { result } = renderHook(() => useLogout(), { wrapper: createWrapper() }); const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(),
});
await waitFor(() => { await waitFor(() => {
expect(result.current).toBeDefined(); expect(result.current).toBeDefined();
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* 测试环境设置 * 测试环境设置
*/ */
import { expect, afterEach } from 'vitest'; import { expect, afterEach } from "vitest";
import { cleanup } from '@testing-library/react'; import { cleanup } from "@testing-library/react";
import * as matchers from '@testing-library/jest-dom/matchers'; import * as matchers from "@testing-library/jest-dom/matchers";
// 扩展 Vitest 的 expect // 扩展 Vitest 的 expect
expect.extend(matchers); expect.extend(matchers);
@@ -18,6 +18,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
import numpy as np import numpy as np
from PIL import Image
from packages.domain.classification import AssetClassification from packages.domain.classification import AssetClassification
+1 -1
View File
@@ -92,7 +92,7 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning(f"Timeout extracting metadata from {file_url}") logger.warning(f"Timeout extracting metadata from {file_url}")
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"ffprobe not found, cannot extract video metadata") logger.warning("ffprobe not found, cannot extract video metadata")
except Exception as e: except Exception as e:
logger.warning(f"Failed to extract metadata: {e}") logger.warning(f"Failed to extract metadata: {e}")
@@ -27,7 +27,7 @@ class SQLAlchemyRecipeRepository:
self.session.query(RecipeModel) self.session.query(RecipeModel)
.filter( .filter(
RecipeModel.user_id == user_id, RecipeModel.user_id == user_id,
RecipeModel.is_active == True, RecipeModel.is_active.is_(True),
) )
.order_by(RecipeModel.created_at.desc()) .order_by(RecipeModel.created_at.desc())
.offset(skip) .offset(skip)
@@ -33,7 +33,7 @@ class SQLAlchemyTemplateRepository:
self.session.query(TemplateModel) self.session.query(TemplateModel)
.filter( .filter(
TemplateModel.user_id == user_id, TemplateModel.user_id == user_id,
TemplateModel.is_active == True, TemplateModel.is_active.is_(True),
) )
.order_by(TemplateModel.created_at.desc()) .order_by(TemplateModel.created_at.desc())
.offset(skip) .offset(skip)
@@ -147,7 +147,7 @@ class SQLAlchemyTemplateRepository:
self.session.query(TemplateModel) self.session.query(TemplateModel)
.filter( .filter(
TemplateModel.user_id == user_id, TemplateModel.user_id == user_id,
TemplateModel.is_active == True, TemplateModel.is_active.is_(True),
) )
.count() .count()
) )
-1
View File
@@ -3,7 +3,6 @@
from .config import SharedSettings, get_shared_settings from .config import SharedSettings, get_shared_settings
from .storage import ( from .storage import (
SharedStorageService, SharedStorageService,
get_shared_settings,
get_shared_storage_service, get_shared_storage_service,
get_storage_service, get_storage_service,
) )
+4 -4
View File
@@ -26,11 +26,11 @@ def test_create_and_list_projects():
create_use_case = CreateProjectUseCase(repository) create_use_case = CreateProjectUseCase(repository)
list_use_case = ListProjectsUseCase(repository) list_use_case = ListProjectsUseCase(repository)
assert project.name == "Demo Project" assert project.name == "Demo Project" # noqa: F821
items = list_use_case.execute("ws-1") items = list_use_case.execute("ws-1")
assert len(items) == 1 assert len(items) == 1
assert items[0].id == project.id assert items[0].id == project.id # noqa: F821
def test_get_project_by_id_restores_workspace_context(): def test_get_project_by_id_restores_workspace_context():
@@ -38,9 +38,9 @@ def test_get_project_by_id_restores_workspace_context():
create_use_case = CreateProjectUseCase(repository) create_use_case = CreateProjectUseCase(repository)
get_use_case = GetProjectUseCase(repository) get_use_case = GetProjectUseCase(repository)
retrieved = get_use_case.execute(project.id) retrieved = get_use_case.execute(project.id) # noqa: F821
assert retrieved is not None assert retrieved is not None
assert retrieved.id == project.id assert retrieved.id == project.id # noqa: F821
def test_create_and_list_asset_libraries(): def test_create_and_list_asset_libraries():
+1 -1
View File
@@ -83,7 +83,7 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials():
def test_workspace_dependency_allows_member_access(): def test_workspace_dependency_allows_member_access():
repo = _WorkspaceMemberRepositoryStub(role="member") repo = _WorkspaceMemberRepositoryStub(role="member")
assert role == "member" assert role == "member" # noqa: F821
def test_workspace_dependency_rejects_non_member(): def test_workspace_dependency_rejects_non_member():
+12 -12
View File
@@ -23,11 +23,11 @@ class TestJWTService:
def test_create_access_token(self, jwt_service): def test_create_access_token(self, jwt_service):
"""测试创建 access_token""" """测试创建 access_token"""
assert isinstance(token, str) assert isinstance(token, str) # noqa: F821
assert len(token) > 0 assert len(token) > 0 # noqa: F821
# 验证 Token 内容 # 验证 Token 内容
payload = jwt_service.verify_access_token(token) payload = jwt_service.verify_access_token(token) # noqa: F821
assert payload["sub"] == "user-123" assert payload["sub"] == "user-123"
assert payload["role"] == "admin" assert payload["role"] == "admin"
assert payload["type"] == TokenType.ACCESS assert payload["type"] == TokenType.ACCESS
@@ -36,11 +36,11 @@ class TestJWTService:
"""测试创建 refresh_token""" """测试创建 refresh_token"""
token = jwt_service.create_refresh_token(user_id="user-123", session_id="session-789") token = jwt_service.create_refresh_token(user_id="user-123", session_id="session-789")
assert isinstance(token, str) assert isinstance(token, str) # noqa: F821
assert len(token) > 0 assert len(token) > 0 # noqa: F821
# 验证 Token 内容 # 验证 Token 内容
payload = jwt_service.verify_refresh_token(token) payload = jwt_service.verify_refresh_token(token) # noqa: F821
assert payload["sub"] == "user-123" assert payload["sub"] == "user-123"
assert payload["session_id"] == "session-789" assert payload["session_id"] == "session-789"
assert payload["type"] == TokenType.REFRESH assert payload["type"] == TokenType.REFRESH
@@ -48,7 +48,7 @@ class TestJWTService:
def test_verify_valid_access_token(self, jwt_service): def test_verify_valid_access_token(self, jwt_service):
"""测试验证有效的 access_token""" """测试验证有效的 access_token"""
payload = jwt_service.verify_access_token(token) payload = jwt_service.verify_access_token(token) # noqa: F821
assert payload["sub"] == "user-123" assert payload["sub"] == "user-123"
assert payload["role"] == "member" assert payload["role"] == "member"
@@ -63,7 +63,7 @@ class TestJWTService:
# 验证应该抛出过期异常 # 验证应该抛出过期异常
with pytest.raises(ExpiredSignatureError): with pytest.raises(ExpiredSignatureError):
jwt_service.verify_access_token(token) jwt_service.verify_access_token(token) # noqa: F821
def test_verify_invalid_token(self, jwt_service): def test_verify_invalid_token(self, jwt_service):
"""测试验证无效的 Token""" """测试验证无效的 Token"""
@@ -84,13 +84,13 @@ class TestJWTService:
# 反过来也一样 # 反过来也一样
with pytest.raises(ValueError, match="Token type must be 'refresh'"): with pytest.raises(ValueError, match="Token type must be 'refresh'"):
jwt_service.verify_refresh_token(access_token) jwt_service.verify_refresh_token(access_token) # noqa: F821
def test_verify_tampered_token(self, jwt_service): def test_verify_tampered_token(self, jwt_service):
"""测试验证被篡改的 Token""" """测试验证被篡改的 Token"""
# 篡改 Token(修改最后几个字符) # 篡改 Token(修改最后几个字符)
tampered_token = token[:-5] + "XXXXX" tampered_token = token[:-5] + "XXXXX" # noqa: F821
with pytest.raises(InvalidTokenError): with pytest.raises(InvalidTokenError):
jwt_service.verify_access_token(tampered_token) jwt_service.verify_access_token(tampered_token)
@@ -106,7 +106,7 @@ class TestJWTService:
}, },
) )
payload = jwt_service.verify_access_token(token) payload = jwt_service.verify_access_token(token) # noqa: F821
assert payload["email"] == "user@example.com" assert payload["email"] == "user@example.com"
assert payload["display_name"] == "Test User" assert payload["display_name"] == "Test User"
@@ -114,7 +114,7 @@ class TestJWTService:
"""测试不安全解码(不验证签名)""" """测试不安全解码(不验证签名)"""
# 不验证签名地解码 # 不验证签名地解码
payload = jwt_service.decode_token_unsafe(token) payload = jwt_service.decode_token_unsafe(token) # noqa: F821
assert payload is not None assert payload is not None
assert payload["sub"] == "user-123" assert payload["sub"] == "user-123"