fix: resolve all flake8 errors and apply Prettier formatting (#131)
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 210h26m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 210h27m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 210h27m7s

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