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

This commit is contained in:
CI Test
2026-07-01 12:26:48 +08:00
parent 3e10860f02
commit f0eed0ca3f
45 changed files with 1339 additions and 409 deletions
+2 -4
View File
@@ -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;
}
}
/* ============================================================
+8 -2
View File
@@ -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}
+3 -1
View File
@@ -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);
+4 -7
View File
@@ -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",
+4 -1
View File
@@ -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>
);
+7 -11
View File
@@ -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";
+1 -4
View File
@@ -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}
+3 -2
View File
@@ -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 -1
View File
@@ -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";
+1 -4
View File
@@ -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";
+4 -5
View File
@@ -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>
+1 -1
View File
@@ -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));
}
/* ── 卡片头部 ───────────────────────────────────────────── */
+170 -33
View File
@@ -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
+1 -4
View File
@@ -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>
+5 -1
View File
@@ -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> MP4AVIMOVMKV 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>
+62 -32
View File
@@ -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>
+5 -1
View File
@@ -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;
}
+2 -4
View File
@@ -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>
+46 -11
View File
@@ -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>
+42 -16
View File
@@ -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;
+269 -53
View File
@@ -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>
+9 -3
View File
@@ -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 {
+2 -10
View File
@@ -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">
+13 -3
View File
@@ -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) {
+3 -1
View File
@@ -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" />
+19 -5
View File
@@ -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)),
);
/** 切换收藏 */
+239 -50
View File
@@ -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) => (
+12 -4
View File
@@ -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>
+36 -13
View File
@@ -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;
}
+177 -37
View File
@@ -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
+19 -5
View File
@@ -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);