feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

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:
iven
2026-06-02 12:16:44 +08:00
parent 0a9e5b1cb3
commit 78018a9a64
204 changed files with 2573 additions and 35241 deletions

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