diff --git a/apps/web/src/components/modals/CloneVoiceModal.tsx b/apps/web/src/components/modals/CloneVoiceModal.tsx deleted file mode 100644 index bf37c4d02..000000000 --- a/apps/web/src/components/modals/CloneVoiceModal.tsx +++ /dev/null @@ -1,290 +0,0 @@ -/** - * CloneVoiceModal — 音色克隆弹窗 - * - * 三步骤状态:input → uploading → success - * 支持上传音频文件或直接录制(mock,无真实录音) - * - * V21 Design System — 零 antd 直接导入 - */ -import React, { useState, useCallback, useRef } from "react"; -import { Modal, Button } from "@/components/ui"; -import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; -import type { VoiceClone } from "@/api/voiceClone"; -import { uploadAsset } from "@/api/assets"; -import "./clone-voice-modal.css"; - -/* ── 类型定义 ───────────────────────────────────────────── */ - -type ModalStep = "input" | "uploading" | "success"; - -export interface CloneVoiceModalProps { - /** 弹窗是否可见 */ - open: boolean; - /** 关闭弹窗回调 */ - onClose: () => void; - /** 克隆成功回调(返回新创建的音色) */ - onSuccess?: (voice: VoiceClone) => void; -} - -/* ── 默认音色名称计数器 ─────────────────────────────────── */ - -let cloneCounter = 1; - -const getNextDefaultName = (): string => { - const name = `我的声音 ${cloneCounter}`; - cloneCounter += 1; - return name; -}; - -/* ── 组件 ───────────────────────────────────────────────── */ - -const CloneVoiceModal: React.FC = ({ - open, - onClose, - onSuccess, -}) => { - const [step, setStep] = useState("input"); - const [voiceName, setVoiceName] = useState(""); - const [isRecording, setIsRecording] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - const [dragActive, setDragActive] = useState(false); - const fileInputRef = useRef(null); - - /** 重置弹窗状态 */ - const resetState = useCallback(() => { - setStep("input"); - setVoiceName(""); - setSelectedFile(null); - setIsRecording(false); - setDragActive(false); - }, []); - - /** 关闭弹窗 */ - const handleClose = useCallback(() => { - resetState(); - onClose(); - }, [resetState, onClose]); - - /** 上传区域点击 */ - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - /** 文件选择 */ - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setSelectedFile(file); - // 清除之前的录制状态 - setIsRecording(false); - } - // 清空 input 以允许重复选择同一文件 - e.target.value = ""; - }; - - /** 拖拽事件 */ - const handleDrag = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive(true); - } else if (e.type === "dragleave") { - setDragActive(false); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragActive(false); - const file = e.dataTransfer.files?.[0]; - if (file) { - const ext = file.name.split(".").pop()?.toLowerCase(); - if (ext === "mp3" || ext === "wav") { - setSelectedFile(file); - setIsRecording(false); - } - } - }; - - /** 录制按钮(mock) */ - const handleRecord = () => { - setIsRecording((prev) => !prev); - if (!isRecording) { - // 开始录制 — 清除已选文件 - setSelectedFile(null); - } - }; - - /** 开始克隆 */ - const handleStartClone = async () => { - const name = voiceName.trim() || getNextDefaultName(); - setStep("uploading"); - - try { - // 先上传音频文件获取真实 URL - let audioUrl: string; - if (selectedFile) { - const formData = new FormData(); - formData.append("file", selectedFile); - formData.append("kind", "voice"); - const uploadResult = await uploadAsset(formData); - audioUrl = uploadResult.url; - } else { - // 录制功能暂未实现,提示用户上传 - setStep("input"); - return; - } - - // 提交克隆请求 - const result = await createVoiceClone({ - name, - audio_url: audioUrl, - }); - - setStep("success"); - - // 2秒后自动关闭 - setTimeout(() => { - onSuccess?.(toVoiceClone(result)); - handleClose(); - }, 2000); - } catch { - setStep("input"); - } - }; - - /** 弹窗打开时初始化默认名称 */ - const handleAfterOpenChange = (visible: boolean) => { - if (visible) { - setVoiceName(getNextDefaultName()); - } - }; - - const canStart = selectedFile || isRecording; - - return ( - - {/* ── 输入步骤 ──────────────────────────────────── */} - {step === "input" && ( -
- {/* 音色名称 */} -
- - setVoiceName(e.target.value)} - placeholder="输入音色名称" - /> -
- - {/* 上传区域 */} -
- -
-
🎵
-

