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

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:
2026-07-18 18:08:19 +08:00
committed by auto-approve-bot
parent 1ee779a566
commit 0fcb77b991
145 changed files with 10310 additions and 12869 deletions
@@ -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"
+7 -7
View File
@@ -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
+25 -38
View File
@@ -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
+15 -24
View File
@@ -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
+40 -52
View File
@@ -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
+21 -29
View File
@@ -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
+25 -27
View File
@@ -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
+10 -14
View File
@@ -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
+34 -45
View File
@@ -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,
});
})
+27 -32
View File
@@ -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
+12 -22
View File
@@ -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
+16 -23
View File
@@ -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
+18 -26
View File
@@ -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
+14 -14
View File
@@ -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"
+193 -240
View File
@@ -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">
MP3WAVM4A 10MB
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 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);
}