ci: Prettier纳入两层防御体系 (#520)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #520.
This commit is contained in:
@@ -9,72 +9,62 @@
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import "./AssetSelector.css";
|
||||
import { Input, Select, Button } from "@/components/ui";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import {
|
||||
MATERIAL_TYPE_LABELS,
|
||||
MATERIAL_TYPE_ICONS,
|
||||
QUALITY_OPTIONS,
|
||||
} from "@/api/editPlans";
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[];
|
||||
selectedIds?: string[];
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void;
|
||||
showQualityFilter?: boolean;
|
||||
showBatchSelect?: boolean;
|
||||
compact?: boolean;
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
type ViewMode = "grid" | "list"
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
|
||||
};
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none";
|
||||
if (score >= 90) return "excellent";
|
||||
if (score >= 70) return "good";
|
||||
if (score >= 50) return "fair";
|
||||
return "poor";
|
||||
};
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)";
|
||||
if (score >= 90) return "var(--success-color, #10b981)";
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)";
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)";
|
||||
return "var(--error-color, #ef4444)";
|
||||
};
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
|
||||
/* ──────────── 类型筛选选项 ──────────── */
|
||||
|
||||
@@ -83,7 +73,7 @@ const TYPE_OPTIONS = [
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
@@ -98,189 +88,171 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
compact = false,
|
||||
}) => {
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterQuality, setFilterQuality] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null);
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 });
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null);
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase();
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType);
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality);
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null &&
|
||||
a.quality_score >= opt.min! &&
|
||||
a.quality_score <= opt.max!,
|
||||
);
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [assets, searchText, filterType, filterQuality]);
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
/* ── 选中状态 ── */
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
/* ── 选择操作 ── */
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx);
|
||||
const end = Math.max(lastClickedIdx.current, idx);
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id);
|
||||
const newSet = new Set(selectedIds);
|
||||
rangeIds.forEach((id) => newSet.add(id));
|
||||
onSelectionChange(Array.from(newSet));
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds);
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id);
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id);
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet));
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx;
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
);
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([]);
|
||||
}, [onSelectionChange]);
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
/* ── 拖拽排序 ── */
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-asset",
|
||||
JSON.stringify(filteredAssets[idx]),
|
||||
);
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (
|
||||
selectedIds.length > 1 &&
|
||||
selectedIds.includes(filteredAssets[idx].id)
|
||||
) {
|
||||
const batchAssets = filteredAssets.filter((a) =>
|
||||
selectedIds.includes(a.id),
|
||||
);
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-assets",
|
||||
JSON.stringify(batchAssets),
|
||||
);
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx]);
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
setDragOverIdx(idx);
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx);
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const handleMouseEnter = useCallback(
|
||||
(asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPreviewAsset(asset);
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
});
|
||||
}, 400);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current);
|
||||
previewTimer.current = null;
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null);
|
||||
}, []);
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
};
|
||||
}, []);
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-checkbox]")) return;
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-checkbox]")) return
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
},
|
||||
[toggleSelect],
|
||||
);
|
||||
)
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
@@ -329,9 +301,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">
|
||||
已选 {selectedIds.length} 项
|
||||
</span>
|
||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
@@ -351,10 +321,10 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
/* ── 网格视图 ── */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
const qualityLevel = getQualityLevel(asset.quality_score);
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -380,15 +350,9 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
@@ -397,22 +361,18 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
</span>
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">
|
||||
{formatDuration(asset.duration)}
|
||||
</span>
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
@@ -434,16 +394,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 列表视图 ── */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -476,24 +436,21 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null &&
|
||||
` · ${formatDuration(asset.duration)}`}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,7 +465,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -516,10 +473,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<div
|
||||
className="as-preview-overlay"
|
||||
style={{ left: previewPos.x, top: previewPos.y }}
|
||||
>
|
||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
||||
@@ -535,20 +489,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{previewAsset.duration != null && (
|
||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && (
|
||||
<span>大小: {formatSize(previewAsset.size)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>质量分: {previewAsset.quality_score}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && (
|
||||
<span>标签: {previewAsset.tags.join(", ")}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetSelector;
|
||||
export default AssetSelector
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as AssetSelector } from "./AssetSelector";
|
||||
export type { AssetSelectorProps } from "./AssetSelector";
|
||||
export { default as AssetSelector } from "./AssetSelector"
|
||||
export type { AssetSelectorProps } from "./AssetSelector"
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* 语义化 HTML 结构:header + aside + main 三栏布局
|
||||
* 由 MainLayout 管理侧边栏状态,AppLayout 负责渲染结构
|
||||
*/
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import Header from "./Header";
|
||||
import React from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import Header from "./Header"
|
||||
|
||||
interface AppLayoutProps {
|
||||
sidebar: React.ReactNode;
|
||||
sidebar: React.ReactNode
|
||||
}
|
||||
|
||||
const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
|
||||
@@ -23,7 +23,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
export default AppLayout
|
||||
|
||||
@@ -2,27 +2,22 @@
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd";
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import type { MenuProps } from "antd";
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import "./Header.css";
|
||||
import React, { useState } from "react"
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd"
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import "./Header.css"
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const logoutMutation = useLogout()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps["items"] = [
|
||||
@@ -45,7 +40,7 @@ const Header: React.FC = () => {
|
||||
label: "退出登录",
|
||||
onClick: () => logoutMutation.mutateAsync(),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
@@ -55,19 +50,15 @@ const Header: React.FC = () => {
|
||||
location.pathname === "/" ||
|
||||
location.pathname === "/app" ||
|
||||
location.pathname === "/app/dashboard"
|
||||
);
|
||||
)
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="xx-top-nav">
|
||||
<div className="xx-top-nav-inner">
|
||||
<button
|
||||
className="xx-brand"
|
||||
type="button"
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
>
|
||||
<button className="xx-brand" type="button" onClick={() => navigate("/app/dashboard")}>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
</button>
|
||||
@@ -88,11 +79,7 @@ const Header: React.FC = () => {
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button
|
||||
className="xx-hamburger"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
|
||||
@@ -123,8 +110,8 @@ const Header: React.FC = () => {
|
||||
className={`xx-mobile-nav-item ${isActive(item.path) ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
navigate(item.path)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="xx-mobile-nav-icon">{item.icon}</span>
|
||||
@@ -134,7 +121,7 @@ const Header: React.FC = () => {
|
||||
</div>
|
||||
</Drawer>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default Header
|
||||
|
||||
@@ -9,39 +9,32 @@
|
||||
*
|
||||
* 侧边栏/导航具体内容 deferred to Tasks 1.3, 1.4
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import AppLayout from "./AppLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
import "./MainLayout.css";
|
||||
import React, { useState } from "react"
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"
|
||||
import AppLayout from "./AppLayout"
|
||||
import Sidebar from "./Sidebar"
|
||||
import "./MainLayout.css"
|
||||
|
||||
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
|
||||
export interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
});
|
||||
})
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
// 移动端默认折叠,桌面端默认展开
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768);
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768)
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
{/* 移动端遮罩层 —— 仅在侧边栏展开时由 CSS 显示 */}
|
||||
<div
|
||||
className="xx-sidebar-overlay"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="xx-sidebar-overlay" onClick={() => setCollapsed(true)} aria-hidden="true" />
|
||||
|
||||
<aside
|
||||
className={`xx-app-sidebar${collapsed ? " xx-collapsed" : ""}`}
|
||||
aria-label="侧边栏"
|
||||
>
|
||||
<aside className={`xx-app-sidebar${collapsed ? " xx-collapsed" : ""}`} aria-label="侧边栏">
|
||||
<div className="xx-sidebar-toggle">
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,9 +42,7 @@ const MainLayout: React.FC = () => {
|
||||
aria-label={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
<span className="xx-sidebar-toggle-label">
|
||||
{collapsed ? "展开" : "收起"}
|
||||
</span>
|
||||
<span className="xx-sidebar-toggle-label">{collapsed ? "展开" : "收起"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,13 +52,13 @@ const MainLayout: React.FC = () => {
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed }}>
|
||||
<AppLayout sidebar={sidebar} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
export default MainLayout
|
||||
|
||||
@@ -10,33 +10,33 @@
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import "./PageHead.css";
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import "./PageHead.css"
|
||||
|
||||
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string;
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode;
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[];
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode;
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean;
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||
@@ -66,46 +66,46 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }];
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items;
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
let currentPath = "";
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`;
|
||||
const title = ROUTE_TITLE_MAP[currentPath];
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1;
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1;
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
return items
|
||||
}
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -116,18 +116,18 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname);
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard";
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
@@ -137,42 +137,30 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className="xx-page-breadcrumb-item"
|
||||
>
|
||||
{index > 0 && (
|
||||
<RightOutlined className="xx-page-breadcrumb-separator" />
|
||||
)}
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(item.path!);
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<HomeOutlined className="xx-page-breadcrumb-home" />
|
||||
) : null}
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="xx-page-breadcrumb-current"
|
||||
aria-current="page"
|
||||
>
|
||||
{index === 0 ? (
|
||||
<HomeOutlined className="xx-page-breadcrumb-home" />
|
||||
) : null}
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -188,7 +176,7 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead;
|
||||
export default PageHead
|
||||
|
||||
@@ -9,44 +9,38 @@
|
||||
* - 折叠态:仅显示图标,隐藏文字(通过 SidebarContext 读取 collapsed 状态)
|
||||
* - 响应式:移动端自动折叠
|
||||
*/
|
||||
import React, { useContext } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { SidebarContext } from "./MainLayout";
|
||||
import { NAV_GROUPS } from "@/config/navigation";
|
||||
import "./Sidebar.css";
|
||||
import React, { useContext } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { SidebarContext } from "./MainLayout"
|
||||
import { NAV_GROUPS } from "@/config/navigation"
|
||||
import "./Sidebar.css"
|
||||
|
||||
/** 判断菜单项是否激活 */
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
);
|
||||
return pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
return pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { collapsed } = useContext(SidebarContext)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="xx-sidebar-group-title">{group.title}</div>
|
||||
)}
|
||||
{!collapsed && <div className="xx-sidebar-group-title">{group.title}</div>}
|
||||
<ul className="xx-sidebar-menu" role="menu">
|
||||
{group.items.map((item) => {
|
||||
const active = isMenuItemActive(location.pathname, item.path);
|
||||
const active = isMenuItemActive(location.pathname, item.path)
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
@@ -56,17 +50,15 @@ const Sidebar: React.FC = () => {
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
>
|
||||
<span className="xx-sidebar-menu-icon">{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<span className="xx-sidebar-menu-label">{item.label}</span>
|
||||
)}
|
||||
{!collapsed && <span className="xx-sidebar-menu-label">{item.label}</span>}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar
|
||||
|
||||
@@ -2,26 +2,25 @@
|
||||
* V21 Button 按钮
|
||||
* 封装 Ant Design Button,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button as AntButton } from "antd";
|
||||
import type { ButtonProps as AntButtonProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Button as AntButton } from "antd"
|
||||
import type { ButtonProps as AntButtonProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type ButtonType =
|
||||
"primary" | "secondary" | "ghost" | "text" | "danger" | "link";
|
||||
export type ButtonType = "primary" | "secondary" | "ghost" | "text" | "danger" | "link"
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
export type ButtonSize = "sm" | "md" | "lg"
|
||||
|
||||
export interface ButtonProps extends Omit<AntButtonProps, "type" | "size"> {
|
||||
/** 按钮类型 */
|
||||
buttonType?: ButtonType;
|
||||
buttonType?: ButtonType
|
||||
/** 按钮尺寸 */
|
||||
buttonSize?: ButtonSize;
|
||||
buttonSize?: ButtonSize
|
||||
/** 兼容 antd type(用于直接替换) */
|
||||
type?: AntButtonProps["type"];
|
||||
type?: AntButtonProps["type"]
|
||||
/** 兼容 antd size */
|
||||
size?: AntButtonProps["size"];
|
||||
size?: AntButtonProps["size"]
|
||||
}
|
||||
|
||||
const TYPE_CLASS_MAP: Record<ButtonType, string> = {
|
||||
@@ -31,29 +30,29 @@ const TYPE_CLASS_MAP: Record<ButtonType, string> = {
|
||||
text: "xx-btn-text",
|
||||
danger: "xx-btn-danger",
|
||||
link: "xx-btn-link",
|
||||
};
|
||||
}
|
||||
|
||||
const SIZE_CLASS_MAP: Record<ButtonSize, string> = {
|
||||
sm: "xx-btn-sm",
|
||||
md: "xx-btn-md",
|
||||
lg: "xx-btn-lg",
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 buttonType 映射到 antd type */
|
||||
const toAntdType = (bt: ButtonType): AntButtonProps["type"] => {
|
||||
switch (bt) {
|
||||
case "primary":
|
||||
return "primary";
|
||||
return "primary"
|
||||
case "danger":
|
||||
return "primary";
|
||||
return "primary"
|
||||
case "link":
|
||||
return "link";
|
||||
return "link"
|
||||
case "text":
|
||||
return "text";
|
||||
return "text"
|
||||
default:
|
||||
return "default";
|
||||
return "default"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
buttonType = "ghost",
|
||||
@@ -64,17 +63,16 @@ const Button: React.FC<ButtonProps> = ({
|
||||
size,
|
||||
...rest
|
||||
}) => {
|
||||
const antdType = type ?? toAntdType(buttonType);
|
||||
const antdType = type ?? toAntdType(buttonType)
|
||||
const antdSize =
|
||||
size ??
|
||||
(buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
|
||||
size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle")
|
||||
|
||||
const v21Class = classNames(
|
||||
"xx-btn",
|
||||
TYPE_CLASS_MAP[buttonType],
|
||||
SIZE_CLASS_MAP[buttonSize],
|
||||
className,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<AntButton
|
||||
@@ -86,7 +84,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
>
|
||||
{children}
|
||||
</AntButton>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Button;
|
||||
export default Button
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
* V21 Card 卡片
|
||||
* 封装 Ant Design Card,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Card as AntCard } from "antd";
|
||||
import type { CardProps as AntCardProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Card as AntCard } from "antd"
|
||||
import type { CardProps as AntCardProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface CardProps extends AntCardProps {
|
||||
/** 是否启用悬浮效果 */
|
||||
hover?: boolean;
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({ className, hover, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-card-v21",
|
||||
hover && "xx-card-hover",
|
||||
className,
|
||||
);
|
||||
return <AntCard className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-card-v21", hover && "xx-card-hover", className)
|
||||
return <AntCard className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export default Card;
|
||||
export default Card
|
||||
|
||||
@@ -3,70 +3,59 @@
|
||||
* 封装 Ant Design Input,应用 V21 设计系统样式
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from "react";
|
||||
import { Input as AntInput } from "antd";
|
||||
import type { InputProps as AntInputProps } from "antd";
|
||||
import type { InputRef } from "antd/es/input";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Input as AntInput } from "antd"
|
||||
import type { InputProps as AntInputProps } from "antd"
|
||||
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 {
|
||||
/** 是否处于错误状态 */
|
||||
error?: boolean;
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/** 基础 Input */
|
||||
const Input = React.forwardRef<InputRef, InputProps>(
|
||||
({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
return <AntInput ref={ref} className={v21Class} {...rest} />;
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
const Input = React.forwardRef<InputRef, InputProps>(({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntInput ref={ref} className={v21Class} {...rest} />
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
/** Search 搜索框 */
|
||||
const Search: React.FC<
|
||||
React.ComponentProps<typeof AntSearch> & { error?: boolean }
|
||||
> = ({ className, error, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
"xx-input-search",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
return <AntSearch className={v21Class} {...rest} />;
|
||||
};
|
||||
const Search: React.FC<React.ComponentProps<typeof AntSearch> & { error?: boolean }> = ({
|
||||
className,
|
||||
error,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-input", "xx-input-search", error && "xx-input-error", className)
|
||||
return <AntSearch className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** TextArea 文本域 */
|
||||
const TextArea: React.FC<
|
||||
React.ComponentProps<typeof AntTextArea> & { error?: boolean }
|
||||
> = ({ className, error, ...rest }) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className);
|
||||
return <AntTextArea className={v21Class} {...rest} />;
|
||||
};
|
||||
const TextArea: React.FC<React.ComponentProps<typeof AntTextArea> & { error?: boolean }> = ({
|
||||
className,
|
||||
error,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntTextArea className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** Password 密码框 */
|
||||
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);
|
||||
return <AntPassword ref={ref} className={v21Class} {...rest} />;
|
||||
});
|
||||
Password.displayName = "Password";
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntPassword ref={ref} className={v21Class} {...rest} />
|
||||
})
|
||||
Password.displayName = "Password"
|
||||
|
||||
export default Object.assign(Input, {
|
||||
Search,
|
||||
TextArea,
|
||||
Password,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -2,74 +2,69 @@
|
||||
* V21 Modal 对话框
|
||||
* 封装 Ant Design Modal,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Modal as AntModal } from "antd";
|
||||
import type { ModalProps as AntModalProps } from "antd";
|
||||
import type { ModalFuncProps } from "antd/es/modal";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { ModalProps as AntModalProps } from "antd"
|
||||
import type { ModalFuncProps } from "antd/es/modal"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface ModalProps extends AntModalProps {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
interface ModalComponent extends React.FC<ModalProps> {
|
||||
confirm: (config: ModalFuncProps) => ReturnType<typeof AntModal.confirm>;
|
||||
info: (config: ModalFuncProps) => ReturnType<typeof AntModal.info>;
|
||||
success: (config: ModalFuncProps) => ReturnType<typeof AntModal.success>;
|
||||
error: (config: ModalFuncProps) => ReturnType<typeof AntModal.error>;
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>;
|
||||
confirm: (config: ModalFuncProps) => ReturnType<typeof AntModal.confirm>
|
||||
info: (config: ModalFuncProps) => ReturnType<typeof AntModal.info>
|
||||
success: (config: ModalFuncProps) => ReturnType<typeof AntModal.success>
|
||||
error: (config: ModalFuncProps) => ReturnType<typeof AntModal.error>
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>
|
||||
}
|
||||
|
||||
const Modal: ModalComponent = ({
|
||||
className,
|
||||
v21 = true,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className);
|
||||
const Modal: ModalComponent = ({ className, v21 = true, children, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className)
|
||||
return (
|
||||
<AntModal className={v21Class} {...rest}>
|
||||
{children}
|
||||
</AntModal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
/** 确认对话框快捷方法 */
|
||||
Modal.confirm = (config: ModalFuncProps) => {
|
||||
return AntModal.confirm({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.info = (config: ModalFuncProps) => {
|
||||
return AntModal.info({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.success = (config: ModalFuncProps) => {
|
||||
return AntModal.success({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.error = (config: ModalFuncProps) => {
|
||||
return AntModal.error({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.warning = (config: ModalFuncProps) => {
|
||||
return AntModal.warning({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
export default Modal
|
||||
|
||||
@@ -2,31 +2,21 @@
|
||||
* V21 Select 选择器
|
||||
* 封装 Ant Design Select,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Select as AntSelect } from "antd";
|
||||
import type { SelectProps as AntSelectProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Select as AntSelect } from "antd"
|
||||
import type { SelectProps as AntSelectProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface SelectProps extends AntSelectProps {
|
||||
/** 自定义弹出层类名 */
|
||||
dropdownClassName?: string;
|
||||
dropdownClassName?: string
|
||||
}
|
||||
|
||||
const Select: React.FC<SelectProps> = ({
|
||||
className,
|
||||
dropdownClassName,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-select", className);
|
||||
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName);
|
||||
return (
|
||||
<AntSelect
|
||||
className={v21Class}
|
||||
popupClassName={v21DropdownClass}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const Select: React.FC<SelectProps> = ({ className, dropdownClassName, ...rest }) => {
|
||||
const v21Class = classNames("xx-select", className)
|
||||
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName)
|
||||
return <AntSelect className={v21Class} popupClassName={v21DropdownClass} {...rest} />
|
||||
}
|
||||
|
||||
export default Select;
|
||||
export default Select
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
* V21 Tag 标签 / Badge 徽标
|
||||
* 封装 Ant Design Tag 和 Badge,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Tag as AntTag, Badge as AntBadge } from "antd";
|
||||
import type {
|
||||
TagProps as AntTagProps,
|
||||
BadgeProps as AntBadgeProps,
|
||||
} from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Tag as AntTag, Badge as AntBadge } from "antd"
|
||||
import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type TagVariant = "primary" | "success" | "warning" | "error" | "info";
|
||||
export type TagVariant = "primary" | "success" | "warning" | "error" | "info"
|
||||
|
||||
export interface TagProps extends AntTagProps {
|
||||
/** V21 语义变体 */
|
||||
variant?: TagVariant;
|
||||
variant?: TagVariant
|
||||
}
|
||||
|
||||
const VARIANT_CLASS_MAP: Record<TagVariant, string> = {
|
||||
@@ -24,23 +21,19 @@ const VARIANT_CLASS_MAP: Record<TagVariant, string> = {
|
||||
warning: "xx-tag-warning",
|
||||
error: "xx-tag-error",
|
||||
info: "xx-tag-info",
|
||||
};
|
||||
}
|
||||
|
||||
/** Tag 标签 */
|
||||
const Tag: React.FC<TagProps> = ({ className, variant, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-tag",
|
||||
variant && VARIANT_CLASS_MAP[variant],
|
||||
className,
|
||||
);
|
||||
return <AntTag className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-tag", variant && VARIANT_CLASS_MAP[variant], className)
|
||||
return <AntTag className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** Badge 徽标 */
|
||||
const Badge: React.FC<AntBadgeProps> = ({ className, ...rest }) => {
|
||||
const v21Class = classNames("xx-badge", className);
|
||||
return <AntBadge className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-badge", className)
|
||||
return <AntBadge className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export { Tag, Badge };
|
||||
export default Tag;
|
||||
export { Tag, Badge }
|
||||
export default Tag
|
||||
|
||||
@@ -2,40 +2,32 @@
|
||||
* V21 Tooltip 文字提示 / Popover 气泡卡片
|
||||
* 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type TooltipProps = React.ComponentProps<typeof AntTooltip> & {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
};
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
/** Tooltip 文字提示 */
|
||||
const Tooltip: React.FC<TooltipProps> = ({
|
||||
overlayClassName,
|
||||
v21 = true,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-tooltip", overlayClassName);
|
||||
return <AntTooltip overlayClassName={v21Class} {...rest} />;
|
||||
};
|
||||
const Tooltip: React.FC<TooltipProps> = ({ overlayClassName, v21 = true, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-tooltip", overlayClassName)
|
||||
return <AntTooltip overlayClassName={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export type PopoverProps = React.ComponentProps<typeof AntPopover> & {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
};
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
/** Popover 气泡卡片 */
|
||||
const Popover: React.FC<PopoverProps> = ({
|
||||
overlayClassName,
|
||||
v21 = true,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-popover", overlayClassName);
|
||||
return <AntPopover overlayClassName={v21Class} {...rest} />;
|
||||
};
|
||||
const Popover: React.FC<PopoverProps> = ({ overlayClassName, v21 = true, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-popover", overlayClassName)
|
||||
return <AntPopover overlayClassName={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export { Tooltip, Popover };
|
||||
export default Tooltip;
|
||||
export { Tooltip, Popover }
|
||||
export default Tooltip
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
*/
|
||||
|
||||
// 组件导出
|
||||
export { default as Button } from "./Button";
|
||||
export type { ButtonProps, ButtonType, ButtonSize } from "./Button";
|
||||
export { default as Button } from "./Button"
|
||||
export type { ButtonProps, ButtonType, ButtonSize } from "./Button"
|
||||
|
||||
export { default as Input } from "./Input";
|
||||
export type { InputProps } from "./Input";
|
||||
export { default as Input } from "./Input"
|
||||
export type { InputProps } from "./Input"
|
||||
|
||||
export { default as Select } from "./Select";
|
||||
export type { SelectProps } from "./Select";
|
||||
export { default as Select } from "./Select"
|
||||
export type { SelectProps } from "./Select"
|
||||
|
||||
export { default as Modal } from "./Modal";
|
||||
export type { ModalProps } from "./Modal";
|
||||
export { default as Modal } from "./Modal"
|
||||
export type { ModalProps } from "./Modal"
|
||||
|
||||
export { default as Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export { default as Card } from "./Card"
|
||||
export type { CardProps } from "./Card"
|
||||
|
||||
export { Tag, Badge } from "./Tag";
|
||||
export type { TagProps, TagVariant } from "./Tag";
|
||||
export { Tag, Badge } from "./Tag"
|
||||
export type { TagProps, TagVariant } from "./Tag"
|
||||
|
||||
export { Tooltip, Popover } from "./Tooltip";
|
||||
export type { TooltipProps, PopoverProps } from "./Tooltip";
|
||||
export { Tooltip, Popover } from "./Tooltip"
|
||||
export type { TooltipProps, PopoverProps } from "./Tooltip"
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } 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-modal.css";
|
||||
import React, { useState, useCallback, useRef, useEffect } 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-modal.css"
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
@@ -36,352 +36,331 @@ const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ── 常量 ───────────────────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
const MAX_RECORD_SECONDS = 5 * 60;
|
||||
const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [voiceDescription, setVoiceDescription] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// 录音状态
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1);
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`;
|
||||
cloneCounterRef.current += 1;
|
||||
return name;
|
||||
}, []);
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName(getNextDefaultName());
|
||||
setVoiceDescription("");
|
||||
setSelectedFile(null);
|
||||
setDragActive(false);
|
||||
setErrorMessage("");
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
audioChunksRef.current = [];
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
}, [getNextDefaultName]);
|
||||
mediaRecorderRef.current = null
|
||||
}, [getNextDefaultName])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState]);
|
||||
}, [open, resetState])
|
||||
|
||||
/* ── 文件验证 ──────────────────────────────────────── */
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件";
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试";
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file);
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
setSelectedFile(null);
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
setDragActive(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file);
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
setSelectedFile(null);
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
})
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
||||
setRecordedBlob(blob);
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
// 清除上传的文件
|
||||
setSelectedFile(null);
|
||||
setSelectedFile(null)
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
setErrorMessage("");
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
setErrorMessage("")
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1;
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到 5 分钟上限,自动停止录制
|
||||
setTimeout(() => {
|
||||
setIsRecording(false);
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||||
}, 0);
|
||||
return MAX_RECORD_SECONDS;
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 表单验证 ──────────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null;
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
const name = voiceName.trim();
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称";
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间";
|
||||
return "音色名称需在 2-20 个字符之间"
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音";
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formError = validateForm();
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError);
|
||||
return;
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading");
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File;
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile;
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File(
|
||||
[recordedBlob!],
|
||||
`recorded-${Date.now()}.webm`,
|
||||
{
|
||||
type: "audio/webm",
|
||||
},
|
||||
);
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileToUpload);
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning");
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
});
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input");
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
|
||||
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 当前进度索引 */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1;
|
||||
return 1
|
||||
case "done":
|
||||
return 2;
|
||||
return 2
|
||||
default:
|
||||
return -1;
|
||||
return -1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const progressIndex = getProgressIndex();
|
||||
const progressIndex = getProgressIndex()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -428,9 +407,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/20
|
||||
</div>
|
||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
@@ -444,17 +421,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-clonemodal-upload-icon">
|
||||
{selectedFile ? "📄" : "🎵"}
|
||||
</div>
|
||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||
<p className="xx-clonemodal-upload-title">
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">
|
||||
支持 MP3、WAV、M4A 格式,最大 10MB
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -516,9 +487,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
maxLength={100}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/100
|
||||
</div>
|
||||
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
@@ -532,9 +501,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -542,11 +509,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
@@ -559,15 +522,15 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const isActive = idx === progressIndex
|
||||
const isDone = idx < progressIndex
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
@@ -577,15 +540,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-clonemodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -595,20 +554,14 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -643,7 +596,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneModal;
|
||||
export default CloneModal
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active)
|
||||
.xx-clonemodal-step-number {
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user