- {selectedFile ? selectedFile.name : "拖拽音频文件到此处"} -

-

支持 MP3、WAV 格式

- -
-
- - {/* 或分隔 */} -
-
- -
-
- - {/* 录制区域 */} -
- -
-

- {isRecording - ? "录制中…再次点击停止" - : "点击按钮开始录制你的声音"} -

- -
-
- - {/* 提示 */} -
- 💡 - - 建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳 - -
- - {/* 底部按钮 */} -
- - -
-
- )} - - {/* ── 上传中步骤 ────────────────────────────────── */} - {step === "uploading" && ( -
-
-

正在克隆你的音色…

-

AI 正在分析你的声音特征,请稍候

-
- )} - - {/* ── 成功步骤 ──────────────────────────────────── */} - {step === "success" && ( -
-
-

克隆已提交

-

- 音色正在生成中,完成后将出现在列表中 -

-
- )} - - ); -}; - -export default CloneVoiceModal; diff --git a/apps/web/src/components/modals/clone-voice-modal.css b/apps/web/src/components/modals/clone-voice-modal.css deleted file mode 100644 index 589dbb8c3..000000000 --- a/apps/web/src/components/modals/clone-voice-modal.css +++ /dev/null @@ -1,325 +0,0 @@ -/** - * CloneVoiceModal — V21 Design System - * - * 音色克隆弹窗样式 - * 三步骤状态:input → uploading → success - */ - -/* ── 弹窗内容区 ─────────────────────────────────────────── */ - -.cvm-body { - display: flex; - flex-direction: column; - gap: 20px; -} - -/* ── 表单区 ─────────────────────────────────────────────── */ - -.cvm-field { - display: flex; - flex-direction: column; - gap: 6px; -} - -.cvm-label { - font-size: 13px; - font-weight: 600; - color: var(--text-secondary, #475467); -} - -.cvm-input { - width: 100%; - padding: 10px 14px; - border: 1px solid var(--line, #e4e7ec); - border-radius: var(--radius-sm); - background: var(--bg-surface, #fff); - color: var(--text-primary, #101828); - font-size: 14px; - line-height: 1.5; - 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); -} - -.cvm-input::placeholder { - color: var(--muted, #98a2b3); -} - -/* ── 上传区域 ───────────────────────────────────────────── */ - -.cvm-upload-zone { - border: 2px dashed var(--line, #e4e7ec); - border-radius: var(--radius-md); - padding: 28px 20px; - text-align: center; - background: var(--bg-subtle, #f8fafc); - cursor: pointer; - transition: - border-color 0.2s, - background 0.2s; -} - -.cvm-upload-zone:hover { - border-color: var(--primary, #6366f1); - background: color-mix(in srgb, var(--primary-color) 4%, transparent); -} - -.cvm-upload-zone.cvm-upload-zone--active { - border-color: var(--primary, #6366f1); - background: color-mix(in srgb, var(--primary-color) 6%, transparent); -} - -.cvm-upload-icon { - font-size: 36px; - margin-bottom: 8px; - line-height: 1; -} - -.cvm-upload-title { - font-size: 14px; - font-weight: 600; - color: var(--text-primary, #101828); - margin: 0 0 4px; -} - -.cvm-upload-hint { - font-size: 13px; - color: var(--muted, #98a2b3); - margin: 0; -} - -/* ── 或分隔线 ───────────────────────────────────────────── */ - -.cvm-divider { - display: flex; - align-items: center; - gap: 16px; - margin: 4px 0; -} - -.cvm-divider-line { - flex: 1; - height: 1px; - background: var(--line, #e4e7ec); -} - -.cvm-divider-text { - font-size: 13px; - color: var(--muted, #98a2b3); - flex-shrink: 0; -} - -/* ── 录制区域 ───────────────────────────────────────────── */ - -.cvm-record-area { - border: 1px solid var(--line, #e4e7ec); - border-radius: var(--radius-md); - padding: 24px; - text-align: center; -} - -.cvm-record-hint { - font-size: 13px; - color: var(--muted, #98a2b3); - margin: 0 0 14px; -} - -.cvm-record-btn { - width: 80px; - height: 80px; - border-radius: 50%; - border: none; - cursor: pointer; - font-size: 32px; - line-height: 1; - padding: 0; - 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; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.cvm-record-btn:hover { - transform: scale(1.06); - box-shadow: 0 6px 20px - color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent); -} - -.cvm-record-btn:active { - transform: scale(0.96); -} - -.cvm-record-btn--recording { - animation: cvm-pulse 1.2s ease-in-out infinite; -} - -@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); - } -} - -/* ── 提示条 ─────────────────────────────────────────────── */ - -.cvm-tip { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 12px 16px; - background: var(--warning-soft, #fef3c7); - border-radius: var(--radius-sm); - font-size: 13px; - color: var(--warning-color, #92400e); - line-height: 1.5; -} - -.cvm-tip-icon { - flex-shrink: 0; - font-size: 14px; - line-height: 1.5; -} - -/* ── 底部按钮 ───────────────────────────────────────────── */ - -.cvm-footer { - display: flex; - gap: 12px; - margin-top: 4px; -} - -.cvm-footer .xx-btn { - flex: 1; -} - -/* ── 上传中状态 ─────────────────────────────────────────── */ - -.cvm-uploading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 20px; - gap: 16px; -} - -.cvm-uploading-spinner { - width: 48px; - height: 48px; - border: 3px solid var(--line, #e4e7ec); - border-top-color: var(--primary, #6366f1); - border-radius: 50%; - animation: cvm-spin 0.8s linear infinite; -} - -@keyframes cvm-spin { - to { - transform: rotate(360deg); - } -} - -.cvm-uploading-text { - font-size: 15px; - font-weight: 500; - color: var(--text-primary, #101828); - margin: 0; -} - -.cvm-uploading-sub { - font-size: 13px; - color: var(--muted, #98a2b3); - margin: 0; -} - -/* ── 成功状态 ───────────────────────────────────────────── */ - -.cvm-success { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 20px; - gap: 12px; -} - -.cvm-success-icon { - font-size: 56px; - line-height: 1; -} - -.cvm-success-title { - font-size: 18px; - font-weight: 700; - color: var(--text-primary, #101828); - margin: 0; -} - -.cvm-success-desc { - font-size: 14px; - color: var(--muted, #98a2b3); - margin: 0; -} - -/* ── 响应式 ─────────────────────────────────────────────── */ - -@media (max-width: 768px) { - .cvm-overlay { - padding: var(--space-md); - } - - .cvm-modal { - width: 100%; - max-width: 100%; - padding: var(--space-lg); - } -} - -@media (max-width: 576px) { - .cvm-upload-zone { - padding: 20px 14px; - } - - .cvm-record-btn { - width: 64px; - height: 64px; - font-size: 26px; - } - - .cvm-footer { - flex-direction: column; - } -} - -@media (max-width: 480px) { - .cvm-record-btn { - width: 60px; - height: 60px; - } - - .cvm-tip { - font-size: 12px; - padding: var(--space-sm); - } -} diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css index 1124d1ad9..292852393 100644 --- a/apps/web/src/components/ui/ui.css +++ b/apps/web/src/components/ui/ui.css @@ -532,3 +532,23 @@ padding: 8px 16px !important; } } + + +/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */ +/* AdminComingSoon 等页面使用 时需要 */ +/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */ + +.xx-card .ant-card-head { + border-bottom: 1px solid var(--border-color); + padding: 20px 24px; +} + +.xx-card .ant-card-head-title { + font-weight: 800; + font-size: 17px; + color: var(--text-primary); +} + +.xx-card .ant-card-body { + padding: 24px; +} diff --git a/apps/web/src/pages/accounts/Accounts.tsx b/apps/web/src/pages/accounts/Accounts.tsx index 30b2d6076..c4d5e34d1 100644 --- a/apps/web/src/pages/accounts/Accounts.tsx +++ b/apps/web/src/pages/accounts/Accounts.tsx @@ -153,7 +153,7 @@ const Accounts: React.FC = () => { }, }); - /** 绑定 mutation(mock) */ + /** 绑定 mutation */ const bindMutation = useMutation({ mutationFn: bindAccount, onSuccess: () => { @@ -165,7 +165,7 @@ const Accounts: React.FC = () => { }, }); - /** 绑定新账号(mock:直接创建) */ + /** 绑定新账号 */ const handleBind = (platformId: PlatformId) => { const platform = PLATFORMS.find((p) => p.id === platformId); if (!platform) return; diff --git a/apps/web/src/pages/dashboard/Dashboard.tsx b/apps/web/src/pages/dashboard/Dashboard.tsx index f278b112e..4cb01fd96 100644 --- a/apps/web/src/pages/dashboard/Dashboard.tsx +++ b/apps/web/src/pages/dashboard/Dashboard.tsx @@ -29,6 +29,7 @@ interface KpiItem { accent: string; } +// TODO: kpiData 当前使用硬编码 mock 数据,待后端提供 Dashboard 统计 API 后替换 const kpiData: KpiItem[] = [ { key: "projects", @@ -81,6 +82,7 @@ interface QuickEntry { path: string; } +// TODO: quickEntries 描述中含硬编码计数(如 486个素材),待后端 API 后动态化 const quickEntries: QuickEntry[] = [ { id: "titles", @@ -135,6 +137,7 @@ const statusLabel: Record = { failed: "失败", }; +// TODO: recentTasks 当前使用硬编码 mock 数据,待后端提供最近任务 API 后替换 const recentTasks: RecentTask[] = [ { id: "t-1", diff --git a/apps/web/src/pages/titles/TitleLibrary.tsx b/apps/web/src/pages/titles/TitleLibrary.tsx index 7d07fe7b8..36eac804a 100644 --- a/apps/web/src/pages/titles/TitleLibrary.tsx +++ b/apps/web/src/pages/titles/TitleLibrary.tsx @@ -55,13 +55,9 @@ interface TitleData { /* ============================================================ * Mock 数据 * ============================================================ */ +// TODO: 分类数据当前为前端硬编码 mock,待后端提供标题分类 API 后替换 const MOCK_CATEGORIES: CategoryItem[] = [ - { id: "cat-all", name: "全部标题", count: 15 }, - { id: "cat-1", name: "美食探店", count: 4 }, - { id: "cat-2", name: "科技数码", count: 3 }, - { id: "cat-3", name: "生活日常", count: 4 }, - { id: "cat-4", name: "美妆穿搭", count: 2 }, - { id: "cat-5", name: "教育学习", count: 2 }, + { id: "cat-all", name: "全部标题", count: 0 }, ]; /** 后端 TitleItem → 前端 TitleData 映射 */ diff --git a/apps/web/src/pages/voice-clone/VoiceClone.tsx b/apps/web/src/pages/voice-clone/VoiceClone.tsx index 4fad8a6f8..13a3fd448 100644 --- a/apps/web/src/pages/voice-clone/VoiceClone.tsx +++ b/apps/web/src/pages/voice-clone/VoiceClone.tsx @@ -18,7 +18,7 @@ import { CloseCircleOutlined, } from "@ant-design/icons"; import PageHead from "@/components/layout/PageHead"; -import CloneVoiceModal from "@/components/modals/CloneVoiceModal"; +import CloneModal from "@/components/voice/CloneModal"; import { getVoiceClones, deleteVoiceClone, @@ -356,7 +356,7 @@ const VoiceClone: React.FC = () => { )} {/* 克隆音色弹窗 */} - setCloneModalOpen(false)} onSuccess={() => {