feat(web): 贴纸包 CRUD UI + 主题编辑/停用 — Task 14-15 完成
Task 14: StickerPackList 补全 CRUD UI - stickers.ts: 添加 createPack/deletePack/createSticker API - StickerPackList: 新建贴纸包按钮 + 创建表单 Modal - StickerPackList: 卡片添加删除按钮 (Popconfirm) Task 15: TopicList 补全编辑/停用 - topics.ts: 添加 update/deactivate API - TopicList: 编辑 Modal (标题/描述/截止日期) - TopicList: 卡片添加编辑+停用按钮 附带修复: - types.ts: SchoolClass/TopicAssignment 添加 version 字段 - ClassList.tsx: 修复 onUpdate 回调参数签名 - tsconfig.app.json: 排除 src/test 避免缺失模块编译错误
This commit is contained in:
@@ -6,9 +6,18 @@ export const stickerApi = {
|
||||
client.get<{ success: boolean; data: StickerPack[] }>('/diary/sticker-packs', { params })
|
||||
.then((r) => r.data.data),
|
||||
|
||||
createPack: (data: { name: string; description?: string; thumbnail_url?: string; is_free?: boolean; price?: number; category?: string }) =>
|
||||
client.post('/diary/sticker-packs', data).then((r) => r.data.data),
|
||||
|
||||
deletePack: (packId: string) =>
|
||||
client.delete(`/diary/sticker-packs/${packId}`).then((r) => r.data),
|
||||
|
||||
listStickers: (packId: string) =>
|
||||
client.get<{ success: boolean; data: Sticker[] }>(`/diary/sticker-packs/${packId}/stickers`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
createSticker: (packId: string, data: { name: string; image_url: string; category?: string }) =>
|
||||
client.post(`/diary/sticker-packs/${packId}/stickers`, data).then((r) => r.data.data),
|
||||
};
|
||||
|
||||
export const templateApi = {
|
||||
|
||||
@@ -8,4 +8,10 @@ export const topicApi = {
|
||||
|
||||
assign: (classId: string, data: CreateTopicReq) =>
|
||||
client.post(`/diary/classes/${classId}/topics`, data).then((r) => r.data.data),
|
||||
|
||||
update: (topicId: string, data: { title?: string; description?: string; due_date?: string; version: number }) =>
|
||||
client.put(`/diary/topics/${topicId}`, data).then((r) => r.data.data),
|
||||
|
||||
deactivate: (topicId: string) =>
|
||||
client.patch(`/diary/topics/${topicId}/deactivate`).then((r) => r.data.data),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SchoolClass {
|
||||
class_code: string;
|
||||
member_count: number;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateClassReq {
|
||||
@@ -76,6 +77,7 @@ export interface TopicAssignment {
|
||||
description?: string;
|
||||
due_date?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateTopicReq {
|
||||
|
||||
@@ -57,11 +57,11 @@ export default function ClassList() {
|
||||
onCreate: async (values) => {
|
||||
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
|
||||
},
|
||||
onUpdate: async (record, values) => {
|
||||
await classApi.update(record.id, {
|
||||
onUpdate: async (id, values) => {
|
||||
await classApi.update(id, {
|
||||
name: values.name as string,
|
||||
school_name: values.school_name as string | undefined,
|
||||
version: record.version,
|
||||
version: values.version,
|
||||
});
|
||||
},
|
||||
onSuccess: refresh,
|
||||
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
Select,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Form,
|
||||
Input,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { stickerApi } from '../../api/diary/stickers';
|
||||
import type { StickerPack, Sticker } from '../../api/diary/types';
|
||||
@@ -74,6 +79,11 @@ export default function StickerPackList() {
|
||||
const [stickers, setStickers] = useState<Sticker[]>([]);
|
||||
const [stickersLoading, setStickersLoading] = useState(false);
|
||||
|
||||
// --- Create modal state ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// --- Fetch sticker packs ---
|
||||
const fetchPacks = useCallback(async (currentFilters: StickerFilters) => {
|
||||
setLoading(true);
|
||||
@@ -137,6 +147,36 @@ export default function StickerPackList() {
|
||||
setStickers([]);
|
||||
}, []);
|
||||
|
||||
// --- Create sticker pack ---
|
||||
const handleCreate = useCallback(async () => {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
const result = await execute(
|
||||
() => stickerApi.createPack(values),
|
||||
'贴纸包创建成功',
|
||||
'创建失败',
|
||||
);
|
||||
setCreating(false);
|
||||
if (result) {
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchPacks(filters);
|
||||
}
|
||||
}, [execute, createForm, fetchPacks, filters]);
|
||||
|
||||
// --- Delete sticker pack ---
|
||||
const handleDelete = useCallback(async (packId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const result = await execute(
|
||||
() => stickerApi.deletePack(packId),
|
||||
'贴纸包已删除',
|
||||
'删除失败',
|
||||
);
|
||||
if (result !== null) {
|
||||
fetchPacks(filters);
|
||||
}
|
||||
}, [execute, fetchPacks, filters]);
|
||||
|
||||
// --- Category filter options ---
|
||||
const categoryOptions = useMemo(() =>
|
||||
Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
|
||||
@@ -313,6 +353,29 @@ export default function StickerPackList() {
|
||||
</Space>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确认删除此贴纸包?"
|
||||
description="删除后不可恢复"
|
||||
onConfirm={(e) => handleDelete(pack.id, e ?? undefined)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="删除贴纸包">
|
||||
<Tag
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isDark ? '#3A2020' : '#FFF0F0',
|
||||
color: '#E07A5F',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -336,12 +399,24 @@ export default function StickerPackList() {
|
||||
}
|
||||
onResetFilters={handleResetFilters}
|
||||
actions={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPacks(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPacks(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建贴纸包
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
@@ -465,6 +540,78 @@ export default function StickerPackList() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create sticker pack modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<PlusOutlined style={{ color: '#E07A5F' }} />
|
||||
<span>新建贴纸包</span>
|
||||
</Space>
|
||||
}
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
confirmLoading={creating}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
okButtonProps={{
|
||||
style: {
|
||||
background: '#E07A5F',
|
||||
borderColor: '#E07A5F',
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="贴纸包名称"
|
||||
rules={[{ required: true, message: '请输入贴纸包名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:可爱动物" maxLength={50} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="贴纸包简要描述..."
|
||||
maxLength={200}
|
||||
showCount
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
>
|
||||
<Select
|
||||
placeholder="选择贴纸分类"
|
||||
allowClear
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="is_free"
|
||||
label="免费"
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: '免费' },
|
||||
{ value: false, label: '付费' },
|
||||
]}
|
||||
placeholder="选择类型"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Badge,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EditOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { topicApi } from '../../api/diary/topics';
|
||||
@@ -53,6 +56,12 @@ export default function TopicList() {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// --- Edit modal state ---
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingTopic, setEditingTopic] = useState<TopicAssignment | null>(null);
|
||||
|
||||
// --- Fetch classes ---
|
||||
const fetchClasses = useCallback(async () => {
|
||||
setClassesLoading(true);
|
||||
@@ -158,6 +167,56 @@ export default function TopicList() {
|
||||
}
|
||||
}, [selectedClassId, form, execute, fetchTopics]);
|
||||
|
||||
// --- Open edit modal ---
|
||||
const openEditModal = useCallback((topic: TopicAssignment) => {
|
||||
setEditingTopic(topic);
|
||||
editForm.setFieldsValue({
|
||||
title: topic.title,
|
||||
description: topic.description ?? '',
|
||||
due_date: topic.due_date ? dayjs(topic.due_date) : undefined,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}, [editForm]);
|
||||
|
||||
// --- Submit edit topic ---
|
||||
const handleEdit = useCallback(async () => {
|
||||
if (!editingTopic || !selectedClassId) return;
|
||||
|
||||
const values = await editForm.validateFields();
|
||||
setEditing(true);
|
||||
const result = await execute(
|
||||
() =>
|
||||
topicApi.update(editingTopic.id, {
|
||||
title: values.title as string,
|
||||
description: values.description as string | undefined,
|
||||
due_date: values.due_date ? (values.due_date as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
version: editingTopic.version,
|
||||
}),
|
||||
'主题已更新',
|
||||
'更新主题失败',
|
||||
);
|
||||
setEditing(false);
|
||||
|
||||
if (result) {
|
||||
setEditOpen(false);
|
||||
setEditingTopic(null);
|
||||
editForm.resetFields();
|
||||
fetchTopics(selectedClassId);
|
||||
}
|
||||
}, [editingTopic, selectedClassId, editForm, execute, fetchTopics]);
|
||||
|
||||
// --- Deactivate topic ---
|
||||
const handleDeactivate = useCallback(async (topic: TopicAssignment) => {
|
||||
if (!selectedClassId) return;
|
||||
try {
|
||||
await topicApi.deactivate(topic.id);
|
||||
message.success(`主题「${topic.title}」已停用`);
|
||||
fetchTopics(selectedClassId);
|
||||
} catch {
|
||||
message.error('停用主题失败');
|
||||
}
|
||||
}, [selectedClassId, fetchTopics]);
|
||||
|
||||
// --- Check overdue ---
|
||||
const isOverdue = useCallback((dueDate?: string) => {
|
||||
if (!dueDate) return false;
|
||||
@@ -455,14 +514,43 @@ export default function TopicList() {
|
||||
无截止日期
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{topic.teacher_id.length > 10
|
||||
? `${topic.teacher_id.slice(0, 10)}...`
|
||||
: topic.teacher_id}
|
||||
</Text>
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑主题">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditModal(topic);
|
||||
}}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{topic.is_active && (
|
||||
<Popconfirm
|
||||
title="确认停用此主题?"
|
||||
description="停用后学生将无法看到该主题"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeactivate(topic);
|
||||
}}
|
||||
okText="停用"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="停用主题">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<StopOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: '#E07A5F' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -552,6 +640,72 @@ export default function TopicList() {
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit topic modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<EditOutlined style={{ color: '#E07A5F' }} />
|
||||
<span>编辑主题</span>
|
||||
</Space>
|
||||
}
|
||||
open={editOpen}
|
||||
onOk={handleEdit}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setEditingTopic(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
confirmLoading={editing}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okButtonProps={{
|
||||
style: {
|
||||
background: '#E07A5F',
|
||||
borderColor: '#E07A5F',
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="主题标题"
|
||||
rules={[{ required: true, message: '请输入主题标题' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:我最喜欢的季节"
|
||||
maxLength={100}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="主题描述"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="描述写作要求或提示,帮助学生理解主题..."
|
||||
maxLength={500}
|
||||
showCount
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="due_date"
|
||||
label="截止日期"
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择截止日期(可选)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/test"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user