Files
xiaoxia-saas/apps/web/src/pages/titles/TitleLibrary.tsx
T
Audit Bot 4143018faa
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
fix: 全面补充前端交互状态反馈
- 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 状态,区分加载失败与记录不存在
2026-06-29 09:35:08 +08:00

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="每行一个标题,例如:&#10;今天天气真好&#10;分享我的日常&#10;美食推荐"
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={10}
/>
</Modal>
</div>
);
};
export default TitleLibrary;