Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc4997874a | |||
| f8f6082cbd | |||
| 203f001c1d |
@@ -1,5 +1,273 @@
|
||||
/**
|
||||
* BGM 选择器入口(向后兼容)
|
||||
* 实际实现位于 ./bgm-selector/ 目录
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
export { default } from "./bgm-selector"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd"
|
||||
import {
|
||||
getBgmPresets,
|
||||
type BgmPreset,
|
||||
type BgmCategory,
|
||||
type BgmMixConfig,
|
||||
DEFAULT_BGM_MIX_CONFIG,
|
||||
} from "@/api/bgm"
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all"
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
]
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface BgmSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
}, [open, loadPresets])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(bgm.url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
audio.onended = () => setPreviewingId(null)
|
||||
setPreviewingId(bgm.id)
|
||||
},
|
||||
[previewingId],
|
||||
)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgm.id,
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* ── 搜索框 ── */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 分类标签 ── */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── BGM 列表 ── */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && <div className="bgm-empty">暂无 BGM 数据</div>}
|
||||
{presets.map((bgm) => {
|
||||
const isSelected = config.music_id === bgm.id
|
||||
const isPlaying = previewingId === bgm.id
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
className={`bgm-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => handleSelect(bgm)}
|
||||
>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">
|
||||
{Math.floor(bgm.duration / 60)}:
|
||||
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(bgm)
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 混音配置 ── */}
|
||||
{config.enabled && config.music_id && (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={handleClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入 <span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出 <span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default BgmSelector
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from "react"
|
||||
import type { BgmPreset } from "@/api/bgm"
|
||||
|
||||
interface BgmItemProps {
|
||||
bgm: BgmPreset
|
||||
isSelected: boolean
|
||||
isPlaying: boolean
|
||||
onSelect: () => void
|
||||
onPreview: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 BGM 列表项组件
|
||||
*/
|
||||
export const BgmItem: React.FC<BgmItemProps> = ({
|
||||
bgm,
|
||||
isSelected,
|
||||
isPlaying,
|
||||
onSelect,
|
||||
onPreview,
|
||||
}) => {
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = String(Math.floor(seconds % 60)).padStart(2, "0")
|
||||
return `${mins}:${secs}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bgm-item${isSelected ? " selected" : ""}`} onClick={onSelect}>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">{formatDuration(bgm.duration)}</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview()
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react"
|
||||
import { Slider } from "antd"
|
||||
import type { BgmMixConfig as BgmMixConfigType, BgmPreset } from "@/api/bgm"
|
||||
|
||||
interface BgmMixConfigProps {
|
||||
config: BgmMixConfigType
|
||||
selectedBgm: BgmPreset | undefined
|
||||
onChange: (config: BgmMixConfigType) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* BGM 混音配置面板
|
||||
* 音量、淡入淡出、人声闪避等设置
|
||||
*/
|
||||
export const BgmMixConfig: React.FC<BgmMixConfigProps> = ({
|
||||
config,
|
||||
selectedBgm,
|
||||
onChange,
|
||||
onClear,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={onClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入 <span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出 <span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Input, Tag } from "antd"
|
||||
import { type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG } from "@/api/bgm"
|
||||
import { useBgmSelector, CATEGORY_LIST } from "./useBgmSelector"
|
||||
import { BgmItem } from "./BgmItem"
|
||||
import { BgmMixConfig as BgmMixConfigPanel } from "./BgmMixConfig"
|
||||
|
||||
const { Search } = Input
|
||||
|
||||
interface BgmSelectorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const {
|
||||
presets,
|
||||
loading,
|
||||
activeCategory,
|
||||
setActiveCategory,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewingId,
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
} = useBgmSelector(open)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgmId: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgmId,
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
)
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
stopPreview()
|
||||
onClose()
|
||||
}, [stopPreview, onClose])
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
stopPreview()
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
|
||||
}, [stopPreview, onChange])
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分类标签 */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* BGM 列表 */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && <div className="bgm-empty">暂无 BGM 数据</div>}
|
||||
{presets.map((bgm) => (
|
||||
<BgmItem
|
||||
key={bgm.id}
|
||||
bgm={bgm}
|
||||
isSelected={config.music_id === bgm.id}
|
||||
isPlaying={previewingId === bgm.id}
|
||||
onSelect={() => handleSelect(bgm.id)}
|
||||
onPreview={() => handlePreview(bgm)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 混音配置 */}
|
||||
{config.enabled && config.music_id && (
|
||||
<BgmMixConfigPanel
|
||||
config={config}
|
||||
selectedBgm={selectedBgm}
|
||||
onChange={onChange}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default BgmSelector
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { message } from "antd"
|
||||
import { getBgmPresets, type BgmPreset, type BgmCategory } from "@/api/bgm"
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
export const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all"
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
]
|
||||
|
||||
/**
|
||||
* BGM 选择器数据与交互 Hook
|
||||
* 封装列表加载、搜索、分类筛选、试听播放逻辑
|
||||
*/
|
||||
export function useBgmSelector(open: boolean) {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets()
|
||||
}, [open, loadPresets])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(bgm.url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
audio.onended = () => setPreviewingId(null)
|
||||
setPreviewingId(bgm.id)
|
||||
},
|
||||
[previewingId],
|
||||
)
|
||||
|
||||
/* ── 停止播放(关闭/移除时调用) ── */
|
||||
const stopPreview = useCallback(() => {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
presets,
|
||||
loading,
|
||||
activeCategory,
|
||||
setActiveCategory,
|
||||
keyword,
|
||||
setKeyword,
|
||||
previewingId,
|
||||
loadPresets,
|
||||
handlePreview,
|
||||
stopPreview,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+46
-258
@@ -1,300 +1,88 @@
|
||||
/**
|
||||
* 任务历史页面 — V21 设计系统
|
||||
* 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态
|
||||
* 使用 useQuery 对接后端真实 API(api/tasks.ts)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui"
|
||||
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
|
||||
import React from "react"
|
||||
import { PageHeader, LoadingState, ErrorState, EmptyState } from "./components/States"
|
||||
import { HistoryTabs, TaskItem, Pagination } from "./components/TaskList"
|
||||
import { useTaskHistory } from "./hooks/useTaskHistory"
|
||||
import "./history.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type TaskStatus = "completed" | "processing" | "pending" | "failed"
|
||||
|
||||
const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
pending: "排队中",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
/** 将后端 status 字符串映射为前端 TaskStatus */
|
||||
const normalizeStatus = (s: string): TaskStatus => {
|
||||
const map: Record<string, TaskStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
success: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
pending: "pending",
|
||||
queued: "pending",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "pending"
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return "—"
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Tab 配置
|
||||
* ============================================================ */
|
||||
interface TabConfig {
|
||||
key: string
|
||||
label: string
|
||||
statusFilter?: TaskStatus
|
||||
}
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "processing", label: "进行中", statusFilter: "processing" },
|
||||
{ key: "completed", label: "已完成", statusFilter: "completed" },
|
||||
{ key: "failed", label: "失败", statusFilter: "failed" },
|
||||
]
|
||||
|
||||
/* ============================================================
|
||||
* 分页配置
|
||||
* ============================================================ */
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/* ============================================================
|
||||
* 组件
|
||||
* ============================================================ */
|
||||
const TaskHistory: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// ── 获取任务列表 ──
|
||||
const {
|
||||
data: tasks = [],
|
||||
activeTab,
|
||||
currentPage,
|
||||
totalPages,
|
||||
tabs,
|
||||
tabCounts,
|
||||
paginatedTasks,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
retryLoading,
|
||||
setCurrentPage,
|
||||
handleTabChange,
|
||||
handleRetry,
|
||||
handleView,
|
||||
refetch,
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
} = useTaskHistory()
|
||||
|
||||
// ── 重试任务 mutation ──
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||||
},
|
||||
})
|
||||
|
||||
// 将后端数据映射为页面展示用的结构
|
||||
const mappedTasks = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.user_message || t.task_type,
|
||||
type: t.task_type,
|
||||
template: t.template_id,
|
||||
status: normalizeStatus(t.status),
|
||||
date: formatDate(t.created_at),
|
||||
duration: undefined as string | undefined,
|
||||
progress: t.progress,
|
||||
retryable: t.retryable,
|
||||
errorMessage: t.error_message,
|
||||
}))
|
||||
|
||||
// 获取当前 Tab 的筛选状态
|
||||
const currentTab = tabs.find((t) => t.key === activeTab)
|
||||
const statusFilter = currentTab?.statusFilter
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = statusFilter
|
||||
? mappedTasks.filter((t) => t.status === statusFilter)
|
||||
: mappedTasks
|
||||
|
||||
// 计算各 Tab 的数量
|
||||
const tabCounts: Record<string, number> = {
|
||||
all: mappedTasks.length,
|
||||
processing: mappedTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mappedTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mappedTasks.filter((t) => t.status === "failed").length,
|
||||
}
|
||||
|
||||
// 分页
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
|
||||
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
// 切换 Tab 时重置页码
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// 重试任务
|
||||
const handleRetry = (taskId: string) => {
|
||||
retryMutation.mutate(taskId)
|
||||
}
|
||||
|
||||
// 查看任务详情
|
||||
const handleView = (taskId: string) => {
|
||||
// TODO: 跳转到任务详情页(待路由实现)
|
||||
console.log("查看任务:", taskId)
|
||||
}
|
||||
|
||||
// ── Loading 状态 ──
|
||||
// Loading 状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">⏳</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
<PageHeader />
|
||||
<LoadingState />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Error 状态 ──
|
||||
// Error 状态
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
<PageHeader />
|
||||
<ErrorState message={error?.message} onRetry={() => refetch()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalCount = tabCounts[activeTab] ?? paginatedTasks.length
|
||||
|
||||
return (
|
||||
<div className="xx-history-page">
|
||||
{/* ── 页面头部 ──────────────────────────────────────────── */}
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
<PageHeader />
|
||||
|
||||
{/* ── Tab 切换 ──────────────────────────────────────────── */}
|
||||
<div className="xx-history-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => handleTabChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<HistoryTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
tabCounts={tabCounts}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* ── 任务列表 ──────────────────────────────────────────── */}
|
||||
{/* 任务列表 */}
|
||||
{paginatedTasks.length === 0 ? (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">📭</div>
|
||||
<h3>暂无任务记录</h3>
|
||||
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
|
||||
</div>
|
||||
<EmptyState activeTab={activeTab} />
|
||||
) : (
|
||||
<div className="xx-history-task-list">
|
||||
{paginatedTasks.map((task) => (
|
||||
<div key={task.id} className="xx-history-task-item">
|
||||
{/* 任务信息 */}
|
||||
<div className="xx-history-task-info">
|
||||
<h4>{task.name}</h4>
|
||||
<span>
|
||||
{task.type} · 模板:{task.template}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<span className={`xx-history-status xx-history-status--${task.status}`}>
|
||||
{statusLabel[task.status]}
|
||||
</span>
|
||||
|
||||
{/* 时间区 */}
|
||||
<div className="xx-history-task-time">
|
||||
<span>{task.date}</span>
|
||||
{task.status === "completed" ? (
|
||||
<span>完成</span>
|
||||
) : task.status === "processing" ? (
|
||||
<span>进度 {task.progress}%</span>
|
||||
) : task.status === "failed" ? (
|
||||
<span>{task.errorMessage || "请重试"}</span>
|
||||
) : (
|
||||
<span>等待中</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-history-task-action">
|
||||
{task.status === "failed" && task.retryable ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => handleView(task.id)}>
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRetry={handleRetry}
|
||||
onView={handleView}
|
||||
retryLoading={retryLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 分页 ──────────────────────────────────────────────── */}
|
||||
{totalPages > 1 && (
|
||||
<div className="xx-history-pagination">
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="xx-history-page-info">共 {filteredTasks.length} 条</span>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={totalCount}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface LoadingStateProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** 加载状态 */
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({ title = "加载中..." }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">⏳</div>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ErrorStateProps {
|
||||
message?: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
/** 错误状态 */
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">❌</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{message || "网络异常,请稍后重试"}</p>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onRetry}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface EmptyStateProps {
|
||||
activeTab?: string
|
||||
}
|
||||
|
||||
/** 空状态 */
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ activeTab = "all" }) => (
|
||||
<div className="xx-history-empty">
|
||||
<div className="xx-history-empty-icon">📭</div>
|
||||
<h3>暂无任务记录</h3>
|
||||
<p>{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** 页面头部 */
|
||||
export const PageHeader: React.FC = () => (
|
||||
<div className="xx-history-header">
|
||||
<h2>任务历史</h2>
|
||||
<p>查看和管理所有生成任务</p>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { TabConfig } from "../constants"
|
||||
import type { MappedTask } from "../hooks/useTaskHistory"
|
||||
import { statusLabel } from "../constants"
|
||||
|
||||
interface HistoryTabsProps {
|
||||
tabs: TabConfig[]
|
||||
activeTab: string
|
||||
tabCounts: Record<string, number>
|
||||
onChange: (key: string) => void
|
||||
}
|
||||
|
||||
/** Tab 切换栏 */
|
||||
export const HistoryTabs: React.FC<HistoryTabsProps> = ({
|
||||
tabs,
|
||||
activeTab,
|
||||
tabCounts,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="xx-history-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`xx-history-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => onChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="xx-history-tab-count">{tabCounts[tab.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface TaskItemProps {
|
||||
task: MappedTask
|
||||
onRetry: (id: string) => void
|
||||
onView: (id: string) => void
|
||||
retryLoading?: boolean
|
||||
}
|
||||
|
||||
/** 单个任务卡片 */
|
||||
export const TaskItem: React.FC<TaskItemProps> = ({ task, onRetry, onView, retryLoading }) => {
|
||||
const getSubText = () => {
|
||||
switch (task.status) {
|
||||
case "completed":
|
||||
return "完成"
|
||||
case "processing":
|
||||
return `进度 ${task.progress}%`
|
||||
case "failed":
|
||||
return task.errorMessage || "请重试"
|
||||
default:
|
||||
return "等待中"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-history-task-item">
|
||||
{/* 任务信息 */}
|
||||
<div className="xx-history-task-info">
|
||||
<h4>{task.name}</h4>
|
||||
<span>
|
||||
{task.type} · 模板:{task.template}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<span className={`xx-history-status xx-history-status--${task.status}`}>
|
||||
{statusLabel[task.status]}
|
||||
</span>
|
||||
|
||||
{/* 时间区 */}
|
||||
<div className="xx-history-task-time">
|
||||
<span>{task.date}</span>
|
||||
<span>{getSubText()}</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-history-task-action">
|
||||
{task.status === "failed" && task.retryable ? (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => onRetry(task.id)}
|
||||
disabled={retryLoading}
|
||||
>
|
||||
{retryLoading ? "重试中..." : "重试"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => onView(task.id)}>
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
total: number
|
||||
onChange: (page: number) => void
|
||||
}
|
||||
|
||||
/** 分页组件 */
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
onChange,
|
||||
}) => {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="xx-history-pagination">
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChange(currentPage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`xx-history-page-btn${currentPage === page ? " active" : ""}`}
|
||||
onClick={() => onChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="xx-history-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onChange(currentPage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<span className="xx-history-page-info">共 {total} 条</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** 任务状态 */
|
||||
export type TaskStatus = "completed" | "processing" | "pending" | "failed"
|
||||
|
||||
/** 状态标签 */
|
||||
export const statusLabel: Record<TaskStatus, string> = {
|
||||
completed: "已完成",
|
||||
processing: "进行中",
|
||||
pending: "排队中",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
/** Tab 配置 */
|
||||
export interface TabConfig {
|
||||
key: string
|
||||
label: string
|
||||
statusFilter?: TaskStatus
|
||||
}
|
||||
|
||||
/** 默认 Tab 列表 */
|
||||
export const TABS: TabConfig[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "processing", label: "进行中", statusFilter: "processing" },
|
||||
{ key: "completed", label: "已完成", statusFilter: "completed" },
|
||||
{ key: "failed", label: "失败", statusFilter: "failed" },
|
||||
]
|
||||
|
||||
/** 每页数量 */
|
||||
export const PAGE_SIZE = 10
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useMemo, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"
|
||||
import { TABS, PAGE_SIZE, type TabConfig } from "../constants"
|
||||
import { normalizeStatus, formatDate } from "../utils"
|
||||
|
||||
/** 映射后的任务列表项 */
|
||||
export interface MappedTask {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
template?: string
|
||||
status: "completed" | "processing" | "pending" | "failed"
|
||||
date: string
|
||||
progress?: number
|
||||
retryable?: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务历史业务 Hook
|
||||
*/
|
||||
export const useTaskHistory = () => {
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 获取任务列表
|
||||
const {
|
||||
data: tasks = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TaskItem[], Error>({
|
||||
queryKey: ["tasks"],
|
||||
queryFn: getUserTasks,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 重试 mutation
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] })
|
||||
},
|
||||
})
|
||||
|
||||
// 映射后端数据
|
||||
const mappedTasks: MappedTask[] = useMemo(
|
||||
() =>
|
||||
tasks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.user_message || t.task_type,
|
||||
type: t.task_type,
|
||||
template: t.template_id,
|
||||
status: normalizeStatus(t.status),
|
||||
date: formatDate(t.created_at),
|
||||
progress: t.progress,
|
||||
retryable: t.retryable,
|
||||
errorMessage: t.error_message,
|
||||
})),
|
||||
[tasks],
|
||||
)
|
||||
|
||||
// 当前 Tab 筛选
|
||||
const currentTabConfig: TabConfig | undefined = TABS.find((t) => t.key === activeTab)
|
||||
const statusFilter = currentTabConfig?.statusFilter
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = useMemo(
|
||||
() => (statusFilter ? mappedTasks.filter((t) => t.status === statusFilter) : mappedTasks),
|
||||
[mappedTasks, statusFilter],
|
||||
)
|
||||
|
||||
// 各 Tab 计数
|
||||
const tabCounts: Record<string, number> = useMemo(
|
||||
() => ({
|
||||
all: mappedTasks.length,
|
||||
processing: mappedTasks.filter((t) => t.status === "processing").length,
|
||||
completed: mappedTasks.filter((t) => t.status === "completed").length,
|
||||
failed: mappedTasks.filter((t) => t.status === "failed").length,
|
||||
}),
|
||||
[mappedTasks],
|
||||
)
|
||||
|
||||
// 分页
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE)
|
||||
const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
// 切换 Tab
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key)
|
||||
setCurrentPage(1)
|
||||
}, [])
|
||||
|
||||
// 重试
|
||||
const handleRetry = useCallback(
|
||||
(taskId: string) => {
|
||||
retryMutation.mutate(taskId)
|
||||
},
|
||||
[retryMutation],
|
||||
)
|
||||
|
||||
// 查看详情
|
||||
const handleView = useCallback((taskId: string) => {
|
||||
// TODO: 跳转到任务详情页(待路由实现)
|
||||
console.log("查看任务:", taskId)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
activeTab,
|
||||
currentPage,
|
||||
totalPages,
|
||||
// 数据
|
||||
tabs: TABS,
|
||||
tabCounts,
|
||||
paginatedTasks,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
retryLoading: retryMutation.isPending,
|
||||
// 操作
|
||||
setCurrentPage,
|
||||
handleTabChange,
|
||||
handleRetry,
|
||||
handleView,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TaskStatus } from "./constants"
|
||||
|
||||
/** 将后端 status 字符串映射为前端 TaskStatus */
|
||||
export const normalizeStatus = (s: string): TaskStatus => {
|
||||
const map: Record<string, TaskStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
success: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
pending: "pending",
|
||||
queued: "pending",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "pending"
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
export const formatDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return "—"
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
Reference in New Issue
Block a user