Files
nj/apps/web/src/pages/diary/ClassList.tsx
iven 78018a9a64
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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
2026-06-02 12:16:44 +08:00

366 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Form,
Input,
Tag,
Drawer,
Modal,
Badge,
Typography,
message,
Tooltip,
} from 'antd';
import {
PlusOutlined,
CopyOutlined,
TeamOutlined,
UserOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { classApi } from '../../api/diary/classes';
import type { SchoolClass, ClassMember } from '../../api/diary/types';
import { PageContainer } from '../../components/PageContainer';
import { DrawerForm } from '../../components/DrawerForm';
import { useCrudDrawer } from '../../hooks/useCrudDrawer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode';
const { Text } = Typography;
export default function ClassList() {
const isDark = useThemeMode();
const { execute } = useApiRequest();
const {
data: classes,
total,
page,
loading,
refresh,
} = usePaginatedData<SchoolClass>(async (p, pageSize) => {
const result = await classApi.list({ page: p, page_size: pageSize });
return { data: result.data, total: result.total };
}, 20);
// --- Create/Edit drawer ---
const classDrawer = useCrudDrawer<SchoolClass>({
getId: (r) => r.id,
onCreate: async (values) => {
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
},
onUpdate: async () => {
// Class update API not yet available; refresh list silently
},
onSuccess: refresh,
});
// --- Member drawer ---
const [memberDrawerOpen, setMemberDrawerOpen] = useState(false);
const [memberClass, setMemberClass] = useState<SchoolClass | null>(null);
const [members, setMembers] = useState<ClassMember[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const openMemberDrawer = useCallback(async (cls: SchoolClass) => {
setMemberClass(cls);
setMemberDrawerOpen(true);
setMembersLoading(true);
try {
const result = await classApi.listMembers(cls.id);
setMembers(result);
} catch {
message.error('加载班级成员失败');
setMembers([]);
} finally {
setMembersLoading(false);
}
}, []);
// --- Copy class code ---
const handleCopyCode = useCallback((code: string) => {
navigator.clipboard.writeText(code).then(
() => message.success('班级码已复制'),
() => message.error('复制失败,请手动复制'),
);
}, []);
// --- Table columns ---
const columns = [
{
title: '班级名称',
dataIndex: 'name',
key: 'name',
render: (name: string, record: SchoolClass) => (
<Button
type="link"
style={{ padding: 0, height: 'auto', fontWeight: 500, fontSize: 14 }}
onClick={() => openMemberDrawer(record)}
>
{name}
</Button>
),
},
{
title: '学校',
dataIndex: 'school_name',
key: 'school_name',
render: (v?: string) => v || <Text type="secondary">-</Text>,
},
{
title: '班级码',
dataIndex: 'class_code',
key: 'class_code',
width: 180,
render: (code: string) => (
<Space size={6}>
<Text
code
style={{
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: 14,
letterSpacing: 1,
}}
>
{code}
</Text>
<Tooltip title="复制班级码">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(code)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
</Tooltip>
</Space>
),
},
{
title: '成员数',
dataIndex: 'member_count',
key: 'member_count',
width: 100,
align: 'center' as const,
render: (count: number) => (
<Badge
count={count}
showZero
style={{
backgroundColor: isDark ? '#1f1f1f' : '#f0f0f0',
color: isDark ? '#e0e0e0' : '#333',
fontWeight: 500,
}}
overflowCount={9999}
/>
),
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 100,
align: 'center' as const,
render: (isActive: boolean) => (
<Tag
color={isActive ? 'success' : 'error'}
style={{ fontWeight: 500, border: 'none' }}
>
{isActive ? '活跃' : '已停用'}
</Tag>
),
},
{
title: '教师',
dataIndex: 'teacher_id',
key: 'teacher_id',
render: (teacherId: string) =>
teacherId ? (
<Space size={4}>
<UserOutlined style={{ color: isDark ? '#94a3b8' : '#475569' }} />
<Text type="secondary">{teacherId}</Text>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record: SchoolClass) => (
<Space size={4}>
<Tooltip title="查看成员">
<Button
size="small"
type="text"
icon={<TeamOutlined />}
onClick={() => openMemberDrawer(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
</Tooltip>
</Space>
),
},
];
// --- Member table columns ---
const memberColumns = [
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
render: (v?: string) => v || <Text type="secondary"></Text>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string) => {
const roleMap: Record<string, { label: string; color: string }> = {
teacher: { label: '教师', color: 'blue' },
student: { label: '学生', color: 'green' },
parent: { label: '家长', color: 'orange' },
};
const info = roleMap[role] || { label: role, color: 'default' };
return <Tag color={info.color}>{info.label}</Tag>;
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (v: string) => (v ? new Date(v).toLocaleDateString('zh-CN') : '-'),
},
];
return (
<PageContainer
title="班级管理"
subtitle="管理班级信息、班级码和成员"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => refresh()}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => classDrawer.openCreate()}
>
</Button>
</Space>
}
>
<Table
columns={columns}
dataSource={classes}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => refresh(p),
showTotal: (t) => `${t} 条记录`,
}}
onRow={(record) => ({
onClick: () => openMemberDrawer(record),
style: { cursor: 'pointer' },
})}
/>
{/* Create class drawer */}
<DrawerForm
title={classDrawer.editingRecord ? '编辑班级' : '创建班级'}
open={classDrawer.open}
onClose={classDrawer.close}
onSubmit={classDrawer.handleSubmit}
initialValues={classDrawer.initialValues}
loading={classDrawer.loading}
width={480}
columns={1}
>
<Form.Item
name="name"
label="班级名称"
rules={[{ required: true, message: '请输入班级名称' }]}
>
<Input placeholder="例如:三年级二班" maxLength={50} />
</Form.Item>
<Form.Item
name="school_name"
label="学校名称"
>
<Input placeholder="例如:阳光小学" maxLength={100} />
</Form.Item>
</DrawerForm>
{/* Member drawer */}
<Drawer
title={
<Space>
<TeamOutlined />
<span>{memberClass ? `${memberClass.name} - 班级成员` : '班级成员'}</span>
{memberClass && (
<Tag
color={memberClass.is_active ? 'success' : 'error'}
style={{ marginLeft: 8 }}
>
{memberClass.is_active ? '活跃' : '已停用'}
</Tag>
)}
</Space>
}
open={memberDrawerOpen}
onClose={() => {
setMemberDrawerOpen(false);
setMemberClass(null);
setMembers([]);
}}
width={600}
styles={{
body: { background: isDark ? '#141414' : undefined, padding: 0 },
}}
extra={
memberClass ? (
<Space>
<Text type="secondary"></Text>
<Text
code
style={{
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: 14,
letterSpacing: 1,
}}
>
{memberClass.class_code}
</Text>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(memberClass.class_code)}
>
</Button>
</Space>
) : null
}
>
<Table
columns={memberColumns}
dataSource={members}
rowKey="user_id"
loading={membersLoading}
pagination={false}
size="middle"
locale={{ emptyText: '暂无成员' }}
/>
</Drawer>
</PageContainer>
);
}