style: fix prettier formatting issues
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 192h26m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 192h27m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 192h27m46s
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 192h26m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 192h27m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 192h27m46s
This commit is contained in:
@@ -116,7 +116,7 @@ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 获取指定平台的账号列表 */
|
||||
export async function getAccountsByPlatform(
|
||||
platformId: PlatformId
|
||||
platformId: PlatformId,
|
||||
): Promise<Account[]> {
|
||||
await delay(300);
|
||||
return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId);
|
||||
@@ -129,9 +129,7 @@ export async function getAllAccounts(): Promise<Account[]> {
|
||||
}
|
||||
|
||||
/** 绑定新账号 */
|
||||
export async function bindAccount(
|
||||
data: BindAccountRequest
|
||||
): Promise<Account> {
|
||||
export async function bindAccount(data: BindAccountRequest): Promise<Account> {
|
||||
await delay(500);
|
||||
const newAccount: Account = {
|
||||
id: `acc-${Date.now()}`,
|
||||
|
||||
@@ -145,7 +145,8 @@
|
||||
|
||||
.xx-search-input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
box-shadow: 0 0 0 4px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ==================== Modal ==================== */
|
||||
@@ -221,7 +222,8 @@
|
||||
|
||||
.xx-quota-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
box-shadow: 0 8px 24px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ==================== 进度条 ==================== */
|
||||
@@ -331,7 +333,8 @@
|
||||
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Input overrides */
|
||||
@@ -347,7 +350,8 @@
|
||||
|
||||
.ant-input:focus {
|
||||
border-color: var(--primary-color) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Progress overrides */
|
||||
|
||||
@@ -17,8 +17,6 @@ import type { MenuProps } from "antd";
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import "./Header.css";
|
||||
|
||||
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -142,7 +142,6 @@
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -138,7 +138,10 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className="xx-page-breadcrumb-item"
|
||||
>
|
||||
{index > 0 && (
|
||||
<RightOutlined className="xx-page-breadcrumb-separator" />
|
||||
)}
|
||||
@@ -157,7 +160,10 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
<span
|
||||
className="xx-page-breadcrumb-current"
|
||||
aria-current="page"
|
||||
>
|
||||
{index === 0 ? (
|
||||
<HomeOutlined className="xx-page-breadcrumb-home" />
|
||||
) : null}
|
||||
|
||||
@@ -33,7 +33,9 @@ const Sidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}>
|
||||
<div
|
||||
className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}
|
||||
>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div className="xx-sidebar-group" key={group.title}>
|
||||
{!collapsed && (
|
||||
|
||||
@@ -124,7 +124,9 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
// Mock:模拟上传 + 克隆过程
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
audio_url: selectedFile ? `mock://${selectedFile.name}` : "mock://recorded-audio",
|
||||
audio_url: selectedFile
|
||||
? `mock://${selectedFile.name}`
|
||||
: "mock://recorded-audio",
|
||||
});
|
||||
|
||||
setStep("success");
|
||||
@@ -211,7 +213,9 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
<label className="cvm-label">直接录制</label>
|
||||
<div className="cvm-record-area">
|
||||
<p className="cvm-record-hint">
|
||||
{isRecording ? "录制中…再次点击停止" : "点击按钮开始录制你的声音"}
|
||||
{isRecording
|
||||
? "录制中…再次点击停止"
|
||||
: "点击按钮开始录制你的声音"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -226,7 +230,9 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
{/* 提示 */}
|
||||
<div className="cvm-tip">
|
||||
<span className="cvm-tip-icon">💡</span>
|
||||
<span>建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
<span>
|
||||
建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -234,7 +240,11 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" disabled={!canStart} onClick={handleStartClone}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canStart}
|
||||
onClick={handleStartClone}
|
||||
>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
@@ -255,7 +265,9 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
|
||||
<div className="cvm-success">
|
||||
<div className="cvm-success-icon">✅</div>
|
||||
<h3 className="cvm-success-title">克隆已提交</h3>
|
||||
<p className="cvm-success-desc">音色正在生成中,完成后将出现在列表中</p>
|
||||
<p className="cvm-success-desc">
|
||||
音色正在生成中,完成后将出现在列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -36,13 +36,16 @@
|
||||
color: var(--text-primary, #101828);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cvm-input:focus {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.cvm-input::placeholder {
|
||||
@@ -58,7 +61,9 @@
|
||||
text-align: center;
|
||||
background: var(--bg-subtle, #f8fafc);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.cvm-upload-zone:hover {
|
||||
@@ -135,10 +140,17 @@
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, var(--error-color, #ef4444), var(--error-dark, #dc2626));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--error-color, #ef4444),
|
||||
var(--error-dark, #dc2626)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -146,7 +158,8 @@
|
||||
|
||||
.cvm-record-btn:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent);
|
||||
}
|
||||
|
||||
.cvm-record-btn:active {
|
||||
@@ -158,8 +171,15 @@
|
||||
}
|
||||
|
||||
@keyframes cvm-pulse {
|
||||
0%, 100% { box-shadow: 0 4px 14px color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent); }
|
||||
50% { box-shadow: 0 4px 28px color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 28px
|
||||
color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 提示条 ─────────────────────────────────────────────── */
|
||||
@@ -215,7 +235,9 @@
|
||||
}
|
||||
|
||||
@keyframes cvm-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cvm-uploading-text {
|
||||
@@ -262,7 +284,6 @@
|
||||
|
||||
/* ── 响应式 ─────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cvm-overlay {
|
||||
padding: var(--space-md);
|
||||
|
||||
@@ -9,12 +9,7 @@ import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
export type ButtonType =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "text"
|
||||
| "danger"
|
||||
| "link";
|
||||
"primary" | "secondary" | "ghost" | "text" | "danger" | "link";
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
@@ -70,7 +65,9 @@ const Button: React.FC<ButtonProps> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const antdType = type ?? toAntdType(buttonType);
|
||||
const antdSize = size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
|
||||
const antdSize =
|
||||
size ??
|
||||
(buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
|
||||
|
||||
const v21Class = classNames(
|
||||
"xx-btn",
|
||||
|
||||
@@ -20,7 +20,10 @@ const Form = ({ className, compact, children, ...rest }: FormProps) => {
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<AntForm className={v21Class} {...(rest as Omit<FormProps, "className" | "compact" | "children">)}>
|
||||
<AntForm
|
||||
className={v21Class}
|
||||
{...(rest as Omit<FormProps, "className" | "compact" | "children">)}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</AntForm>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,11 @@ import type { InputRef } from "antd/es/input";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
const { Search: AntSearch, TextArea: AntTextArea, Password: AntPassword } = AntInput;
|
||||
const {
|
||||
Search: AntSearch,
|
||||
TextArea: AntTextArea,
|
||||
Password: AntPassword,
|
||||
} = AntInput;
|
||||
|
||||
export interface InputProps extends AntInputProps {
|
||||
/** 是否处于错误状态 */
|
||||
@@ -46,11 +50,7 @@ const Search: React.FC<
|
||||
const TextArea: React.FC<
|
||||
React.ComponentProps<typeof AntTextArea> & { error?: boolean }
|
||||
> = ({ className, error, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className);
|
||||
return <AntTextArea className={v21Class} {...rest} />;
|
||||
};
|
||||
|
||||
@@ -59,11 +59,7 @@ const Password = React.forwardRef<
|
||||
InputRef,
|
||||
React.ComponentProps<typeof AntPassword> & { error?: boolean }
|
||||
>(({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className);
|
||||
return <AntPassword ref={ref} className={v21Class} {...rest} />;
|
||||
});
|
||||
Password.displayName = "Password";
|
||||
|
||||
@@ -19,10 +19,7 @@ const Select: React.FC<SelectProps> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-select", className);
|
||||
const v21DropdownClass = classNames(
|
||||
"xx-select-dropdown",
|
||||
dropdownClassName,
|
||||
);
|
||||
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName);
|
||||
return (
|
||||
<AntSelect
|
||||
className={v21Class}
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { TableProps as AntTableProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
export interface TableProps<RecordType = unknown>
|
||||
extends AntTableProps<RecordType> {
|
||||
export interface TableProps<
|
||||
RecordType = unknown,
|
||||
> extends AntTableProps<RecordType> {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
*/
|
||||
import React from "react";
|
||||
import { Tag as AntTag, Badge as AntBadge } from "antd";
|
||||
import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd";
|
||||
import type {
|
||||
TagProps as AntTagProps,
|
||||
BadgeProps as AntBadgeProps,
|
||||
} from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
* 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip as AntTooltip,
|
||||
Popover as AntPopover,
|
||||
} from "antd";
|
||||
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ const Accounts: React.FC = () => {
|
||||
/** 统计已绑定账号数 */
|
||||
const totalBound = accountQueries.reduce(
|
||||
(sum, q) => sum + (q.data?.length ?? 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
const totalPlatforms = PLATFORMS.length;
|
||||
|
||||
@@ -215,10 +215,9 @@ const Accounts: React.FC = () => {
|
||||
<div className="acc-stats-bar">
|
||||
<span className="acc-stats-icon">📊</span>
|
||||
<span className="acc-stats-text">
|
||||
已绑定{" "}
|
||||
<span className="acc-stats-highlight">{totalBound}</span> 个账号 /
|
||||
支持 <span className="acc-stats-highlight">{totalPlatforms}</span>{" "}
|
||||
个平台
|
||||
已绑定 <span className="acc-stats-highlight">{totalBound}</span>{" "}
|
||||
个账号 / 支持{" "}
|
||||
<span className="acc-stats-highlight">{totalPlatforms}</span> 个平台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.acc-card:hover {
|
||||
box-shadow: 0 4px 20px var(--shadow-sm, rgba(0,0,0,0.06));
|
||||
box-shadow: 0 4px 20px var(--shadow-sm, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
/* ── 卡片头部 ───────────────────────────────────────────── */
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Upload,
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -61,18 +56,122 @@ const MOCK_LIBRARIES: LibraryItem[] = [
|
||||
];
|
||||
|
||||
const MOCK_ASSETS: AssetItem[] = [
|
||||
{ id: "a-1", name: "产品功能演示_v2.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "02:34", size: 128, createdAt: "2026-06-28" },
|
||||
{ id: "a-2", name: "品牌宣传片_终版.mp4", kind: "video", status: "warn", statusLabel: "待优化", duration: "05:12", size: 356, createdAt: "2026-06-27" },
|
||||
{ id: "a-3", name: "用户案例_张三.mp4", kind: "video", status: "bad", statusLabel: "不合格", duration: "01:48", size: 96, createdAt: "2026-06-26" },
|
||||
{ id: "a-4", name: "功能讲解_配音.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "03:22", size: 48, createdAt: "2026-06-25" },
|
||||
{ id: "a-5", name: "旁白_中文版.wav", kind: "voice", status: "info", statusLabel: "处理中", duration: "04:05", size: 72, createdAt: "2026-06-24" },
|
||||
{ id: "a-6", name: "Banner_春季活动.png", kind: "image", status: "ok", statusLabel: "合格", size: 2.4, createdAt: "2026-06-23" },
|
||||
{ id: "a-7", name: "产品截图_首页.png", kind: "image", status: "ok", statusLabel: "合格", size: 1.8, createdAt: "2026-06-22" },
|
||||
{ id: "a-8", name: "社交媒体封面.jpg", kind: "image", status: "warn", statusLabel: "待优化", size: 3.2, createdAt: "2026-06-21" },
|
||||
{ id: "a-9", name: "操作指南_录屏.mp4", kind: "video", status: "ok", statusLabel: "合格", duration: "08:15", size: 512, createdAt: "2026-06-20" },
|
||||
{ id: "a-10", name: "背景音乐_轻快.mp3", kind: "voice", status: "ok", statusLabel: "合格", duration: "02:00", size: 36, createdAt: "2026-06-19" },
|
||||
{ id: "a-11", name: "Logo_高清.png", kind: "image", status: "ok", statusLabel: "合格", size: 0.8, createdAt: "2026-06-18" },
|
||||
{ id: "a-12", name: "活动预告_短视频.mp4", kind: "video", status: "info", statusLabel: "处理中", duration: "00:30", size: 64, createdAt: "2026-06-17" },
|
||||
{
|
||||
id: "a-1",
|
||||
name: "产品功能演示_v2.mp4",
|
||||
kind: "video",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "02:34",
|
||||
size: 128,
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "a-2",
|
||||
name: "品牌宣传片_终版.mp4",
|
||||
kind: "video",
|
||||
status: "warn",
|
||||
statusLabel: "待优化",
|
||||
duration: "05:12",
|
||||
size: 356,
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "a-3",
|
||||
name: "用户案例_张三.mp4",
|
||||
kind: "video",
|
||||
status: "bad",
|
||||
statusLabel: "不合格",
|
||||
duration: "01:48",
|
||||
size: 96,
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "a-4",
|
||||
name: "功能讲解_配音.mp3",
|
||||
kind: "voice",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "03:22",
|
||||
size: 48,
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "a-5",
|
||||
name: "旁白_中文版.wav",
|
||||
kind: "voice",
|
||||
status: "info",
|
||||
statusLabel: "处理中",
|
||||
duration: "04:05",
|
||||
size: 72,
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "a-6",
|
||||
name: "Banner_春季活动.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 2.4,
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "a-7",
|
||||
name: "产品截图_首页.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 1.8,
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "a-8",
|
||||
name: "社交媒体封面.jpg",
|
||||
kind: "image",
|
||||
status: "warn",
|
||||
statusLabel: "待优化",
|
||||
size: 3.2,
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "a-9",
|
||||
name: "操作指南_录屏.mp4",
|
||||
kind: "video",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "08:15",
|
||||
size: 512,
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "a-10",
|
||||
name: "背景音乐_轻快.mp3",
|
||||
kind: "voice",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
duration: "02:00",
|
||||
size: 36,
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
{
|
||||
id: "a-11",
|
||||
name: "Logo_高清.png",
|
||||
kind: "image",
|
||||
status: "ok",
|
||||
statusLabel: "合格",
|
||||
size: 0.8,
|
||||
createdAt: "2026-06-18",
|
||||
},
|
||||
{
|
||||
id: "a-12",
|
||||
name: "活动预告_短视频.mp4",
|
||||
kind: "video",
|
||||
status: "info",
|
||||
statusLabel: "处理中",
|
||||
duration: "00:30",
|
||||
size: 64,
|
||||
createdAt: "2026-06-17",
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 2048 * 1024 * 1024;
|
||||
@@ -83,17 +182,23 @@ const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
|
||||
* ============================================================ */
|
||||
const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video": return <VideoCameraOutlined />;
|
||||
case "voice": return <SoundOutlined />;
|
||||
case "image": return <PictureOutlined />;
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video": return "视频";
|
||||
case "voice": return "配音";
|
||||
case "image": return "图片";
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,9 +221,7 @@ const StatusPill: React.FC<{ status: StatusType; label: string }> = ({
|
||||
status,
|
||||
label,
|
||||
}) => (
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
@@ -335,7 +438,13 @@ const AssetLibrary: React.FC = () => {
|
||||
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
onClick={() => setActiveLibId(lib.id)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ flex: 1, margin: 0 }}>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
@@ -434,7 +543,12 @@ const AssetLibrary: React.FC = () => {
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={selectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
共 {filteredAssets.length} 个素材
|
||||
</span>
|
||||
</div>
|
||||
@@ -455,7 +569,11 @@ const AssetLibrary: React.FC = () => {
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -496,9 +614,22 @@ const AssetLibrary: React.FC = () => {
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</div>
|
||||
<Input
|
||||
@@ -509,7 +640,13 @@ const AssetLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
类型
|
||||
</div>
|
||||
<Select
|
||||
|
||||
@@ -415,10 +415,7 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
<div className="xx-storage-bar">
|
||||
<div className="xx-storage-bar-track">
|
||||
<div
|
||||
className="xx-storage-bar-fill"
|
||||
style={{ width: "24%" }}
|
||||
/>
|
||||
<div className="xx-storage-bar-fill" style={{ width: "24%" }} />
|
||||
</div>
|
||||
<div className="xx-storage-bar-label">
|
||||
<span>2.4 GB 已用</span>
|
||||
|
||||
@@ -44,7 +44,11 @@
|
||||
}
|
||||
|
||||
.xx-kpi-card {
|
||||
background: linear-gradient(180deg, var(--bg-primary), var(--bg-secondary, #f8fafc));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--bg-primary),
|
||||
var(--bg-secondary, #f8fafc)
|
||||
);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
|
||||
@@ -95,8 +95,9 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
||||
片段 {index + 1}:{segment.matched_video_name}
|
||||
</h4>
|
||||
<p>
|
||||
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}
|
||||
({sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
||||
原始 {formatTime(segment.source_start)} -{" "}
|
||||
{formatTime(segment.source_end)}({sourceDuration.toFixed(0)}s)→ 匹配{" "}
|
||||
{formatTime(segment.matched_start)} -{" "}
|
||||
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
||||
</p>
|
||||
</div>
|
||||
@@ -181,7 +182,9 @@ const DuplicationDetail: React.FC = () => {
|
||||
|
||||
const riskLevel = getRiskLevel(detail.duplicate_rate);
|
||||
const similarityPercent =
|
||||
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—";
|
||||
detail.duplicate_rate !== undefined
|
||||
? detail.duplicate_rate.toFixed(1)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -195,17 +198,30 @@ const DuplicationDetail: React.FC = () => {
|
||||
<PageHead
|
||||
title={`🎬 ${detail.filename}`}
|
||||
description={
|
||||
<div className="dup-detail-header-meta" style={{ display: "flex", gap: 12, alignItems: "center", marginTop: 8 }}>
|
||||
<div
|
||||
className="dup-detail-header-meta"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Tag variant={RISK_TAG_VARIANT[riskLevel]}>
|
||||
{RISK_LABEL[riskLevel]}
|
||||
</Tag>
|
||||
<span>{formatSize(detail.file_size)}</span>
|
||||
<span>{formatDuration(detail.duration_seconds)}</span>
|
||||
<span>提交于 {new Date(detail.created_at).toLocaleString("zh-CN")}</span>
|
||||
<span>
|
||||
提交于 {new Date(detail.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
||||
<div
|
||||
className="dup-detail-actions"
|
||||
style={{ display: "flex", gap: 8 }}
|
||||
>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="md"
|
||||
@@ -247,7 +263,15 @@ const DuplicationDetail: React.FC = () => {
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件名</span>
|
||||
<Tooltip title={detail.filename}>
|
||||
<span className="dup-info-row-value" style={{ maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<span
|
||||
className="dup-info-row-value"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{detail.filename}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -21,7 +21,11 @@ type RiskFilter = "all" | "high" | "medium" | "low";
|
||||
/** 状态配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
DuplicationStatus,
|
||||
{ variant: "primary" | "warning" | "success" | "error"; text: string; icon: string }
|
||||
{
|
||||
variant: "primary" | "warning" | "success" | "error";
|
||||
text: string;
|
||||
icon: string;
|
||||
}
|
||||
> = {
|
||||
pending: { variant: "primary", text: "等待中", icon: "⏳" },
|
||||
processing: { variant: "warning", text: "查重中", icon: "🔄" },
|
||||
@@ -79,7 +83,10 @@ const DuplicationResults: React.FC = () => {
|
||||
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all");
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
const showToast = (
|
||||
message: string,
|
||||
type: "success" | "error" | "warning",
|
||||
) => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
@@ -136,25 +143,25 @@ const DuplicationResults: React.FC = () => {
|
||||
title="查重记录"
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{/* 筛选胶囊 */}
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
|
||||
onClick={() => setRiskFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication")}
|
||||
>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
{/* 筛选胶囊 */}
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
|
||||
onClick={() => setRiskFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/duplication")}
|
||||
>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -247,7 +254,9 @@ const DuplicationResults: React.FC = () => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
<span
|
||||
style={{ color: "var(--text-secondary)", fontSize: 12 }}
|
||||
>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -124,7 +124,10 @@ const DuplicationUpload: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageHead title="视频查重" description="上传视频文件,系统将自动检测与已有素材的重复片段,帮助您避免重复内容。" />
|
||||
<PageHead
|
||||
title="视频查重"
|
||||
description="上传视频文件,系统将自动检测与已有素材的重复片段,帮助您避免重复内容。"
|
||||
/>
|
||||
|
||||
<div className="dup-upload-grid">
|
||||
{/* 左侧:上传区域 */}
|
||||
@@ -137,10 +140,10 @@ const DuplicationUpload: React.FC = () => {
|
||||
onDrop={handleDrop}
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">
|
||||
{uploading ? "⏳" : "📁"}
|
||||
</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>
|
||||
{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}
|
||||
</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
/* 详情内容网格 */
|
||||
.dup-detail-grid {
|
||||
display: grid;
|
||||
gridTemplateColumns: 1fr 1fr;
|
||||
gridtemplatecolumns: 1fr 1fr;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,15 @@ import {
|
||||
} from "@/api/editingPlanner";
|
||||
|
||||
/** antd Tag color → V21 Tag variant */
|
||||
const modeVariantMap: Record<string, "primary" | "success" | "warning" | "info"> =
|
||||
{
|
||||
blue: "primary",
|
||||
green: "success",
|
||||
orange: "warning",
|
||||
purple: "info",
|
||||
};
|
||||
const modeVariantMap: Record<
|
||||
string,
|
||||
"primary" | "success" | "warning" | "info"
|
||||
> = {
|
||||
blue: "primary",
|
||||
green: "success",
|
||||
orange: "warning",
|
||||
purple: "info",
|
||||
};
|
||||
|
||||
interface TemplatePanelProps {
|
||||
templates: EditingTemplate[];
|
||||
@@ -81,8 +83,7 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => {
|
||||
const modeColor =
|
||||
MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const modeColor = MODE_COLORS[tpl.mode as TemplateMode] || "blue";
|
||||
const variant = modeVariantMap[modeColor] || "primary";
|
||||
|
||||
return (
|
||||
@@ -114,7 +115,12 @@ const TemplatePanel: React.FC<TemplatePanelProps> = ({
|
||||
{/* 底部:新建空白模板 */}
|
||||
{loadedTemplateId && (
|
||||
<div className="ep-left-footer">
|
||||
<Button buttonType="ghost" buttonSize="sm" block onClick={onNewTemplate}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
block
|
||||
onClick={onNewTemplate}
|
||||
>
|
||||
✨ 新建空白模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -166,11 +166,36 @@ interface TimelineScene {
|
||||
}
|
||||
|
||||
const MOCK_TIMELINE: TimelineScene[] = [
|
||||
{ scene: "开场 - 品牌 Logo 展示", time: "0:00 - 0:05", duration: 5, color: "#6366f1" },
|
||||
{ scene: "产品特写 - 细节展示", time: "0:05 - 0:15", duration: 10, color: "#818cf8" },
|
||||
{ scene: "使用场景 - 生活化演绎", time: "0:15 - 0:30", duration: 15, color: "#10b981" },
|
||||
{ scene: "卖点字幕 - 核心优势", time: "0:30 - 0:40", duration: 10, color: "#f59e0b" },
|
||||
{ scene: "结尾 CTA - 引导关注", time: "0:40 - 0:50", duration: 10, color: "#ef4444" },
|
||||
{
|
||||
scene: "开场 - 品牌 Logo 展示",
|
||||
time: "0:00 - 0:05",
|
||||
duration: 5,
|
||||
color: "#6366f1",
|
||||
},
|
||||
{
|
||||
scene: "产品特写 - 细节展示",
|
||||
time: "0:05 - 0:15",
|
||||
duration: 10,
|
||||
color: "#818cf8",
|
||||
},
|
||||
{
|
||||
scene: "使用场景 - 生活化演绎",
|
||||
time: "0:15 - 0:30",
|
||||
duration: 15,
|
||||
color: "#10b981",
|
||||
},
|
||||
{
|
||||
scene: "卖点字幕 - 核心优势",
|
||||
time: "0:30 - 0:40",
|
||||
duration: 10,
|
||||
color: "#f59e0b",
|
||||
},
|
||||
{
|
||||
scene: "结尾 CTA - 引导关注",
|
||||
time: "0:40 - 0:50",
|
||||
duration: 10,
|
||||
color: "#ef4444",
|
||||
},
|
||||
];
|
||||
|
||||
const TOTAL_DURATION = MOCK_TIMELINE.reduce((sum, t) => sum + t.duration, 0);
|
||||
@@ -246,7 +271,9 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>("");
|
||||
const [playingVoice, setPlayingVoice] = useState<string | null>(null);
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset");
|
||||
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">(
|
||||
"preset",
|
||||
);
|
||||
const [customVoiceText, setCustomVoiceText] = useState("");
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
@@ -385,9 +412,7 @@ const GeneratePage: React.FC = () => {
|
||||
label="视频标题"
|
||||
required
|
||||
help={`${titleCount}/${TITLE_MAX} 字`}
|
||||
validateStatus={
|
||||
titleCount > TITLE_MAX ? "error" : undefined
|
||||
}
|
||||
validateStatus={titleCount > TITLE_MAX ? "error" : undefined}
|
||||
>
|
||||
<Input
|
||||
placeholder="请输入视频标题"
|
||||
@@ -481,9 +506,7 @@ const GeneratePage: React.FC = () => {
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽素材到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持视频、图片、音频格式
|
||||
</p>
|
||||
<p className="ant-upload-hint">支持视频、图片、音频格式</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,7 +696,8 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* 提示 */}
|
||||
<Typography.Paragraph className="xx-clone-tip">
|
||||
💡 提示:录音环境越安静,克隆效果越好。建议使用专业麦克风录制。
|
||||
💡
|
||||
提示:录音环境越安静,克隆效果越好。建议使用专业麦克风录制。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
)}
|
||||
@@ -907,11 +931,11 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════════════ 右侧:预览面板 ════════════ */}
|
||||
<div>
|
||||
<Card className="xx-generate-section" style={{ marginBottom: 0, position: "sticky", top: 24 }}>
|
||||
<div
|
||||
className="xx-section-header"
|
||||
style={{ padding: "16px 24px" }}
|
||||
>
|
||||
<Card
|
||||
className="xx-generate-section"
|
||||
style={{ marginBottom: 0, position: "sticky", top: 24 }}
|
||||
>
|
||||
<div className="xx-section-header" style={{ padding: "16px 24px" }}>
|
||||
<Typography.Title level={5} className="xx-section-title">
|
||||
预览
|
||||
</Typography.Title>
|
||||
@@ -927,14 +951,23 @@ const GeneratePage: React.FC = () => {
|
||||
controls
|
||||
preload="none"
|
||||
poster=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-video-preview-placeholder">
|
||||
<button className="xx-video-play-btn" type="button">
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
<Text style={{ color: "var(--text-inverse, rgba(255,255,255,0.6))", fontSize: "var(--font-size-sm)" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-inverse, rgba(255,255,255,0.6))",
|
||||
fontSize: "var(--font-size-sm)",
|
||||
}}
|
||||
>
|
||||
预览视频
|
||||
</Text>
|
||||
</div>
|
||||
@@ -983,7 +1016,10 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
{/* 播放头 */}
|
||||
<div className="xx-timeline-playhead" style={{ left: "20%" }} />
|
||||
<div
|
||||
className="xx-timeline-playhead"
|
||||
style={{ left: "20%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 场景列表 */}
|
||||
@@ -991,7 +1027,9 @@ const GeneratePage: React.FC = () => {
|
||||
{MOCK_TIMELINE.map((item, idx) => (
|
||||
<div key={idx} className="xx-timeline-item">
|
||||
<div className="xx-timeline-item-index">{idx + 1}</div>
|
||||
<span className="xx-timeline-item-scene">{item.scene}</span>
|
||||
<span className="xx-timeline-item-scene">
|
||||
{item.scene}
|
||||
</span>
|
||||
<span className="xx-timeline-item-time">{item.time}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1012,18 +1050,10 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
{generated && (
|
||||
<div className="xx-result-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
icon={<DownloadOutlined />}
|
||||
block
|
||||
>
|
||||
<Button buttonType="ghost" icon={<DownloadOutlined />} block>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
icon={<ShareAltOutlined />}
|
||||
block
|
||||
>
|
||||
<Button buttonType="ghost" icon={<ShareAltOutlined />} block>
|
||||
分享
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -383,7 +383,11 @@
|
||||
.xx-video-preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
background: radial-gradient(
|
||||
circle at 72% 28%,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent 40%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ const TaskHistory: React.FC = () => {
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
|
||||
const paginatedTasks = filteredTasks.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE
|
||||
currentPage * PAGE_SIZE,
|
||||
);
|
||||
|
||||
// 切换 Tab 时重置页码
|
||||
@@ -249,9 +249,7 @@ const TaskHistory: React.FC = () => {
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="xx-history-tab-count">
|
||||
{tabCounts[tab.key]}
|
||||
</span>
|
||||
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,22 @@ const HeroSection: React.FC = () => {
|
||||
一键生成短视频
|
||||
</h1>
|
||||
<p className="hp-hero-desc">
|
||||
基于先进的 AI 技术,自动识别视频亮点,智能剪辑、配音、加字幕。
|
||||
30 秒内将长视频转化为适合各平台传播的精品短视频。
|
||||
基于先进的 AI 技术,自动识别视频亮点,智能剪辑、配音、加字幕。 30
|
||||
秒内将长视频转化为适合各平台传播的精品短视频。
|
||||
</p>
|
||||
<div className="hp-hero-actions">
|
||||
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
立即免费开始
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="lg" onClick={() => navigate("/pricing")}>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="lg"
|
||||
onClick={() => navigate("/pricing")}
|
||||
>
|
||||
查看定价方案
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +56,11 @@ const HeroSection: React.FC = () => {
|
||||
<div className="hp-hero-video-inner">
|
||||
<span className="hp-hero-video-placeholder">🎬</span>
|
||||
</div>
|
||||
<button className="hp-hero-play" type="button" aria-label="播放演示视频">
|
||||
<button
|
||||
className="hp-hero-play"
|
||||
type="button"
|
||||
aria-label="播放演示视频"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
@@ -153,7 +165,12 @@ const PLANS = [
|
||||
price: "免费",
|
||||
period: "",
|
||||
desc: "适合个人体验,快速上手",
|
||||
features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"],
|
||||
features: [
|
||||
"每月 5 次 AI 生成",
|
||||
"720p 视频导出",
|
||||
"基础模板库",
|
||||
"1 个平台账号绑定",
|
||||
],
|
||||
highlighted: false,
|
||||
cta: "免费开始",
|
||||
},
|
||||
@@ -210,7 +227,9 @@ const PricingSection: React.FC = () => {
|
||||
<h3 className="hp-pricing-name">{plan.name}</h3>
|
||||
<div className="hp-pricing-price">
|
||||
{plan.price}
|
||||
{plan.period && <span className="hp-pricing-period">{plan.period}</span>}
|
||||
{plan.period && (
|
||||
<span className="hp-pricing-period">{plan.period}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="hp-pricing-desc">{plan.desc}</p>
|
||||
<ul className="hp-pricing-features">
|
||||
@@ -247,7 +266,11 @@ const CTASection: React.FC = () => {
|
||||
<p className="hp-cta-desc">
|
||||
免费注册,立即体验 AI 智能视频创作。无需信用卡,零风险上手。
|
||||
</p>
|
||||
<Button buttonType="primary" buttonSize="lg" onClick={() => navigate("/register")}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="lg"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
免费注册
|
||||
</Button>
|
||||
</div>
|
||||
@@ -273,15 +296,27 @@ const HomePage: React.FC = () => {
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="hp-nav">
|
||||
<div className="hp-nav-inner">
|
||||
<button className="hp-nav-brand" type="button" onClick={() => navigate("/")}>
|
||||
<button
|
||||
className="hp-nav-brand"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<span className="hp-nav-logo">🦐</span>
|
||||
<span className="hp-nav-brand-text">小虾智剪</span>
|
||||
</button>
|
||||
<div className="hp-nav-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/login")}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => navigate("/register")}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
免费注册
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
min-height: 100vh;
|
||||
background: var(--bg-base, #fafafa);
|
||||
color: var(--text-primary, #1a1a2e);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@@ -24,7 +25,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-glass, rgba(255,255,255,0.72));
|
||||
background: var(--bg-glass, rgba(255, 255, 255, 0.72));
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
@@ -70,7 +71,11 @@
|
||||
|
||||
.hp-hero {
|
||||
padding: 120px 24px 60px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--primary-color) 4%, transparent) 0%, transparent 60%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--primary-color) 4%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
|
||||
.hp-hero-inner {
|
||||
@@ -133,12 +138,17 @@
|
||||
position: relative;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-primary-400, #8b5cf6) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--primary-color) 0%,
|
||||
var(--color-primary-400, #8b5cf6) 100%
|
||||
);
|
||||
aspect-ratio: 16 / 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 20px 60px color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
box-shadow: 0 20px 60px
|
||||
color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||
}
|
||||
|
||||
.hp-hero-video-inner {
|
||||
@@ -159,7 +169,7 @@
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elevated, rgba(255,255,255,0.92));
|
||||
background: var(--bg-elevated, rgba(255, 255, 255, 0.92));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -167,13 +177,15 @@
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: var(--primary, var(--primary-color));
|
||||
box-shadow: 0 4px 20px var(--shadow-lg, rgba(0,0,0,0.15));
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 4px 20px var(--shadow-lg, rgba(0, 0, 0, 0.15));
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.hp-hero-play:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 28px var(--shadow-xl, rgba(0,0,0,0.2));
|
||||
box-shadow: 0 6px 28px var(--shadow-xl, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.hp-hero-info {
|
||||
@@ -249,12 +261,14 @@
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.hp-feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px var(--shadow-md, rgba(0,0,0,0.08));
|
||||
box-shadow: 0 12px 40px var(--shadow-md, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.hp-feature-icon {
|
||||
@@ -287,7 +301,12 @@
|
||||
|
||||
.hp-workflow {
|
||||
padding: 80px 0;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(99, 102, 241, 0.03) 50%, transparent 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(99, 102, 241, 0.03) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hp-step-grid {
|
||||
@@ -375,17 +394,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.hp-pricing-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px var(--shadow-md, rgba(0,0,0,0.08));
|
||||
box-shadow: 0 12px 40px var(--shadow-md, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.hp-pricing-card--highlight {
|
||||
border-color: var(--primary, var(--primary-color));
|
||||
box-shadow: 0 8px 32px color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
box-shadow: 0 8px 32px
|
||||
color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
}
|
||||
|
||||
.hp-pricing-badge {
|
||||
@@ -461,7 +483,11 @@
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color) 6%, transparent) 0%, color-mix(in srgb, var(--color-primary-400, #8b5cf6) 6%, transparent) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-color) 6%, transparent) 0%,
|
||||
color-mix(in srgb, var(--color-primary-400, #8b5cf6) 6%, transparent) 100%
|
||||
);
|
||||
border: 1px solid var(--line, rgba(0, 0, 0, 0.06));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { message, Popconfirm } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -57,29 +63,162 @@ const GRADIENTS = [
|
||||
];
|
||||
|
||||
const MOCK_PRODUCTS: ProductItem[] = [
|
||||
{ id: "p-1", name: "产品介绍_智能客服系统_v2", status: "completed", duration: 95, date: "2026-06-28", gradient: GRADIENTS[0], duplicateRate: 3.2, isPublished: true, resolution: "1080×1920", fileSize: 45.6 },
|
||||
{ id: "p-2", name: "品牌宣传_春季活动", status: "completed", duration: 60, date: "2026-06-27", gradient: GRADIENTS[1], duplicateRate: 5.8, isPublished: true, resolution: "1080×1920", fileSize: 32.1 },
|
||||
{ id: "p-3", name: "教程_如何使用AI生成视频", status: "completed", duration: 180, date: "2026-06-26", gradient: GRADIENTS[2], duplicateRate: 1.5, isPublished: false, resolution: "1080×1920", fileSize: 78.3 },
|
||||
{ id: "p-4", name: "电商推广_夏日特惠", status: "processing", duration: 45, date: "2026-06-25", gradient: GRADIENTS[3], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 },
|
||||
{ id: "p-5", name: "知识科普_量子计算入门", status: "completed", duration: 120, date: "2026-06-24", gradient: GRADIENTS[4], duplicateRate: 8.1, isPublished: false, resolution: "1080×1920", fileSize: 56.2 },
|
||||
{ id: "p-6", name: "企业宣传片_科技创新", status: "review", duration: 150, date: "2026-06-23", gradient: GRADIENTS[5], duplicateRate: 12.5, isPublished: false, resolution: "1080×1920", fileSize: 68.9 },
|
||||
{ id: "p-7", name: "短视频_产品开箱测评", status: "completed", duration: 75, date: "2026-06-22", gradient: GRADIENTS[6], duplicateRate: 6.3, isPublished: true, resolution: "1080×1920", fileSize: 38.7 },
|
||||
{ id: "p-8", name: "Vlog_日常工作记录", status: "completed", duration: 200, date: "2026-06-21", gradient: GRADIENTS[7], duplicateRate: 2.1, isPublished: false, resolution: "1080×1920", fileSize: 92.4 },
|
||||
{ id: "p-9", name: "广告_新品发布会预告", status: "failed", duration: 30, date: "2026-06-20", gradient: GRADIENTS[8], duplicateRate: 0, isPublished: false, resolution: "1080×1920", fileSize: 0 },
|
||||
{ id: "p-10", name: "教育课程_AI基础讲解", status: "completed", duration: 300, date: "2026-06-19", gradient: GRADIENTS[9], duplicateRate: 4.7, isPublished: false, resolution: "1080×1920", fileSize: 125.8 },
|
||||
{ id: "p-11", name: "直播带货_美妆专场", status: "review", duration: 90, date: "2026-06-18", gradient: GRADIENTS[10], duplicateRate: 15.3, isPublished: false, resolution: "1080×1920", fileSize: 42.1 },
|
||||
{ id: "p-12", name: "品牌故事_创始人访谈", status: "completed", duration: 240, date: "2026-06-17", gradient: GRADIENTS[11], duplicateRate: 7.9, isPublished: false, resolution: "1080×1920", fileSize: 108.5 },
|
||||
{
|
||||
id: "p-1",
|
||||
name: "产品介绍_智能客服系统_v2",
|
||||
status: "completed",
|
||||
duration: 95,
|
||||
date: "2026-06-28",
|
||||
gradient: GRADIENTS[0],
|
||||
duplicateRate: 3.2,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 45.6,
|
||||
},
|
||||
{
|
||||
id: "p-2",
|
||||
name: "品牌宣传_春季活动",
|
||||
status: "completed",
|
||||
duration: 60,
|
||||
date: "2026-06-27",
|
||||
gradient: GRADIENTS[1],
|
||||
duplicateRate: 5.8,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 32.1,
|
||||
},
|
||||
{
|
||||
id: "p-3",
|
||||
name: "教程_如何使用AI生成视频",
|
||||
status: "completed",
|
||||
duration: 180,
|
||||
date: "2026-06-26",
|
||||
gradient: GRADIENTS[2],
|
||||
duplicateRate: 1.5,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 78.3,
|
||||
},
|
||||
{
|
||||
id: "p-4",
|
||||
name: "电商推广_夏日特惠",
|
||||
status: "processing",
|
||||
duration: 45,
|
||||
date: "2026-06-25",
|
||||
gradient: GRADIENTS[3],
|
||||
duplicateRate: 0,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 0,
|
||||
},
|
||||
{
|
||||
id: "p-5",
|
||||
name: "知识科普_量子计算入门",
|
||||
status: "completed",
|
||||
duration: 120,
|
||||
date: "2026-06-24",
|
||||
gradient: GRADIENTS[4],
|
||||
duplicateRate: 8.1,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 56.2,
|
||||
},
|
||||
{
|
||||
id: "p-6",
|
||||
name: "企业宣传片_科技创新",
|
||||
status: "review",
|
||||
duration: 150,
|
||||
date: "2026-06-23",
|
||||
gradient: GRADIENTS[5],
|
||||
duplicateRate: 12.5,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 68.9,
|
||||
},
|
||||
{
|
||||
id: "p-7",
|
||||
name: "短视频_产品开箱测评",
|
||||
status: "completed",
|
||||
duration: 75,
|
||||
date: "2026-06-22",
|
||||
gradient: GRADIENTS[6],
|
||||
duplicateRate: 6.3,
|
||||
isPublished: true,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 38.7,
|
||||
},
|
||||
{
|
||||
id: "p-8",
|
||||
name: "Vlog_日常工作记录",
|
||||
status: "completed",
|
||||
duration: 200,
|
||||
date: "2026-06-21",
|
||||
gradient: GRADIENTS[7],
|
||||
duplicateRate: 2.1,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 92.4,
|
||||
},
|
||||
{
|
||||
id: "p-9",
|
||||
name: "广告_新品发布会预告",
|
||||
status: "failed",
|
||||
duration: 30,
|
||||
date: "2026-06-20",
|
||||
gradient: GRADIENTS[8],
|
||||
duplicateRate: 0,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 0,
|
||||
},
|
||||
{
|
||||
id: "p-10",
|
||||
name: "教育课程_AI基础讲解",
|
||||
status: "completed",
|
||||
duration: 300,
|
||||
date: "2026-06-19",
|
||||
gradient: GRADIENTS[9],
|
||||
duplicateRate: 4.7,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 125.8,
|
||||
},
|
||||
{
|
||||
id: "p-11",
|
||||
name: "直播带货_美妆专场",
|
||||
status: "review",
|
||||
duration: 90,
|
||||
date: "2026-06-18",
|
||||
gradient: GRADIENTS[10],
|
||||
duplicateRate: 15.3,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 42.1,
|
||||
},
|
||||
{
|
||||
id: "p-12",
|
||||
name: "品牌故事_创始人访谈",
|
||||
status: "completed",
|
||||
duration: 240,
|
||||
date: "2026-06-17",
|
||||
gradient: GRADIENTS[11],
|
||||
duplicateRate: 7.9,
|
||||
isPublished: false,
|
||||
resolution: "1080×1920",
|
||||
fileSize: 108.5,
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
};
|
||||
const statusConfig: Record<ProductStatus, { text: string; className: string }> =
|
||||
{
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
};
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
const formatTime = (seconds: number): string => {
|
||||
@@ -108,11 +247,26 @@ const ProductCard: React.FC<{
|
||||
onShare: (product: ProductItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPublish: (product: ProductItem) => void;
|
||||
}> = ({ product, isSelected, batchMode, onToggleSelect, onPlay, onDownload, onShare, onDelete, onPublish }) => {
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
}) => {
|
||||
const st = statusConfig[product.status];
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass = product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad";
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5
|
||||
? "good"
|
||||
: product.duplicateRate <= 15
|
||||
? "warn"
|
||||
: "bad";
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
@@ -144,18 +298,21 @@ const ProductCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && (
|
||||
<div className="xx-product-badge">✅ 已发布</div>
|
||||
)}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{ background: product.gradient }}
|
||||
/>
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
<span className="xx-product-duration">
|
||||
{formatTime(product.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -194,7 +351,10 @@ const ProductCard: React.FC<{
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
<span
|
||||
className="xx-product-action-btn success"
|
||||
style={{ cursor: "default" }}
|
||||
>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
@@ -211,7 +371,10 @@ const ProductCard: React.FC<{
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
style={{ padding: "0 14px 14px" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
@@ -356,20 +519,28 @@ const VideoPlayer: React.FC<{
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.resolution}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{formatTime(product.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{formatSize(product.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
{product.duplicateRate > 0
|
||||
? `${product.duplicateRate.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,7 +593,9 @@ const ProductLibrary: React.FC = () => {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null);
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0;
|
||||
@@ -442,10 +615,14 @@ const ProductLibrary: React.FC = () => {
|
||||
const d = new Date(p.date);
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
|
||||
switch (filterTime) {
|
||||
case "today": return diff < 1;
|
||||
case "week": return diff <= 7;
|
||||
case "month": return diff <= 30;
|
||||
default: return true;
|
||||
case "today":
|
||||
return diff < 1;
|
||||
case "week":
|
||||
return diff <= 7;
|
||||
case "month":
|
||||
return diff <= 30;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -454,10 +631,14 @@ const ProductLibrary: React.FC = () => {
|
||||
if (filterDuration !== "all") {
|
||||
list = list.filter((p) => {
|
||||
switch (filterDuration) {
|
||||
case "short": return p.duration > 0 && p.duration <= 60;
|
||||
case "medium": return p.duration > 60 && p.duration <= 180;
|
||||
case "long": return p.duration > 180;
|
||||
default: return true;
|
||||
case "short":
|
||||
return p.duration > 0 && p.duration <= 60;
|
||||
case "medium":
|
||||
return p.duration > 60 && p.duration <= 180;
|
||||
case "long":
|
||||
return p.duration > 180;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -472,7 +653,9 @@ const ProductLibrary: React.FC = () => {
|
||||
}, [products, filterStatus, filterTime, filterDuration, searchText]);
|
||||
|
||||
/* 全选 */
|
||||
const allSelected = filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id));
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 &&
|
||||
filteredProducts.every((p) => selectedIds.has(p.id));
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
@@ -507,7 +690,7 @@ const ProductLibrary: React.FC = () => {
|
||||
const link = `https://xiaoxia.ai/share/${product.id}`;
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`)
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -525,7 +708,7 @@ const ProductLibrary: React.FC = () => {
|
||||
/* 发布 */
|
||||
const handlePublish = (product: ProductItem) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? { ...p, isPublished: true } : p))
|
||||
prev.map((p) => (p.id === product.id ? { ...p, isPublished: true } : p)),
|
||||
);
|
||||
message.success(`"${product.name}" 已发布`);
|
||||
};
|
||||
@@ -549,7 +732,7 @@ const ProductLibrary: React.FC = () => {
|
||||
const handleBatchPublish = () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (ids.includes(p.id) ? { ...p, isPublished: true } : p))
|
||||
prev.map((p) => (ids.includes(p.id) ? { ...p, isPublished: true } : p)),
|
||||
);
|
||||
message.success(`已批量发布 ${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
@@ -559,9 +742,15 @@ const ProductLibrary: React.FC = () => {
|
||||
<div className="xx-products-page">
|
||||
{/* 页面头部 */}
|
||||
<div className="xx-products-header">
|
||||
<h2><VideoCameraOutlined /> 成片库</h2>
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
<div style={{ display: "flex", gap: "var(--space-sm)" }}>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
>
|
||||
批量导出
|
||||
</Button>
|
||||
</div>
|
||||
@@ -585,10 +774,20 @@ const ProductLibrary: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-products-batch-bar-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<DownloadOutlined />} onClick={handleBatchDownload}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
>
|
||||
批量下载
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<CloudUploadOutlined />} onClick={handleBatchPublish}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={handleBatchPublish}
|
||||
>
|
||||
批量发布
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -597,11 +796,19 @@ const ProductLibrary: React.FC = () => {
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
@@ -655,7 +862,12 @@ const ProductLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span style={{ fontSize: "var(--font-size-sm)", color: "var(--text-tertiary)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
共 {filteredProducts.length} 个成片
|
||||
</span>
|
||||
</div>
|
||||
@@ -685,7 +897,11 @@ const ProductLibrary: React.FC = () => {
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<p>暂无成片,去一键生成吧</p>
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={() => message.info("跳转到生成页面(mock)")}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => message.info("跳转到生成页面(mock)")}
|
||||
>
|
||||
去生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,9 @@
|
||||
/* 选中状态 */
|
||||
.xx-product-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft), var(--shadow-sm);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--primary-soft),
|
||||
var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* 已发布状态 - 降低透明度 */
|
||||
@@ -447,8 +449,12 @@
|
||||
}
|
||||
|
||||
@keyframes player-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-player-container {
|
||||
|
||||
@@ -37,20 +37,12 @@ const Settings: React.FC = () => {
|
||||
<div className="xx-settings-form">
|
||||
<div className="xx-settings-field">
|
||||
<label className="xx-settings-label">用户名</label>
|
||||
<Input
|
||||
value={user?.username || ""}
|
||||
disabled
|
||||
placeholder="用户名"
|
||||
/>
|
||||
<Input value={user?.username || ""} disabled placeholder="用户名" />
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-field">
|
||||
<label className="xx-settings-label">邮箱</label>
|
||||
<Input
|
||||
value={user?.email || ""}
|
||||
disabled
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
<Input value={user?.email || ""} disabled placeholder="邮箱" />
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-field">
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
}
|
||||
|
||||
@keyframes xx-toggle-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Spinner 组件 ───────────────────────────────── */
|
||||
@@ -193,8 +195,16 @@
|
||||
}
|
||||
|
||||
@keyframes xx-spinner-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -45,7 +45,9 @@ const ToggleSwitch: React.FC<{
|
||||
);
|
||||
|
||||
/** 自定义 Spinner 组件 */
|
||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
|
||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({
|
||||
size = "large",
|
||||
}) => (
|
||||
<div className={`xx-spinner xx-spinner--${size}`}>
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
|
||||
.xx-plan-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
box-shadow: 0 32px 90px
|
||||
color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -88,7 +89,11 @@
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--primary-color));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--primary-color)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
@@ -189,7 +194,11 @@
|
||||
}
|
||||
|
||||
.xx-subscribe-btn.primary {
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--primary-color)) !important;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--primary-color)
|
||||
) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-primary) !important;
|
||||
@@ -213,10 +222,15 @@
|
||||
|
||||
/* 企业版按钮 */
|
||||
.xx-subscribe-btn.enterprise {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-color),
|
||||
var(--accent-dark)
|
||||
) !important;
|
||||
color: var(--text-inverse) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
|
||||
box-shadow: 0 14px 26px
|
||||
color-mix(in srgb, var(--accent-color) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
|
||||
.xx-upgrade-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 8px 30px color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
box-shadow: 0 8px 30px
|
||||
color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.xx-upgrade-card.selected {
|
||||
@@ -163,8 +164,16 @@
|
||||
}
|
||||
|
||||
@keyframes xx-spinner-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -52,16 +52,16 @@ const BillingCycleSwitch: React.FC<{
|
||||
>
|
||||
¥{yearlyPrice}/年
|
||||
{yearlyPrice > 0 && monthlyPrice > 0 && (
|
||||
<span className="xx-save">
|
||||
省 ¥{monthlyPrice * 12 - yearlyPrice}
|
||||
</span>
|
||||
<span className="xx-save">省 ¥{monthlyPrice * 12 - yearlyPrice}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 自定义 Spinner 组件 */
|
||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
|
||||
const Spinner: React.FC<{ size?: "small" | "large" }> = ({
|
||||
size = "large",
|
||||
}) => (
|
||||
<div className={`xx-spinner xx-spinner--${size}`}>
|
||||
<div className="xx-spinner-dot" />
|
||||
<div className="xx-spinner-dot" />
|
||||
@@ -181,7 +181,10 @@ const UpgradeSubscription: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-upgrade-page">
|
||||
<PageHead title="变更订阅方案" description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`} />
|
||||
<PageHead
|
||||
title="变更订阅方案"
|
||||
description={`当前套餐:${PLANS_META[currentPlan]?.name ?? "体验版"}`}
|
||||
/>
|
||||
|
||||
<div className="xx-upgrade-plans">
|
||||
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => {
|
||||
|
||||
@@ -122,7 +122,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
"全部" | TemplateCategory
|
||||
>("全部");
|
||||
const [favorites, setFavorites] = useState<Set<string>>(
|
||||
() => new Set(mockTemplates.filter((t) => t.isFavorite).map((t) => t.id))
|
||||
() => new Set(mockTemplates.filter((t) => t.isFavorite).map((t) => t.id)),
|
||||
);
|
||||
|
||||
/** 切换收藏 */
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import { Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -61,21 +57,141 @@ const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
];
|
||||
|
||||
const MOCK_TITLES: TitleData[] = [
|
||||
{ id: "t-1", content: "这家隐藏在巷子里的小店,味道绝了!", type: "hot", industry: "food", usageCount: 128, isFavorited: true, createdAt: "2026-06-28" },
|
||||
{ id: "t-2", content: "2026 年最值得入手的 5 款蓝牙耳机", type: "hot", industry: "tech", usageCount: 96, isFavorited: false, createdAt: "2026-06-27" },
|
||||
{ id: "t-3", content: "周末在家做了一道妈妈的味道", type: "normal", industry: "food", usageCount: 42, isFavorited: false, createdAt: "2026-06-26" },
|
||||
{ id: "t-4", content: "用 AI 帮我写了一周的小红书文案,效果惊人", type: "hot", industry: "tech", usageCount: 215, isFavorited: true, createdAt: "2026-06-25" },
|
||||
{ id: "t-5", content: "今天穿了一套被路人要链接的衣服", type: "creative", industry: "beauty", usageCount: 67, isFavorited: false, createdAt: "2026-06-24" },
|
||||
{ id: "t-6", content: "分享我的早起 5 点俱乐部 30 天打卡体验", type: "normal", industry: "education", usageCount: 38, isFavorited: true, createdAt: "2026-06-23" },
|
||||
{ id: "t-7", content: "这个平价面霜居然比大牌还好用?", type: "hot", industry: "beauty", usageCount: 183, isFavorited: false, createdAt: "2026-06-22" },
|
||||
{ id: "t-8", content: "一个人的旅行也可以很精彩", type: "normal", industry: "travel", usageCount: 55, isFavorited: false, createdAt: "2026-06-21" },
|
||||
{ id: "t-9", content: "考研上岸!我的备考时间管理方法全公开", type: "hot", industry: "education", usageCount: 147, isFavorited: true, createdAt: "2026-06-20" },
|
||||
{ id: "t-10", content: "把旧 T 恤改造成时尚单品,零成本!", type: "creative", industry: "beauty", usageCount: 29, isFavorited: false, createdAt: "2026-06-19" },
|
||||
{ id: "t-11", content: "这家咖啡馆的氛围感也太好了吧", type: "normal", industry: "food", usageCount: 74, isFavorited: false, createdAt: "2026-06-18" },
|
||||
{ id: "t-12", content: "手机摄影技巧:拍出电影感画面", type: "creative", industry: "tech", usageCount: 61, isFavorited: false, createdAt: "2026-06-17" },
|
||||
{ id: "t-13", content: "带娃旅行必备清单,少带一样都崩溃", type: "hot", industry: "travel", usageCount: 109, isFavorited: false, createdAt: "2026-06-16" },
|
||||
{ id: "t-14", content: "30 天学会一门新语言?我的实验记录", type: "creative", industry: "education", usageCount: 33, isFavorited: false, createdAt: "2026-06-15" },
|
||||
{ id: "t-15", content: "今天做了一道让全家惊艳的菜", type: "normal", industry: "food", usageCount: 48, isFavorited: false, createdAt: "2026-06-14" },
|
||||
{
|
||||
id: "t-1",
|
||||
content: "这家隐藏在巷子里的小店,味道绝了!",
|
||||
type: "hot",
|
||||
industry: "food",
|
||||
usageCount: 128,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "t-2",
|
||||
content: "2026 年最值得入手的 5 款蓝牙耳机",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 96,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "t-3",
|
||||
content: "周末在家做了一道妈妈的味道",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 42,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "t-4",
|
||||
content: "用 AI 帮我写了一周的小红书文案,效果惊人",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 215,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "t-5",
|
||||
content: "今天穿了一套被路人要链接的衣服",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 67,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "t-6",
|
||||
content: "分享我的早起 5 点俱乐部 30 天打卡体验",
|
||||
type: "normal",
|
||||
industry: "education",
|
||||
usageCount: 38,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "t-7",
|
||||
content: "这个平价面霜居然比大牌还好用?",
|
||||
type: "hot",
|
||||
industry: "beauty",
|
||||
usageCount: 183,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "t-8",
|
||||
content: "一个人的旅行也可以很精彩",
|
||||
type: "normal",
|
||||
industry: "travel",
|
||||
usageCount: 55,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "t-9",
|
||||
content: "考研上岸!我的备考时间管理方法全公开",
|
||||
type: "hot",
|
||||
industry: "education",
|
||||
usageCount: 147,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "t-10",
|
||||
content: "把旧 T 恤改造成时尚单品,零成本!",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 29,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
{
|
||||
id: "t-11",
|
||||
content: "这家咖啡馆的氛围感也太好了吧",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 74,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-18",
|
||||
},
|
||||
{
|
||||
id: "t-12",
|
||||
content: "手机摄影技巧:拍出电影感画面",
|
||||
type: "creative",
|
||||
industry: "tech",
|
||||
usageCount: 61,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-17",
|
||||
},
|
||||
{
|
||||
id: "t-13",
|
||||
content: "带娃旅行必备清单,少带一样都崩溃",
|
||||
type: "hot",
|
||||
industry: "travel",
|
||||
usageCount: 109,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-16",
|
||||
},
|
||||
{
|
||||
id: "t-14",
|
||||
content: "30 天学会一门新语言?我的实验记录",
|
||||
type: "creative",
|
||||
industry: "education",
|
||||
usageCount: 33,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-15",
|
||||
},
|
||||
{
|
||||
id: "t-15",
|
||||
content: "今天做了一道让全家惊艳的菜",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 48,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-14",
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
@@ -83,9 +199,12 @@ const MOCK_TITLES: TitleData[] = [
|
||||
* ============================================================ */
|
||||
const typeLabel = (type: TitleType): string => {
|
||||
switch (type) {
|
||||
case "hot": return "爆款";
|
||||
case "normal": return "常规";
|
||||
case "creative": return "创意";
|
||||
case "hot":
|
||||
return "爆款";
|
||||
case "normal":
|
||||
return "常规";
|
||||
case "creative":
|
||||
return "创意";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,12 +304,8 @@ const TitleCard: React.FC<{
|
||||
<span className={`xx-title-type-tag ${title.type}`}>
|
||||
{typeLabel(title.type)}
|
||||
</span>
|
||||
<span className="xx-title-card-stat">
|
||||
使用 {title.usageCount} 次
|
||||
</span>
|
||||
<span className="xx-title-card-stat">
|
||||
{title.createdAt}
|
||||
</span>
|
||||
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
|
||||
<span className="xx-title-card-stat">{title.createdAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-card-actions">
|
||||
@@ -294,11 +409,11 @@ const TitleLibrary: React.FC = () => {
|
||||
if (activeCatId !== "cat-all") {
|
||||
const catName = activeCategory?.name || "";
|
||||
const catToIndustry: Record<string, Industry> = {
|
||||
"美食探店": "food",
|
||||
"科技数码": "tech",
|
||||
"生活日常": "general",
|
||||
"美妆穿搭": "beauty",
|
||||
"教育学习": "education",
|
||||
美食探店: "food",
|
||||
科技数码: "tech",
|
||||
生活日常: "general",
|
||||
美妆穿搭: "beauty",
|
||||
教育学习: "education",
|
||||
};
|
||||
const mappedIndustry = catToIndustry[catName];
|
||||
if (mappedIndustry) {
|
||||
@@ -338,14 +453,22 @@ const TitleLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText]);
|
||||
}, [
|
||||
titles,
|
||||
activeCatId,
|
||||
activeCategory,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 收藏切换 */
|
||||
const handleToggleFavorite = useCallback((id: string) => {
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t
|
||||
)
|
||||
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -372,8 +495,8 @@ const TitleLibrary: React.FC = () => {
|
||||
}
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === editingId ? { ...t, content: editText.trim() } : t
|
||||
)
|
||||
t.id === editingId ? { ...t, content: editText.trim() } : t,
|
||||
),
|
||||
);
|
||||
setEditingId(null);
|
||||
setEditText("");
|
||||
@@ -501,7 +624,15 @@ const TitleLibrary: React.FC = () => {
|
||||
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
||||
onClick={() => setActiveCatId(cat.id)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
<FileTextOutlined /> {cat.name}
|
||||
@@ -592,10 +723,20 @@ const TitleLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-titles-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={() => setCreateTitleModalOpen(true)}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateTitleModalOpen(true)}
|
||||
>
|
||||
新建标题
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
AI 生成标题
|
||||
</Button>
|
||||
</div>
|
||||
@@ -625,7 +766,11 @@ const TitleLibrary: React.FC = () => {
|
||||
<div className="xx-titles-empty-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
|
||||
<p>
|
||||
{searchText
|
||||
? "未找到匹配的标题"
|
||||
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -642,7 +787,13 @@ const TitleLibrary: React.FC = () => {
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
分类名称
|
||||
</div>
|
||||
<Input
|
||||
@@ -664,9 +815,22 @@ const TitleLibrary: React.FC = () => {
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题内容
|
||||
</div>
|
||||
<Input.TextArea
|
||||
@@ -679,7 +843,13 @@ const TitleLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题类型
|
||||
</div>
|
||||
<Select
|
||||
@@ -713,9 +883,22 @@ const TitleLibrary: React.FC = () => {
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
输入关键词或主题
|
||||
</div>
|
||||
<Input
|
||||
@@ -742,7 +925,13 @@ const TitleLibrary: React.FC = () => {
|
||||
{/* AI 生成结果列表 */}
|
||||
{aiResults.length > 0 && (
|
||||
<div className="xx-ai-results">
|
||||
<div style={{ fontSize: "var(--font-size-sm)", color: "var(--text-secondary)", marginBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
||||
</div>
|
||||
{aiResults.map((text, idx) => (
|
||||
|
||||
@@ -406,12 +406,20 @@
|
||||
animation: ai-dot-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.xx-ai-loading-dot:nth-child(1) { animation-delay: 0s; }
|
||||
.xx-ai-loading-dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.xx-ai-loading-dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
.xx-ai-loading-dot:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.xx-ai-loading-dot:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.xx-ai-loading-dot:nth-child(3) {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
@keyframes ai-dot-bounce {
|
||||
0%, 80%, 100% {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -267,9 +267,7 @@ const VoiceClone: React.FC = () => {
|
||||
<div className="vc-empty">
|
||||
<div className="vc-empty-icon">🎤</div>
|
||||
<h3 className="vc-empty-title">还没有克隆音色</h3>
|
||||
<p className="vc-empty-desc">
|
||||
上传你的声音,AI将克隆你的专属音色
|
||||
</p>
|
||||
<p className="vc-empty-desc">上传你的声音,AI将克隆你的专属音色</p>
|
||||
<Button buttonType="primary" onClick={handleCloneNew}>
|
||||
✨ 立即克隆
|
||||
</Button>
|
||||
|
||||
@@ -27,12 +27,15 @@
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.vc-card:hover {
|
||||
border-color: var(--primary-light, #6366f1);
|
||||
box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
box-shadow: 0 4px 20px
|
||||
color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||
}
|
||||
|
||||
/* 右上角操作按钮 */
|
||||
@@ -61,7 +64,9 @@
|
||||
color: var(--muted, #64748b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.vc-card-action-btn:hover {
|
||||
@@ -95,13 +100,22 @@
|
||||
}
|
||||
|
||||
.vc-card-avatar--processing {
|
||||
background: linear-gradient(135deg, var(--text-secondary), var(--color-gray-500));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--text-secondary),
|
||||
var(--color-gray-500)
|
||||
);
|
||||
animation: vc-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vc-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-card-info {
|
||||
@@ -158,8 +172,13 @@
|
||||
}
|
||||
|
||||
@keyframes vc-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 元信息 */
|
||||
@@ -246,7 +265,7 @@
|
||||
font-weight: 500;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
box-shadow: 0 4px 12px var(--shadow-sm, rgba(0,0,0,0.08));
|
||||
box-shadow: 0 4px 12px var(--shadow-sm, rgba(0, 0, 0, 0.08));
|
||||
animation: vc-toast-in 0.25s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -282,13 +301,17 @@
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg, rgba(0,0,0,0.4));
|
||||
background: var(--overlay-bg, rgba(0, 0, 0, 0.4));
|
||||
animation: vc-fade-in 0.2s;
|
||||
}
|
||||
|
||||
@keyframes vc-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.vc-edit-dialog {
|
||||
@@ -297,7 +320,7 @@
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 40px);
|
||||
box-shadow: 0 20px 60px var(--shadow-lg, rgba(0,0,0,0.15));
|
||||
box-shadow: 0 20px 60px var(--shadow-lg, rgba(0, 0, 0, 0.15));
|
||||
animation: vc-scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import { Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -56,16 +52,96 @@ const MOCK_LIBRARIES: LibraryItem[] = [
|
||||
];
|
||||
|
||||
const MOCK_VOICES: VoiceItem[] = [
|
||||
{ id: "v-1", name: "磁性男声_深沉", voiceType: "male", language: "zh", duration: 45, tags: ["磁性", "深沉"], createdAt: "2026-06-28" },
|
||||
{ id: "v-2", name: "温暖男声_治愈", voiceType: "male", language: "zh", duration: 38, tags: ["温暖", "治愈"], createdAt: "2026-06-27" },
|
||||
{ id: "v-3", name: "活力女声_青春", voiceType: "female", language: "zh", duration: 52, tags: ["活力", "青春"], createdAt: "2026-06-26" },
|
||||
{ id: "v-4", name: "温柔女声_优雅", voiceType: "female", language: "zh", duration: 41, tags: ["温柔", "优雅"], createdAt: "2026-06-25" },
|
||||
{ id: "v-5", name: "可爱童声_活泼", voiceType: "child", language: "zh", duration: 35, tags: ["可爱", "活泼"], createdAt: "2026-06-24" },
|
||||
{ id: "v-6", name: "沉稳男声_专业", voiceType: "male", language: "en", duration: 48, tags: ["沉稳", "专业"], createdAt: "2026-06-23" },
|
||||
{ id: "v-7", name: "甜美女声_清新", voiceType: "female", language: "ja", duration: 42, tags: ["甜美", "清新"], createdAt: "2026-06-22" },
|
||||
{ id: "v-8", name: "沧桑男声_故事感", voiceType: "elderly", language: "zh", duration: 56, tags: ["沧桑", "故事感"], createdAt: "2026-06-21" },
|
||||
{ id: "v-9", name: "知性女声_商务", voiceType: "female", language: "ko", duration: 39, tags: ["知性", "商务"], createdAt: "2026-06-20" },
|
||||
{ id: "v-10", name: "阳光男声_运动", voiceType: "male", language: "zh", duration: 44, tags: ["阳光", "运动"], createdAt: "2026-06-19" },
|
||||
{
|
||||
id: "v-1",
|
||||
name: "磁性男声_深沉",
|
||||
voiceType: "male",
|
||||
language: "zh",
|
||||
duration: 45,
|
||||
tags: ["磁性", "深沉"],
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "v-2",
|
||||
name: "温暖男声_治愈",
|
||||
voiceType: "male",
|
||||
language: "zh",
|
||||
duration: 38,
|
||||
tags: ["温暖", "治愈"],
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "v-3",
|
||||
name: "活力女声_青春",
|
||||
voiceType: "female",
|
||||
language: "zh",
|
||||
duration: 52,
|
||||
tags: ["活力", "青春"],
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "v-4",
|
||||
name: "温柔女声_优雅",
|
||||
voiceType: "female",
|
||||
language: "zh",
|
||||
duration: 41,
|
||||
tags: ["温柔", "优雅"],
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "v-5",
|
||||
name: "可爱童声_活泼",
|
||||
voiceType: "child",
|
||||
language: "zh",
|
||||
duration: 35,
|
||||
tags: ["可爱", "活泼"],
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "v-6",
|
||||
name: "沉稳男声_专业",
|
||||
voiceType: "male",
|
||||
language: "en",
|
||||
duration: 48,
|
||||
tags: ["沉稳", "专业"],
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "v-7",
|
||||
name: "甜美女声_清新",
|
||||
voiceType: "female",
|
||||
language: "ja",
|
||||
duration: 42,
|
||||
tags: ["甜美", "清新"],
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "v-8",
|
||||
name: "沧桑男声_故事感",
|
||||
voiceType: "elderly",
|
||||
language: "zh",
|
||||
duration: 56,
|
||||
tags: ["沧桑", "故事感"],
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "v-9",
|
||||
name: "知性女声_商务",
|
||||
voiceType: "female",
|
||||
language: "ko",
|
||||
duration: 39,
|
||||
tags: ["知性", "商务"],
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "v-10",
|
||||
name: "阳光男声_运动",
|
||||
voiceType: "male",
|
||||
language: "zh",
|
||||
duration: 44,
|
||||
tags: ["阳光", "运动"],
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
@@ -73,19 +149,27 @@ const MOCK_VOICES: VoiceItem[] = [
|
||||
* ============================================================ */
|
||||
const voiceTypeLabel = (type: VoiceType) => {
|
||||
switch (type) {
|
||||
case "male": return "男声";
|
||||
case "female": return "女声";
|
||||
case "child": return "童声";
|
||||
case "elderly": return "老年";
|
||||
case "male":
|
||||
return "男声";
|
||||
case "female":
|
||||
return "女声";
|
||||
case "child":
|
||||
return "童声";
|
||||
case "elderly":
|
||||
return "老年";
|
||||
}
|
||||
};
|
||||
|
||||
const languageLabel = (lang: Language) => {
|
||||
switch (lang) {
|
||||
case "zh": return "中文";
|
||||
case "en": return "英文";
|
||||
case "ja": return "日文";
|
||||
case "ko": return "韩文";
|
||||
case "zh":
|
||||
return "中文";
|
||||
case "en":
|
||||
return "英文";
|
||||
case "ja":
|
||||
return "日文";
|
||||
case "ko":
|
||||
return "韩文";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,9 +269,7 @@ const VoiceCard: React.FC<{
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="xx-voice-time">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
<span className="xx-voice-time">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -249,7 +331,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
list = list.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
v.tags.some((tag) => tag.toLowerCase().includes(q))
|
||||
v.tags.some((tag) => tag.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -366,7 +448,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
className={`xx-voice-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
onClick={() => setActiveLibId(lib.id)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ flex: 1, margin: 0 }}>
|
||||
<SoundOutlined /> {lib.name}
|
||||
</h4>
|
||||
@@ -444,13 +532,27 @@ const VoiceLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-voices-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={() => setCreateModalOpen(true)}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建配音库
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
AI 生成配音
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
上传配音
|
||||
</Button>
|
||||
</div>
|
||||
@@ -492,9 +594,22 @@ const VoiceLibrary: React.FC = () => {
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</div>
|
||||
<Input
|
||||
@@ -505,7 +620,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
音色类型
|
||||
</div>
|
||||
<Select
|
||||
@@ -534,9 +655,22 @@ const VoiceLibrary: React.FC = () => {
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
配音文本
|
||||
</div>
|
||||
<Input.TextArea
|
||||
@@ -549,7 +683,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: "var(--font-size-sm)", color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
音色类型
|
||||
</div>
|
||||
<Select
|
||||
|
||||
@@ -44,7 +44,11 @@
|
||||
--primary-hover: var(--color-primary-500);
|
||||
--primary-dark: var(--color-primary-700);
|
||||
--primary-soft: var(--color-primary-50);
|
||||
--gradient-primary: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
--gradient-primary: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
|
||||
/* 辅色 — Emerald */
|
||||
--color-secondary-50: #ecfdf5;
|
||||
@@ -154,7 +158,8 @@
|
||||
--font-family-base:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
--font-family-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
--font-family-mono:
|
||||
"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
@@ -212,7 +217,8 @@
|
||||
--transition-slow: 300ms ease;
|
||||
--transition-slower: 500ms ease;
|
||||
|
||||
--transition-colors: color 200ms ease, background-color 200ms ease, border-color 200ms ease;
|
||||
--transition-colors:
|
||||
color 200ms ease, background-color 200ms ease, border-color 200ms ease;
|
||||
--transition-shadow: box-shadow 200ms ease;
|
||||
--transition-transform: transform 200ms ease;
|
||||
--transition-all: all 200ms ease;
|
||||
@@ -291,7 +297,11 @@
|
||||
--primary-hover: var(--color-primary-400, #a5b4fc);
|
||||
--primary-dark: var(--color-primary-600);
|
||||
--primary-soft: rgba(99, 102, 241, 0.15);
|
||||
--gradient-primary: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
--gradient-primary: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
|
||||
/* 辅色 */
|
||||
--secondary-color: var(--color-secondary-400, #34d399);
|
||||
@@ -371,7 +381,11 @@
|
||||
--primary-hover: var(--color-primary-400, #a5b4fc);
|
||||
--primary-dark: var(--color-primary-600);
|
||||
--primary-soft: rgba(99, 102, 241, 0.15);
|
||||
--gradient-primary: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
--gradient-primary: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
|
||||
--secondary-color: var(--color-secondary-400, #34d399);
|
||||
--accent-color: var(--color-accent-400, #fbbf24);
|
||||
|
||||
Reference in New Issue
Block a user