feat(web): 贴纸包 CRUD UI + 主题编辑/停用 — Task 14-15 完成
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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:
iven
2026-06-02 23:40:46 +08:00
parent f0741450bc
commit d6dd017155
7 changed files with 337 additions and 18 deletions

View File

@@ -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 = {

View File

@@ -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),
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -21,5 +21,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/test"]
}