4143018faa
- client.ts: 添加全局错误拦截器,自动提取后端 detail/message/msg 字段展示 toast - 处理网络异常、超时、5xx 等场景 - 通过 __msgShown 标记避免与组件 onError 重复弹提示 - 6 个 delete mutation 补充 onError 回退提示(AssetLibrary、TitleLibrary、 VoiceLibrary、ProductLibrary、DuplicationResults×2) - GeneratePage: 5 个 query 增加 loading + error 状态展示 - TemplateLibrary: favMutation 增加收藏/取消收藏成功提示及失败回退 - Dashboard: 增加 isError 错误状态展示,避免静默显示全零数据 - DuplicationDetail: 增加 isError 状态,区分加载失败与记录不存在
300 lines
7.7 KiB
TypeScript
300 lines
7.7 KiB
TypeScript
/**
|
|
* 标题库页面
|
|
* 管理用户标题,支持 CRUD 和批量导入
|
|
*/
|
|
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Button,
|
|
Typography,
|
|
Space,
|
|
Table,
|
|
Empty,
|
|
Spin,
|
|
Modal,
|
|
Input,
|
|
Tag,
|
|
Popconfirm,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
EditOutlined,
|
|
DeleteOutlined,
|
|
ImportOutlined,
|
|
SearchOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
getTitles,
|
|
createTitle,
|
|
updateTitle,
|
|
deleteTitle,
|
|
batchImportTitles,
|
|
type TitleItem,
|
|
} from '@/api/titles';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
|
|
const { Title } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
const TitleLibrary: React.FC = () => {
|
|
const queryClient = useQueryClient();
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [importModalOpen, setImportModalOpen] = useState(false);
|
|
const [editingTitle, setEditingTitle] = useState<TitleItem | null>(null);
|
|
const [formContent, setFormContent] = useState('');
|
|
const [formCategory, setFormCategory] = useState('');
|
|
const [importText, setImportText] = useState('');
|
|
const [searchText, setSearchText] = useState('');
|
|
|
|
// 获取标题列表
|
|
const { data: titles = [], isLoading } = useQuery({
|
|
queryKey: ['titles'],
|
|
queryFn: getTitles,
|
|
});
|
|
|
|
// 创建标题
|
|
const createMutation = useMutation({
|
|
mutationFn: createTitle,
|
|
onSuccess: () => {
|
|
message.success('标题创建成功');
|
|
setModalOpen(false);
|
|
resetForm();
|
|
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
|
},
|
|
onError: () => message.error('创建失败'),
|
|
});
|
|
|
|
// 更新标题
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Partial<{ content: string; category: string }> }) =>
|
|
updateTitle(id, data),
|
|
onSuccess: () => {
|
|
message.success('标题更新成功');
|
|
setModalOpen(false);
|
|
resetForm();
|
|
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
|
},
|
|
onError: () => message.error('更新失败'),
|
|
});
|
|
|
|
// 删除标题
|
|
const deleteMutation = useMutation({
|
|
mutationFn: deleteTitle,
|
|
onSuccess: () => {
|
|
message.success('已删除');
|
|
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
|
},
|
|
onError: (err: any) => { if (!err?.__msgShown) message.error('删除失败') },
|
|
});
|
|
|
|
// 批量导入
|
|
const importMutation = useMutation({
|
|
mutationFn: (titles: string[]) =>
|
|
batchImportTitles(titles),
|
|
onSuccess: () => {
|
|
message.success('批量导入成功');
|
|
setImportModalOpen(false);
|
|
setImportText('');
|
|
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
|
},
|
|
onError: () => message.error('导入失败'),
|
|
});
|
|
|
|
const resetForm = () => {
|
|
setEditingTitle(null);
|
|
setFormContent('');
|
|
setFormCategory('');
|
|
};
|
|
|
|
const openCreate = () => {
|
|
resetForm();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (title: TitleItem) => {
|
|
setEditingTitle(title);
|
|
setFormContent(title.content);
|
|
setFormCategory(title.category || '');
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!formContent.trim()) {
|
|
message.warning('请输入标题内容');
|
|
return;
|
|
}
|
|
if (editingTitle) {
|
|
updateMutation.mutate({
|
|
id: editingTitle.id,
|
|
data: { content: formContent, category: formCategory },
|
|
});
|
|
} else {
|
|
createMutation.mutate({ content: formContent, category: formCategory });
|
|
}
|
|
};
|
|
|
|
/** 过滤后的标题 */
|
|
const filteredTitles = titles.filter(
|
|
(t) =>
|
|
!searchText ||
|
|
t.content.toLowerCase().includes(searchText.toLowerCase())
|
|
);
|
|
|
|
const columns: ColumnsType<TitleItem> = [
|
|
{
|
|
title: '标题内容',
|
|
dataIndex: 'content',
|
|
key: 'content',
|
|
ellipsis: true,
|
|
},
|
|
{
|
|
title: '分类',
|
|
dataIndex: 'category',
|
|
key: 'category',
|
|
width: 120,
|
|
render: (cat: string) =>
|
|
cat ? <Tag>{cat}</Tag> : <Tag color="default">未分类</Tag>,
|
|
},
|
|
{
|
|
title: '字数',
|
|
dataIndex: 'word_count',
|
|
key: 'word_count',
|
|
width: 80,
|
|
render: (count: number) => count ?? '-',
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 120,
|
|
render: (_, record) => (
|
|
<Space>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => openEdit(record)}
|
|
/>
|
|
<Popconfirm
|
|
title="确定删除此标题?"
|
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
|
>
|
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 24,
|
|
flexWrap: 'wrap',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Title level={3} style={{ margin: 0 }}>
|
|
标题库
|
|
</Title>
|
|
<Space wrap>
|
|
<Button
|
|
icon={<ImportOutlined />}
|
|
onClick={() => setImportModalOpen(true)}
|
|
>
|
|
批量导入
|
|
</Button>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
|
新建标题
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 搜索 */}
|
|
<Input
|
|
prefix={<SearchOutlined />}
|
|
placeholder="搜索标题..."
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
allowClear
|
|
style={{ marginBottom: 16 }}
|
|
/>
|
|
|
|
{isLoading ? (
|
|
<div style={{ textAlign: 'center', padding: 60 }}>
|
|
<Spin size="large" />
|
|
</div>
|
|
) : filteredTitles.length === 0 ? (
|
|
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'} />
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={filteredTitles}
|
|
rowKey="id"
|
|
pagination={{ pageSize: 20, showSizeChanger: true }}
|
|
size="small"
|
|
scroll={{ x: 600 }}
|
|
/>
|
|
)}
|
|
|
|
{/* 新建/编辑弹窗 */}
|
|
<Modal
|
|
title={editingTitle ? '编辑标题' : '新建标题'}
|
|
open={modalOpen}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
resetForm();
|
|
}}
|
|
onOk={handleSave}
|
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
<TextArea
|
|
placeholder="输入标题内容"
|
|
value={formContent}
|
|
onChange={(e) => setFormContent(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<Input
|
|
placeholder="分类(可选)"
|
|
value={formCategory}
|
|
onChange={(e) => setFormCategory(e.target.value)}
|
|
/>
|
|
</Space>
|
|
</Modal>
|
|
|
|
{/* 批量导入弹窗 */}
|
|
<Modal
|
|
title="批量导入标题"
|
|
open={importModalOpen}
|
|
onCancel={() => setImportModalOpen(false)}
|
|
onOk={() => {
|
|
const lines = importText
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
if (lines.length === 0) {
|
|
message.warning('请输入至少一条标题');
|
|
return;
|
|
}
|
|
importMutation.mutate(lines);
|
|
}}
|
|
confirmLoading={importMutation.isPending}
|
|
>
|
|
<TextArea
|
|
placeholder="每行一个标题,例如: 今天天气真好 分享我的日常 美食推荐"
|
|
value={importText}
|
|
onChange={(e) => setImportText(e.target.value)}
|
|
rows={10}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TitleLibrary;
|