Files
nj/apps/web/src/pages/diary/StickerPackList.tsx
iven d6dd017155
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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 避免缺失模块编译错误
2026-06-02 23:40:46 +08:00

618 lines
18 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo } from 'react';
import {
Card,
Row,
Col,
Tag,
Space,
Empty,
Modal,
Image,
Button,
Spin,
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';
import { PageContainer } from '../../components/PageContainer';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { Text, Paragraph } = Typography;
// --- Category color mapping ---
const CATEGORY_COLORS: Record<string, string> = {
animal: '#E07A5F',
food: '#81B29A',
nature: '#6DB1BF',
emoji: '#F2CC8F',
school: '#9B8EC4',
holiday: '#D4A5A5',
travel: '#5F9EA0',
sport: '#E8A87C',
};
const CATEGORY_LABELS: Record<string, string> = {
animal: '动物',
food: '美食',
nature: '自然',
emoji: '表情',
school: '校园',
holiday: '节日',
travel: '旅行',
sport: '运动',
};
// --- Filter state ---
interface StickerFilters {
category: string | undefined;
}
const DEFAULT_FILTERS: StickerFilters = {
category: undefined,
};
export default function StickerPackList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
// --- Data state ---
const [packs, setPacks] = useState<StickerPack[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<StickerFilters>(DEFAULT_FILTERS);
// --- Pack detail modal state ---
const [modalOpen, setModalOpen] = useState(false);
const [currentPack, setCurrentPack] = useState<StickerPack | null>(null);
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);
try {
const params: { category?: string } = {};
if (currentFilters.category) {
params.category = currentFilters.category;
}
const result = await stickerApi.listPacks(params);
setPacks(result);
} catch {
// Error handled by useApiRequest if used via execute, or silently here
setPacks([]);
} finally {
setLoading(false);
}
}, []);
// --- Initial load ---
useEffect(() => {
fetchPacks(DEFAULT_FILTERS);
}, [fetchPacks]);
// --- Handle filter change ---
const handleFilterChange = useCallback((value: string | undefined) => {
const next = { category: value };
setFilters(next);
fetchPacks(next);
}, [fetchPacks]);
// --- Reset filters ---
const handleResetFilters = useCallback(() => {
const reset = { ...DEFAULT_FILTERS };
setFilters(reset);
fetchPacks(reset);
}, [fetchPacks]);
// --- Open pack detail modal ---
const openPackDetail = useCallback(async (pack: StickerPack) => {
setCurrentPack(pack);
setModalOpen(true);
setStickersLoading(true);
setStickers([]);
const result = await execute(
() => stickerApi.listStickers(pack.id),
undefined,
'加载贴纸失败',
);
setStickersLoading(false);
if (result) {
setStickers(result);
}
}, [execute]);
// --- Close pack detail modal ---
const closePackDetail = useCallback(() => {
setModalOpen(false);
setCurrentPack(null);
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]) => ({
value,
label,
})),
[],
);
// --- Render helpers ---
const renderCategoryTag = useCallback((category?: string) => {
if (!category) return null;
const color = CATEGORY_COLORS[category] || '#8c8c8c';
const label = CATEGORY_LABELS[category] || category;
return (
<Tag
color={color}
style={{ fontWeight: 500, border: 'none', color: '#fff' }}
>
{label}
</Tag>
);
}, []);
const renderPriceTag = useCallback((isFree: boolean) => {
if (isFree) {
return (
<Tag
color="success"
style={{ fontWeight: 600, border: 'none' }}
>
</Tag>
);
}
return (
<Tag
color="gold"
style={{ fontWeight: 600, border: 'none' }}
>
</Tag>
);
}, []);
// --- Pack card style ---
const cardStyle = useMemo(() => ({
borderRadius: 16,
overflow: 'hidden' as const,
border: `1px solid ${isDark ? '#3A3530' : '#f0e8e0'}`,
background: isDark ? '#1f1f1f' : '#ffffff',
cursor: 'pointer' as const,
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
height: '100%',
}), [isDark]);
const cardHoverShadow = '0 8px 24px rgba(224, 122, 95, 0.15)';
// --- Pack cards ---
const packCards = useMemo(() => {
if (packs.length === 0 && !loading) {
return (
<Col span={24}>
<Empty
description="暂无贴纸包"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '60px 0' }}
/>
</Col>
);
}
return packs.map((pack) => (
<Col xs={24} sm={12} md={8} lg={6} key={pack.id}>
<Card
hoverable
style={cardStyle}
styles={{
body: { padding: 0 },
}}
onClick={() => openPackDetail(pack)}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.boxShadow = cardHoverShadow;
(e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.boxShadow = 'none';
(e.currentTarget as HTMLElement).style.transform = 'translateY(0)';
}}
>
{/* Cover image */}
<div
style={{
height: 160,
background: isDark
? 'linear-gradient(135deg, #2A2520 0%, #3A3530 100%)'
: 'linear-gradient(135deg, #FFF8F0 0%, #FFE8D6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
}}
>
{pack.cover_image_url ? (
<img
src={pack.cover_image_url}
alt={pack.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
<PictureOutlined
style={{
fontSize: 48,
color: isDark ? '#5A5550' : '#D4B896',
}}
/>
)}
{/* Price badge overlay */}
<div style={{ position: 'absolute', top: 8, right: 8 }}>
{renderPriceTag(pack.is_free)}
</div>
</div>
{/* Pack info */}
<div style={{ padding: '12px 16px 16px' }}>
<Space size={6} align="center" style={{ marginBottom: 6 }}>
<Text
strong
ellipsis
style={{
fontSize: 15,
maxWidth: 160,
color: isDark ? '#F0E8DF' : '#2D2420',
}}
>
{pack.name}
</Text>
</Space>
{pack.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{
fontSize: 13,
marginBottom: 8,
lineHeight: 1.5,
color: isDark ? '#94a3b8' : '#6B6560',
}}
>
{pack.description}
</Paragraph>
)}
<Space size={8} wrap>
{renderCategoryTag(pack.category)}
<Tooltip title={`${pack.sticker_count} 个贴纸`}>
<Tag
style={{
fontWeight: 500,
border: 'none',
background: isDark ? '#2A2520' : '#FFF0E5',
color: isDark ? '#D4B896' : '#E07A5F',
}}
>
<Space size={4}>
<AppstoreOutlined />
<span>{pack.sticker_count}</span>
</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>
</Col>
));
}, [packs, loading, isDark, cardStyle, openPackDetail, cardHoverShadow, renderPriceTag, renderCategoryTag]);
return (
<PageContainer
title="贴纸包管理"
subtitle="管理暖记贴纸素材包"
filters={
<Select
allowClear
placeholder="筛选分类"
options={categoryOptions}
value={filters.category}
onChange={handleFilterChange}
style={{ minWidth: 130 }}
/>
}
onResetFilters={handleResetFilters}
actions={
<Space>
<Button
icon={<ReloadOutlined />}
onClick={() => fetchPacks(filters)}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>
</Space>
}
>
<Spin spinning={loading}>
<Row gutter={[16, 16]} style={{ padding: 24 }}>
{packCards}
</Row>
</Spin>
{/* Pack detail modal */}
<Modal
title={
<Space>
<AppstoreOutlined style={{ color: '#E07A5F' }} />
<span>{currentPack?.name ?? '贴纸包详情'}</span>
{currentPack && renderPriceTag(currentPack.is_free)}
</Space>
}
open={modalOpen}
onCancel={closePackDetail}
footer={null}
width={720}
destroyOnClose
styles={{
body: {
background: isDark ? '#141414' : undefined,
paddingTop: 20,
},
}}
>
{currentPack && (
<>
{/* Pack meta */}
<div style={{ marginBottom: 20 }}>
<Space size={12} wrap>
{renderCategoryTag(currentPack.category)}
<Tag
style={{
border: 'none',
background: isDark ? '#2A2520' : '#FFF0E5',
color: isDark ? '#D4B896' : '#E07A5F',
fontWeight: 500,
}}
>
{currentPack.sticker_count}
</Tag>
</Space>
{currentPack.description && (
<Paragraph
type="secondary"
style={{ marginTop: 8, marginBottom: 0, fontSize: 14 }}
>
{currentPack.description}
</Paragraph>
)}
</div>
{/* Sticker grid */}
<Spin spinning={stickersLoading}>
{stickers.length > 0 ? (
<Row gutter={[12, 12]}>
{stickers.map((sticker) => (
<Col span={6} key={sticker.id}>
<div
style={{
borderRadius: 12,
border: `1px solid ${isDark ? '#3A3530' : '#f0e8e0'}`,
background: isDark ? '#1f1f1f' : '#FFF8F0',
padding: 12,
textAlign: 'center',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
}}
>
<Image
src={sticker.image_url}
alt={sticker.name}
preview={{
mask: (
<span style={{ fontSize: 12 }}>
</span>
),
}}
style={{
maxWidth: '100%',
maxHeight: 120,
objectFit: 'contain',
}}
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHJ4PSI4IiBmaWxsPSIjRjVGNUY1Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiNDQ0MiIGZvbnQtc2l6ZT0iMTQiPuWbvueJh+WKoOi9veWksei0pTwvdGV4dD48L3N2Zz4="
/>
<Text
ellipsis
style={{
display: 'block',
marginTop: 6,
fontSize: 12,
color: isDark ? '#94a3b8' : '#6B6560',
}}
>
{sticker.name}
</Text>
</div>
</Col>
))}
</Row>
) : (
!stickersLoading && (
<Empty
description="该贴纸包暂无贴纸"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '40px 0' }}
/>
)
)}
</Spin>
</>
)}
</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>
);
}