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
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: "系统自动创建的默认项目",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 || [];
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
返回工作空间
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 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 关联后端分类 API(P1-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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 />,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user