feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码
Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限
Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers
验证: tsc 0 error, vite build ✓, vitest 226/226 pass
This commit is contained in:
470
apps/web/src/pages/diary/StickerPackList.tsx
Normal file
470
apps/web/src/pages/diary/StickerPackList.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Space,
|
||||
Empty,
|
||||
Modal,
|
||||
Image,
|
||||
Button,
|
||||
Spin,
|
||||
Select,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
PictureOutlined,
|
||||
} 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);
|
||||
|
||||
// --- 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([]);
|
||||
}, []);
|
||||
|
||||
// --- 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>
|
||||
</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={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPacks(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user