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 避免缺失模块编译错误
618 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|