feat:生文

This commit is contained in:
R524809
2026-01-30 16:04:10 +08:00
parent d4fc8f44ee
commit e7b4a948ac
63 changed files with 981954 additions and 1124 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ function App() {
locale={zhCN}
theme={{
token: {
colorPrimary: '#93D333',
colorPrimary: '#4E9B6E',
borderRadius: 8,
},
}}
+1 -1
View File
@@ -21,7 +21,7 @@
.error-icon {
font-size: 80px;
color: #93D333;
color: #4E9B6E;
margin-bottom: 24px;
animation: pulse 2s ease-in-out infinite;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const WordCard: React.FC<WordCardProps> = ({ word, onClick }) => {
<div className="word-card-pinyin">{pinyinDisplay}</div>
<div className="word-card-svg-indicator">
{hasSvg ? (
<CheckCircleOutlined style={{ color: '#93D333' }} />
<CheckCircleOutlined style={{ color: '#4E9B6E' }} />
) : (
<CloseCircleOutlined style={{ color: '#d9d9d9' }} />
)}
+1 -1
View File
@@ -4,7 +4,7 @@
/* 侧边栏样式 */
.main-sider {
background: linear-gradient(180deg, #93D333 0%, #7CB518 100%) !important;
background: linear-gradient(180deg, #4E9B6E 0%, #3D8260 100%) !important;
display: flex;
flex-direction: column;
}
+9 -2
View File
@@ -6,6 +6,7 @@ import {
MenuUnfoldOutlined,
LogoutOutlined,
UserOutlined,
IdcardOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { authService } from '@/services/auth';
@@ -43,6 +44,12 @@ const MainLayout = () => {
// 用户下拉菜单
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <IdcardOutlined />,
label: '个人中心',
onClick: () => navigate('/profile'),
},
{
key: 'logout',
icon: <LogoutOutlined />,
@@ -120,8 +127,8 @@ const MainLayout = () => {
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<div className="user-info" style={{ cursor: 'pointer' }}>
<Avatar
style={{
backgroundColor: '#93D333',
style={{
backgroundColor: '#4E9B6E',
verticalAlign: 'middle',
}}
src={user?.avatarUrl}
+2
View File
@@ -79,6 +79,8 @@ const SidebarMenu = ({ collapsed, user, onMenuClick }: SidebarMenuProps) => {
if (path === '/' || path === '/words') {
return ['/words'];
}
if (path === '/users') return ['/users'];
if (path === '/profile') return ['/profile'];
return [path];
}, [location.pathname]);
+28 -16
View File
@@ -2,6 +2,8 @@ import {
FileTextOutlined,
SettingOutlined,
DashboardOutlined,
UserOutlined,
IdcardOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
@@ -41,25 +43,35 @@ export const routeMenuConfig: RouteMenuConfig[] = [
group: 'main',
},
{
path: '/settings',
key: '/settings',
icon: <SettingOutlined />,
label: '系统设置',
title: '系统设置',
subtitle: '系统配置管理',
group: 'admin',
requireAdmin: true,
},
{
path: '/analytics',
key: '/analytics',
icon: <DashboardOutlined />,
label: '数据统计',
title: '数据统计',
subtitle: '了解系统数据',
path: '/users',
key: '/users',
icon: <UserOutlined />,
label: '用户管理',
title: '用户管理',
subtitle: '管理后台用户',
group: 'admin',
requireAdmin: true,
},
// {
// path: '/settings',
// key: '/settings',
// icon: <SettingOutlined />,
// label: '系统设置',
// title: '系统设置',
// subtitle: '系统配置管理',
// group: 'admin',
// requireAdmin: true,
// },
// {
// path: '/analytics',
// key: '/analytics',
// icon: <DashboardOutlined />,
// label: '数据统计',
// title: '数据统计',
// subtitle: '了解系统数据',
// group: 'admin',
// requireAdmin: true,
// },
];
/**
-68
View File
@@ -1,68 +0,0 @@
import { Form, Input, Button, Card, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { authService } from '@/services/auth';
import type { LoginRequest } from '@/services/auth';
import './LoginPage.css';
const LoginPage = () => {
const navigate = useNavigate();
const [form] = Form.useForm();
const handleLogin = async (values: LoginRequest) => {
try {
// TODO: 实现实际的登录逻辑
// await authService.login(values);
// navigate('/words');
message.info('登录功能待实现');
} catch (error) {
message.error('登录失败,请检查用户名和密码');
}
};
return (
<div className="login-page">
<Card className="login-card">
<div className="login-header">
<h1></h1>
<p></p>
</div>
<Form
form={form}
name="login"
onFinish={handleLogin}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default LoginPage;
+95 -93
View File
@@ -7,106 +7,108 @@ import { wordsService } from '@/services/words';
import type { Word } from '@/types/words';
import './index.css';
export const WordsPage: React.FC = () => {
const [words, setWords] = useState<Word[]>([]);
const [loading, setLoading] = useState(false);
const [selectedWord, setSelectedWord] = useState<Word | null>(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [limit] = useState(9);
const WordsPage: React.FC = () => {
const [words, setWords] = useState<Word[]>([]);
const [loading, setLoading] = useState(false);
const [selectedWord, setSelectedWord] = useState<Word | null>(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [limit] = useState(9);
useEffect(() => {
loadWords();
}, [page, search]);
useEffect(() => {
loadWords();
}, [page, search]);
const loadWords = async () => {
try {
setLoading(true);
const response = await wordsService.getWords({
search: search || undefined,
page,
limit,
});
setWords(response.data);
setTotal(response.total);
} catch (error) {
console.error('Failed to load words:', error);
} finally {
setLoading(false);
}
};
const loadWords = async () => {
try {
setLoading(true);
const response = await wordsService.getWords({
search: search || undefined,
page,
limit,
});
setWords(response.data);
setTotal(response.total);
} catch (error) {
console.error('Failed to load words:', error);
} finally {
setLoading(false);
}
};
const handleCardClick = async (word: Word) => {
try {
// 加载完整数据
const fullWord = await wordsService.getWord(word.id);
setSelectedWord(fullWord);
setDrawerVisible(true);
} catch (error) {
console.error('Failed to load word details:', error);
}
};
const handleCardClick = async (word: Word) => {
try {
// 加载完整数据
const fullWord = await wordsService.getWord(word.id);
setSelectedWord(fullWord);
setDrawerVisible(true);
} catch (error) {
console.error('Failed to load word details:', error);
}
};
const handleSearch = (value: string) => {
setSearch(value);
setPage(1);
};
const handleSearch = (value: string) => {
setSearch(value);
setPage(1);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="words-page">
<div className="words-page-header">
<h1></h1>
<Input
placeholder="搜索汉字"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearch(e.target.value)}
allowClear
style={{ width: 300 }}
/>
</div>
<Spin spinning={loading}>
{words.length === 0 ? (
<Empty description="暂无数据" />
) : (
<>
<div className="words-grid">
{words.map((word) => (
<WordCard
key={word.id}
word={word}
onClick={() => handleCardClick(word)}
return (
<div className="words-page">
<div className="words-page-header">
<h1></h1>
<Input
placeholder="搜索汉字"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearch(e.target.value)}
allowClear
style={{ width: 300 }}
/>
))}
</div>
<div className="words-pagination">
<Pagination
current={page}
total={total}
pageSize={limit}
onChange={handlePageChange}
showSizeChanger={false}
showTotal={(total) => `${total} 个汉字`}
/>
</div>
</>
)}
</Spin>
<WordDetailDrawer
word={selectedWord}
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onRefresh={loadWords}
/>
</div>
);
<Spin spinning={loading}>
{words.length === 0 ? (
<Empty description="暂无数据" />
) : (
<>
<div className="words-grid">
{words.map((word) => (
<WordCard
key={word.id}
word={word}
onClick={() => handleCardClick(word)}
/>
))}
</div>
<div className="words-pagination">
<Pagination
current={page}
total={total}
pageSize={limit}
onChange={handlePageChange}
showSizeChanger={false}
showTotal={(total) => `${total} 个汉字`}
/>
</div>
</>
)}
</Spin>
<WordDetailDrawer
word={selectedWord}
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onRefresh={loadWords}
/>
</div>
);
};
export default WordsPage;
@@ -3,14 +3,16 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #93D333 0%, #7CB518 100%);
background: linear-gradient(135deg, #E6F3EC 0%, #D1E8DB 100%);
padding: 24px;
}
.login-card {
width: 100%;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: white;
padding: 40px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border-radius: 12px;
}
@@ -30,3 +32,19 @@
color: #6b7280;
font-size: 14px;
}
.login-form {
margin-top: 24px;
}
.login-form .ant-input-affix-wrapper,
.login-form .ant-input {
border-radius: 8px;
}
.login-button {
height: 44px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
}
+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { Button, Form, Input, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { authService } from '@/services/auth';
import type { LoginRequest } from '@/services/auth';
import './LoginPage.css';
export default function LoginPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (authService.isAuthenticated()) {
navigate('/', { replace: true });
}
}, [navigate]);
const onFinish = async (values: LoginRequest) => {
setLoading(true);
try {
await authService.login(values);
message.success('登录成功');
navigate('/', { replace: true });
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
'登录失败,请检查用户名/邮箱和密码';
message.error(msg);
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<h1></h1>
<p></p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
className="login-form"
>
<Form.Item
name="usernameOrEmail"
rules={[
{ required: true, message: '请输入用户名或邮箱' },
{ min: 3, message: '用户名或邮箱至少 3 个字符' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名或邮箱" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少 6 个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
className="login-button"
>
</Button>
</Form.Item>
</Form>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from './LoginPage';
@@ -0,0 +1,15 @@
.profile-content {
padding: 24px 0;
}
.profile-avatar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.profile-field-text {
font-size: 14px;
line-height: 1.5715;
color: rgba(0, 0, 0, 0.88);
}
@@ -0,0 +1,322 @@
import { useState, useEffect, useRef } from 'react';
import {
Tabs,
Card,
Avatar,
Form,
Input,
Button,
Space,
App as AntdApp,
Modal,
} from 'antd';
import type { TabsProps } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { authService } from '@/services/auth';
import { usersService } from '@/services/users';
import type { User } from '@/types/user';
import dayjs from 'dayjs';
import './ProfilePage.css';
function calculateUsageDays(createdAt: string | Date): string {
const created = dayjs(createdAt);
const now = dayjs();
const days = now.diff(created, 'day');
if (days < 365) return `${days}`;
const years = Math.floor(days / 365);
const remaining = days % 365;
return `${years}${remaining}`;
}
export default function ProfilePage() {
const { message: messageApi } = AntdApp.useApp();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [passwordForm] = Form.useForm();
const [infoForm] = Form.useForm();
const [editing, setEditing] = useState(false);
const loadingRef = useRef(false);
const loadUser = async () => {
if (loadingRef.current) return;
const current = authService.getUser();
if (!current) {
messageApi.error('未找到用户信息');
return;
}
loadingRef.current = true;
setLoading(true);
try {
const data = await usersService.getById(current.id);
setUser(data);
infoForm.setFieldsValue({
realName: data.realName ?? '',
email: data.email ?? '',
});
} catch (e: unknown) {
const err = e as { message?: string };
messageApi.error(err?.message ?? '加载用户信息失败');
} finally {
setLoading(false);
loadingRef.current = false;
}
};
useEffect(() => {
loadUser();
}, []);
const handleEdit = () => {
if (user) {
infoForm.setFieldsValue({
realName: user.realName ?? '',
email: user.email ?? '',
});
setEditing(true);
}
};
const handleCancelEdit = () => {
setEditing(false);
infoForm.resetFields();
};
const handleUpdateInfo = async () => {
try {
await infoForm.validateFields();
if (!user) return;
Modal.confirm({
title: '确认更新',
content: '确定要更新个人信息吗?',
onOk: async () => {
try {
const v = infoForm.getFieldsValue();
await usersService.updateUser(user.id, {
realName: v.realName || undefined,
email: v.email || undefined,
});
messageApi.success('个人信息更新成功');
setEditing(false);
await loadUser();
} catch (e: unknown) {
const err = e as { message?: string };
messageApi.error(err?.message ?? '更新失败');
}
},
});
} catch {
// 校验未通过
}
};
const handleChangePassword = async () => {
try {
const v = await passwordForm.validateFields();
if (!user) return;
await usersService.changePassword(user.id, {
oldPassword: v.oldPassword,
newPassword: v.newPassword,
});
messageApi.success('密码修改成功');
passwordForm.resetFields();
} catch (e: unknown) {
if ((e as { errorFields?: unknown })?.errorFields) return;
const err = e as { message?: string };
messageApi.error(err?.message ?? '密码修改失败');
}
};
if (!user) {
return <Card loading={loading}>...</Card>;
}
const tabItems: TabsProps['items'] = [
{
key: 'info',
label: '个人信息',
children: (
<div className="profile-content">
<Form
form={infoForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
>
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
<div className="profile-avatar-wrapper">
{(user.realName || user.username) ? (
<Avatar
size={100}
style={{ backgroundColor: '#4E9B6E' }}
>
{(user.realName || user.username)![0]}
</Avatar>
) : (
<Avatar
size={100}
icon={<UserOutlined />}
style={{ backgroundColor: '#4E9B6E' }}
/>
)}
<span
style={{
marginTop: 12,
fontSize: 14,
color: 'rgba(0, 0, 0, 0.65)',
}}
>
</span>
</div>
</Form.Item>
<Form.Item label="用户名">
<span className="profile-field-text">{user.username}</span>
</Form.Item>
<Form.Item
label="真实姓名"
rules={[{ max: 50, message: '真实姓名不能超过50个字符' }]}
>
{editing ? (
<Form.Item
name="realName"
noStyle
rules={[{ max: 50, message: '真实姓名不能超过50个字符' }]}
>
<Input placeholder="请输入真实姓名" />
</Form.Item>
) : (
<span className="profile-field-text">
{user.realName ?? '-'}
</span>
)}
</Form.Item>
<Form.Item
label="邮箱"
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
]}
>
{editing ? (
<Form.Item
name="email"
noStyle
rules={[
{ type: 'email', message: '邮箱格式不正确' },
{ max: 100, message: '邮箱不能超过100个字符' },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
) : (
<span className="profile-field-text">
{user.email ?? '-'}
</span>
)}
</Form.Item>
<Form.Item label="注册时间">
<span className="profile-field-text">
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</span>
</Form.Item>
<Form.Item label="使用天数">
<span className="profile-field-text">
{calculateUsageDays(user.createdAt)}
</span>
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
{editing ? (
<Space>
<Button type="primary" onClick={handleUpdateInfo}>
</Button>
<Button onClick={handleCancelEdit}></Button>
</Space>
) : (
<Button type="primary" onClick={handleEdit}>
</Button>
)}
</Form.Item>
</Form>
</div>
),
},
{
key: 'password',
label: '修改密码',
children: (
<div className="profile-content">
<Form
form={passwordForm}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
style={{ maxWidth: 600 }}
onFinish={handleChangePassword}
>
<Form.Item
name="oldPassword"
label="原密码"
rules={[{ required: true, message: '请输入原密码' }]}
>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少6位' },
{ max: 100, message: '密码不能超过100位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
</div>
),
},
];
return (
<div className="profile-page">
<Card>
<Tabs defaultActiveKey="info" items={tabItems} />
</Card>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as ProfilePage } from './ProfilePage';
export { default } from './ProfilePage';
@@ -0,0 +1,143 @@
import {
Modal,
Descriptions,
Avatar,
Tag,
Space,
Button,
Popconfirm,
} from 'antd';
import { UserOutlined, DeleteOutlined } from '@ant-design/icons';
import type { User } from '@/types/user';
import { getRoleText, getStatusText } from '@/types/user';
import dayjs from 'dayjs';
interface UserDetailModalProps {
open: boolean;
user: User | null;
/** 是否展示删除按钮(仅超级管理员可删除已冻结且非超管用户) */
canDelete?: boolean;
onCancel: () => void;
onDelete?: (id: number) => void | Promise<void>;
}
export default function UserDetailModal({
open,
user,
canDelete = false,
onCancel,
onDelete,
}: UserDetailModalProps) {
if (!user) return null;
const showDelete = canDelete && !!onDelete;
return (
<Modal
title="用户详情"
open={open}
onCancel={onCancel}
footer={[
<Button key="close" onClick={onCancel}>
</Button>,
showDelete && (
<Popconfirm
key="delete"
title="确定要删除这个用户吗?"
description="删除后无法恢复"
onConfirm={() => onDelete?.(user.id)}
okText="确定"
cancelText="取消"
>
<Button key="delete" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
),
].filter(Boolean)}
width={640}
>
<div style={{ marginBottom: 24, textAlign: 'center' }}>
<Space size={16} align="center">
{(user.realName || user.username) ? (
<Avatar
size={60}
style={{ backgroundColor: '#4E9B6E' }}
>
{(user.realName || user.username)![0]}
</Avatar>
) : (
<Avatar
size={60}
icon={<UserOutlined />}
style={{ backgroundColor: '#4E9B6E' }}
/>
)}
<span
style={{
fontSize: 20,
fontWeight: 'bold',
color: '#262626',
}}
>
{user.realName || user.username}
</span>
</Space>
</div>
<Descriptions column={2} bordered>
<Descriptions.Item label="用户ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">
{user.email ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="真实姓名">
{user.realName ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="角色">
<Tag
color={
user.role === 'super_admin'
? 'red'
: user.role === 'admin'
? 'orange'
: 'blue'
}
>
{getRoleText(user.role)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag
color={
user.status === 'active'
? 'success'
: user.status === 'inactive'
? 'warning'
: 'error'
}
>
{getStatusText(user.status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="更新时间" span={2}>
{dayjs(user.updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="最后登录时间" span={2}>
{user.lastLoginAt
? dayjs(user.lastLoginAt).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
{user.lastLoginIp && (
<Descriptions.Item label="最后登录IP" span={2}>
{user.lastLoginIp}
</Descriptions.Item>
)}
</Descriptions>
</Modal>
);
}
+7
View File
@@ -0,0 +1,7 @@
.user-page .user-search-form {
margin-bottom: 16px;
}
.user-page .user-search-form .ant-form-item {
margin-bottom: 16px;
}
+407
View File
@@ -0,0 +1,407 @@
import { useState, useEffect, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Space,
Avatar,
Popconfirm,
Card,
Form,
Row,
Col,
App as AntdApp,
Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
LockOutlined,
UnlockOutlined,
UserOutlined,
} from '@ant-design/icons';
import { usersService } from '@/services/users';
import { authService } from '@/services/auth';
import type { User, QueryUserRequest } from '@/types/user';
import {
USER_ROLE_OPTIONS,
USER_STATUS_OPTIONS,
getRoleText,
getStatusText,
} from '@/types/user';
import UserDetailModal from './UserDetailModal';
import dayjs from 'dayjs';
import './UserPage.css';
export default function UserPage() {
const { message: messageApi } = AntdApp.useApp();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const [form] = Form.useForm();
const [detailOpen, setDetailOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const queryRef = useRef<QueryUserRequest>({});
const loadData = async (params?: QueryUserRequest, resetPage = false) => {
setLoading(true);
try {
const basePage = resetPage ? 1 : pagination.current;
const baseSize = pagination.pageSize;
const query: QueryUserRequest = {
page: params?.page ?? basePage,
limit: params?.limit ?? baseSize,
sortBy: 'createdAt',
sortOrder: 'DESC',
...queryRef.current,
...params,
};
const res = await usersService.getList(query);
setUsers(res.list);
setPagination((prev) => ({
...prev,
current: res.pagination.current_page,
pageSize: res.pagination.page_size,
total: res.pagination.total,
}));
} catch (e: unknown) {
const err = e as { message?: string };
messageApi.error(err?.message || '加载用户列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData({}, true);
}, []);
const handleSearch = () => {
const v = form.getFieldsValue();
queryRef.current = {
username: v.username || undefined,
realName: v.realName || undefined,
email: v.email || undefined,
role: v.role || undefined,
status: v.status || undefined,
};
loadData(queryRef.current, true);
};
const handleReset = () => {
form.resetFields();
queryRef.current = {};
loadData({}, true);
};
const currentUser = authService.getUser();
const isSuperAdmin = currentUser?.role === 'super_admin';
const handleView = (record: User) => {
setSelectedUser(record);
setDetailOpen(true);
};
const handleToggleStatus = async (user: User) => {
try {
const next = user.status === 'active' ? 'inactive' : 'active';
if (next === 'inactive') {
await authService.freezeUser(user.id);
} else {
await authService.activateUser(user.id);
}
messageApi.success(next === 'inactive' ? '用户已冻结' : '用户已解冻');
loadData();
} catch (e: unknown) {
const err = e as { message?: string };
messageApi.error(err?.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
try {
await usersService.deleteUser(id);
messageApi.success('删除成功');
setDetailOpen(false);
setSelectedUser(null);
loadData();
} catch (e: unknown) {
const err = e as { message?: string };
messageApi.error(err?.message || '删除失败');
}
};
const columns: ColumnsType<User> = [
{
title: '头像',
key: 'avatar',
width: 72,
render: (_: unknown, r: User) => (
<Avatar
size={32}
icon={<UserOutlined />}
style={{ backgroundColor: '#4E9B6E' }}
>
{(r.realName || r.username)?.[0]}
</Avatar>
),
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120,
},
{
title: '真实姓名',
dataIndex: 'realName',
key: 'realName',
width: 120,
render: (v: string | null) => v ?? '-',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180,
render: (v: string | null) => v ?? '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (d: string) => dayjs(d).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
width: 160,
render: (d: string | null) =>
d ? dayjs(d).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (s: string) => (
<Tag
color={
s === 'active'
? 'success'
: s === 'inactive'
? 'warning'
: 'error'
}
>
{getStatusText(s)}
</Tag>
),
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 110,
render: (r: string) => (
<Tag
color={
r === 'super_admin' ? 'red' : r === 'admin' ? 'orange' : 'blue'
}
>
{getRoleText(r)}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_: unknown, record: User) => {
const frozen = record.status === 'inactive';
const superAdmin = record.role === 'super_admin';
return (
<Space size={0}>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleView(record)}
>
</Button>
{!superAdmin && (
<>
{frozen ? (
<Popconfirm
title="确定要解冻这个用户吗?"
description="解冻后用户可以正常使用"
onConfirm={() => handleToggleStatus(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" icon={<UnlockOutlined />}>
</Button>
</Popconfirm>
) : (
<Popconfirm
title="确定要冻结这个用户吗?"
description="冻结后用户将无法登录和使用系统"
onConfirm={() => handleToggleStatus(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" icon={<LockOutlined />}>
</Button>
</Popconfirm>
)}
</>
)}
</Space>
);
},
},
];
return (
<div className="user-page">
<Card>
<Form
form={form}
layout="inline"
className="user-search-form"
>
<Row gutter={16} style={{ width: '100%' }}>
<Col span={6}>
<Form.Item name="username" label="用户名">
<Input
placeholder="请输入用户名"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="realName" label="真实姓名">
<Input
placeholder="请输入真实姓名"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="email" label="邮箱">
<Input
placeholder="请输入邮箱"
allowClear
onPressEnter={handleSearch}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="role" label="角色" initialValue="">
<Select
placeholder="请选择角色"
allowClear
style={{ width: '100%' }}
>
{USER_ROLE_OPTIONS.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="status" label="状态" initialValue="">
<Select
placeholder="请选择状态"
allowClear
style={{ width: '100%' }}
>
{USER_STATUS_OPTIONS.map((o) => (
<Select.Option key={o.value} value={o.value}>
{o.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={handleSearch}
>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
>
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
<Table<User>
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, pageSize) => {
const nextSize = pageSize || 10;
setPagination((prev) => ({
...prev,
current: page,
pageSize: nextSize,
}));
loadData({ page, limit: nextSize });
},
}}
scroll={{ x: 1200 }}
/>
</Card>
<UserDetailModal
open={detailOpen}
user={selectedUser}
canDelete={
!!selectedUser &&
selectedUser.status === 'inactive' &&
selectedUser.role !== 'super_admin' &&
isSuperAdmin
}
onCancel={() => {
setDetailOpen(false);
setSelectedUser(null);
}}
onDelete={handleDelete}
/>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as UserPage } from './UserPage';
export { default } from './UserPage';
+12 -2
View File
@@ -4,8 +4,10 @@ import MainLayout from '../layouts/MainLayout';
import ProtectedRoute from '../components/ProtectedRoute';
import ErrorPage from '../components/ErrorPage';
const WordsPage = lazy(() => import('../pages/Words'));
const LoginPage = lazy(() => import('../pages/LoginPage'));
const WordsPage = lazy(() => import('../pages/words'));
const UsersPage = lazy(() => import('../pages/users'));
const ProfilePage = lazy(() => import('../pages/profile'));
const LoginPage = lazy(() => import('../pages/login'));
export const router = createBrowserRouter([
{
@@ -30,6 +32,14 @@ export const router = createBrowserRouter([
path: 'words',
element: <WordsPage />,
},
{
path: 'users',
element: <UsersPage />,
},
{
path: 'profile',
element: <ProfilePage />,
},
],
},
{
+19 -3
View File
@@ -16,13 +16,14 @@ export interface UserInfo {
nickname?: string;
avatarUrl?: string;
role: string;
status?: string;
}
/**
* 登录请求参数
* 登录请求参数(与 API LoginDto 一致)
*/
export interface LoginRequest {
username: string;
usernameOrEmail: string;
password: string;
}
@@ -42,7 +43,6 @@ class AuthService {
* 登录
*/
async login(credentials: LoginRequest): Promise<LoginResponse> {
// TODO: 根据实际 API 调整
const response = await api.post<LoginResponse>('/auth/login', credentials);
this.setToken(response.accessToken);
this.setUser(response.user);
@@ -120,6 +120,22 @@ class AuthService {
const user = this.getUser();
return user?.role === 'admin' || user?.role === 'super_admin';
}
/**
* 冻结用户(将状态设为 inactive)
* 需要管理员权限,调用 PATCH /users/:id
*/
async freezeUser(id: number): Promise<UserInfo> {
return api.patch<UserInfo>(`/users/${id}`, { status: 'inactive' });
}
/**
* 激活用户(将状态设为 active)
* 需要管理员权限,调用 PATCH /users/:id
*/
async activateUser(id: number): Promise<UserInfo> {
return api.patch<UserInfo>(`/users/${id}`, { status: 'active' });
}
}
// 导出单例
+37
View File
@@ -0,0 +1,37 @@
import { api } from './api';
import type { User, QueryUserRequest, PaginatedUserResponse } from '@/types/user';
/**
* 用户管理服务(列表、详情、删除、更新、改密;冻结/激活见 authService
*/
class UsersService {
async getList(params: QueryUserRequest): Promise<PaginatedUserResponse> {
return api.get<PaginatedUserResponse>('/users', { params });
}
async getById(id: number): Promise<User> {
return api.get<User>(`/users/${id}`);
}
async deleteUser(id: number): Promise<void> {
return api.delete(`/users/${id}`);
}
/** 更新用户信息(个人中心用:email、realName */
async updateUser(
id: number,
data: { email?: string; realName?: string },
): Promise<User> {
return api.patch<User>(`/users/${id}`, data);
}
/** 修改密码 */
async changePassword(
id: number,
data: { oldPassword: string; newPassword: string },
): Promise<void> {
return api.patch(`/users/${id}/password`, data);
}
}
export const usersService = new UsersService();
+60
View File
@@ -0,0 +1,60 @@
/**
* 用户管理相关类型(与 API 一致,无头像上传)
*/
export interface User {
id: number;
username: string;
email: string | null;
realName: string | null;
role: string;
status: string;
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
lastLoginIp: string | null;
}
export interface QueryUserRequest {
username?: string;
realName?: string;
email?: string;
role?: string;
status?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface PaginatedUserResponse {
list: User[];
pagination: {
total: number;
total_page: number;
page_size: number;
current_page: number;
};
}
export const USER_ROLE_OPTIONS = [
{ label: '不限', value: '' },
{ label: '管理员', value: 'admin' },
{ label: '超级管理员', value: 'super_admin' },
] as const;
export const USER_STATUS_OPTIONS = [
{ label: '不限', value: '' },
{ label: '活跃', value: 'active' },
{ label: '冻结', value: 'inactive' },
] as const;
export function getRoleText(role: string): string {
const o = USER_ROLE_OPTIONS.find((opt) => opt.value === role);
return o ? o.label : role;
}
export function getStatusText(status: string): string {
const o = USER_STATUS_OPTIONS.find((opt) => opt.value === status);
return o ? o.label : status;
}
+8 -8
View File
@@ -17,11 +17,11 @@ JWT_EXPIRES_IN=7d
# 用户种子数据配置(开发环境自动创建初始管理员用户)
ENABLE_USER_SEEDER=true
SUPER_ADMIN_USERNAME=superadmin
SUPER_ADMIN_PASSWORD=admin123
SUPER_ADMIN_EMAIL=superadmin@doodle.com
SUPER_ADMIN_REAL_NAME=超级管理员
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_EMAIL=admin@doodle.com
ADMIN_REAL_NAME=系统管理员
SUPER_ADMIN_USERNAME=joey
SUPER_ADMIN_PASSWORD=joey@5628
SUPER_ADMIN_EMAIL=zhangyi5628@126.com
SUPER_ADMIN_REAL_NAME=Joey超级管理员
ADMIN_USERNAME=tuyaya
ADMIN_PASSWORD=Tuyaya@5628
ADMIN_EMAIL=joeyswork@126.com
ADMIN_REAL_NAME=Tuyaya管理员
+7 -7
View File
@@ -3,14 +3,14 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WordsModule } from './modules/words/words.module';
import { CharsModule } from './modules/chars/chars.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { User } from './modules/users/user.entity';
import { Word } from './modules/words/word.entity';
import { WordImage } from './modules/words/word-image.entity';
import { Sentence } from './modules/words/sentence.entity';
import { WordSentence } from './modules/words/word-sentence.entity';
import { Character } from './modules/chars/character.entity';
import { CharacterImage } from './modules/chars/character-image.entity';
import { Sentence } from './modules/chars/sentence.entity';
import { CharacterSentence } from './modules/chars/character-sentence.entity';
@Module({
imports: [
@@ -27,13 +27,13 @@ import { WordSentence } from './modules/words/word-sentence.entity';
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE', 'doodle'),
entities: [Word, WordImage, Sentence, WordSentence, User],
entities: [User, Character, CharacterImage, Sentence, CharacterSentence],
synchronize: configService.get('NODE_ENV') !== 'production', // 生产环境应设为false
logging: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
WordsModule,
CharsModule,
AuthModule,
UsersModule,
],
+98
View File
@@ -0,0 +1,98 @@
### char_common_base.json 常用字
```json
[
{
"index": 52,
"char": "反",
"strokes": 4,
"pinyin": ["fǎn"],
"radicals": "又",
"frequency": 0,
"structure": "R2"
},
{
"index": 18,
"char": "干",
"strokes": 3,
"pinyin": ["gān", "gàn"],
"radicals": "干",
"frequency": 0,
"structure": "D0",
"traditional": "乾幹",
"variant": "乹亁榦"
}
]
```
- `index` 表示从 1 开始的自然增长序列,唯一不重复,前8000按照《通用规范汉字表》给的顺序。
- `char` 表示一个汉字,唯一不重复。
- `strokes` 汉字的笔画数。
- `pinyin` 汉字的读音列表,数组表示,多音字会有多个读音。
- `radicals` 汉字的读音偏旁部首。
- `frequency` 表示使用频率,0 为最常用,1 为较常用,2 为次常用,3 为二级字,4 为三级字, 5 为不在《通用规范汉字》的生僻字。
- `structure` 汉字结构,结构表示的含义见下表。
- `traditional` 表示繁体字写法,可能会有多个。
- `variant` 表示异体字,可能会有多个。
> 常用字(3500) = 最常用字 0(500)+ 较常用字 1(2000+ 次常用字 2(1000
> 通用规范汉字(8105= 一级字 0/1/23500+ 3 二级字(3000+ 4 三级字(1605
### char_common.json 常用字
> 考虑到某些场景下,只需要关注 3500 常用字,因此特意建了 common 文件,在其中仅包含常用字 3500 的相关数据。
```json
[
{ "index": 4, "char": "十", "frequency": 0 },
{ "index": 278, "char": "乐", "frequency": 1 },
{ "index": 3405, "char": "瞪", "frequency": 2 }
]
```
- `id` 表示从 1 开始的自然增长序列,默认按照汉字笔画数排序。
- `char` 表示一个汉字。
- `frequency` 表示使用频率,0 为最常用,1 为较常用,2 为次常用。
#### 汉字结构
《汉字结构表》统计了汉字的八种结构类型:
| 结 构 方 式 | 例 字 | 间 架 比 例 | 代码 |
| -------------- | ------ | ----------- | ---- |
| 独 体 结 构 | 米、不 |  方正 | D0 |
| 品 字 形 结 构 | 晶、众 | 各部分相同 | A0 |
| 上 下 结 构 | | | B0 |
| - | 录、华 |  上下相等 | B1 |
| - | 它、花 | 上小下大 | B2 |
| - | 基、想 | 上大下小 | B3 |
| 上 中 下 结 构 | | | E0 |
| - | 意、翼 | 上中下相等 | E1 |
| - | 量、裹 | 上中下不等 | E2 |
| 左 右 结 构 | | | H0 |
| - | 羽、联 | 左右相等 | H1 |
| - | 伟、搞 | 左窄右宽 | H2 |
| - | 刚、郭 | 左宽右窄 | H3 |
| 左 中 右 结 构 | | | M0 |
| - | 街、掰 | 左中右相等 | M1 |
| - | 辩、傲 | 左中右不等 | M2 |
| 全 包 围 结 构 | 圆、国 | 全包围 | Q0 |
| 半 包 围 结 构 | | | R0 |
| - | 匠、区 | 左包右 | R1 |
| - | 历、尾 | 左上包右下 | R2 |
| - | 勾、句 | 右上包左下 | R3 |
| - | 遍、廷 | 左下包右上 | R4 |
| - | 冈、闲 | 上包下 | R5 |
| - | 函、凶 | 下包上 | R6 |
一般认为上面的分类可以覆盖所有的汉字,但是,还有一些分法更细一些:
在独体结构中分出了镶嵌结构,在上下结构中分出了田字结构:
| 结 构 方 式 | 例 字 | 间 架 比 例 | 代码 |
| ----------- | ------ | ------------ | ---- |
| 独 体 结 构 | |   | D0 |
| 镶嵌结构 | 爽 |  方正 | D1 |
| 上 下 结 构 | | | B0 |
| 田字结构 | 叕、茻 |  四部分相同 | B4 |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+158
View File
@@ -0,0 +1,158 @@
{
"D0": {
"code": "D0",
"name": "独体结构",
"example": "米、不",
"proportion": "方正"
},
"D1": {
"code": "D1",
"name": "镶嵌结构",
"example": "爽",
"proportion": "方正"
},
"A0": {
"code": "A0",
"name": "品字形结构",
"example": "晶、众",
"proportion": "各部分相同"
},
"B0": {
"code": "B0",
"name": "上下结构",
"example": "",
"proportion": ""
},
"B1": {
"code": "B1",
"name": "上下结构",
"example": "录、华",
"proportion": "上下相等"
},
"B2": {
"code": "B2",
"name": "上下结构",
"example": "它、花",
"proportion": "上小下大"
},
"B3": {
"code": "B3",
"name": "上下结构",
"example": "基、想",
"proportion": "上大下小"
},
"B4": {
"code": "B4",
"name": "田字结构",
"example": "叕、茻",
"proportion": "四部分相同"
},
"E0": {
"code": "E0",
"name": "上中下结构",
"example": "",
"proportion": ""
},
"E1": {
"code": "E1",
"name": "上中下结构",
"example": "意、翼",
"proportion": "上中下相等"
},
"E2": {
"code": "E2",
"name": "上中下结构",
"example": "量、裹",
"proportion": "上中下不等"
},
"H0": {
"code": "H0",
"name": "左右结构",
"example": "",
"proportion": ""
},
"H1": {
"code": "H1",
"name": "左右结构",
"example": "羽、联",
"proportion": "左右相等"
},
"H2": {
"code": "H2",
"name": "左右结构",
"example": "伟、搞",
"proportion": "左窄右宽"
},
"H3": {
"code": "H3",
"name": "左右结构",
"example": "刚、郭",
"proportion": "左宽右窄"
},
"M0": {
"code": "M0",
"name": "左中右结构",
"example": "",
"proportion": ""
},
"M1": {
"code": "M1",
"name": "左中右结构",
"example": "街、掰",
"proportion": "左中右相等"
},
"M2": {
"code": "M2",
"name": "左中右结构",
"example": "辩、傲",
"proportion": "左中右不等"
},
"Q0": {
"code": "Q0",
"name": "全包围结构",
"example": "圆、国",
"proportion": "全包围"
},
"R0": {
"code": "R0",
"name": "半包围结构",
"example": "",
"proportion": ""
},
"R1": {
"code": "R1",
"name": "半包围结构",
"example": "匠、区",
"proportion": "左包右"
},
"R2": {
"code": "R2",
"name": "半包围结构",
"example": "历、尾",
"proportion": "左上包右下"
},
"R3": {
"code": "R3",
"name": "半包围结构",
"example": "勾、句",
"proportion": "右上包左下"
},
"R4": {
"code": "R4",
"name": "半包围结构",
"example": "遍、廷",
"proportion": "左下包右上"
},
"R5": {
"code": "R5",
"name": "半包围结构",
"example": "冈、闲",
"proportion": "上包下"
},
"R6": {
"code": "R6",
"name": "半包围结构",
"example": "函、凶",
"proportion": "下包上"
}
}
-2
View File
@@ -1,2 +0,0 @@
// Word 相关实体已移动到 modules/words 目录
export * from '../modules/users/user.entity';
+3 -2
View File
@@ -1,5 +1,5 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { LoginResponse } from './interfaces/login-response.interface';
@@ -41,8 +41,9 @@ export class AuthController {
},
},
})
@ApiResponse({ status: 401, description: '用户名或密码错误' })
@ApiResponse({ status: 401, description: '用户名或密码错误 / 用户已被禁用' })
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiBody({ type: LoginDto })
async login(@Body() loginDto: LoginDto, @Ip() ip: string): Promise<LoginResponse> {
return this.authService.login(loginDto, ip);
}
@@ -4,32 +4,30 @@ import {
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Word } from './word.entity';
import { Character } from './character.entity';
export enum ImageType {
ORIGINAL = 'original',
STANDARD = 'standard',
}
@Entity('word_images')
export class WordImage {
@Entity('char_images')
export class CharacterImage {
@ApiProperty({ description: '图片ID', example: 1 })
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;
@ApiProperty({ description: '字ID', example: 1 })
@Column({ type: 'bigint', name: 'word_id' })
@ApiProperty({ description: '字ID', example: 1 })
@Column({ type: 'bigint', name: 'char_id' })
@Index()
wordId: number;
charId: number;
@ManyToOne(() => Word, (word) => word.images, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'word_id' })
word: Word;
@ManyToOne(() => Character, (character) => character.images, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'char_id' })
character: Character;
@ApiProperty({
description: '图片类型',
@@ -44,11 +42,11 @@ export class WordImage {
})
imageType: ImageType;
@ApiProperty({ description: '图片文件路径', example: '/images/word1.jpg', maxLength: 500 })
@ApiProperty({ description: '图片文件路径', example: '/images/character1.jpg', maxLength: 500 })
@Column({ type: 'varchar', length: 500, name: 'file_path' })
filePath: string;
@ApiProperty({ description: '原始文件名', example: 'word1.jpg', maxLength: 200 })
@ApiProperty({ description: '原始文件名', example: 'character1.jpg', maxLength: 200 })
@Column({ type: 'varchar', length: 200, name: 'file_name' })
fileName: string;
@@ -100,18 +98,4 @@ export class WordImage {
})
@Column({ type: 'int', default: 0, name: 'sort_order' })
sortOrder: number;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ApiProperty({
description: '更新时间',
example: '2024-01-01T00:00:00.000Z',
})
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -4,32 +4,31 @@ import {
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
Index,
} from 'typeorm';
import { Word } from './word.entity';
import { Character } from './character.entity';
import { Sentence } from './sentence.entity';
@Entity('word_sentences')
@Unique(['wordId', 'sentenceId'])
export class WordSentence {
@Entity('char_sentences')
@Unique(['charId', 'sentenceId'])
export class CharacterSentence {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;
@Column({ type: 'bigint', name: 'word_id' })
@Column({ type: 'bigint', name: 'char_id' })
@Index()
wordId: number;
charId: number;
@ManyToOne(() => Word, (word) => word.wordSentences, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'word_id' })
word: Word;
@ManyToOne(() => Character, (character) => character.characterSentences, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'char_id' })
character: Character;
@Column({ type: 'bigint', name: 'sentence_id' })
@Index()
sentenceId: number;
@ManyToOne(() => Sentence, (sentence) => sentence.wordSentences, {
@ManyToOne(() => Sentence, (sentence) => sentence.characterSentences, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'sentence_id' })
@@ -37,7 +36,4 @@ export class WordSentence {
@Column({ type: 'int', default: 0, name: 'sort_order' })
sortOrder: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
@@ -0,0 +1,135 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
OneToMany,
Index,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CharacterImage } from './character-image.entity';
import { CharacterSentence } from './character-sentence.entity';
export enum CharacterStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
@Entity('chars')
export class Character {
@ApiProperty({ description: '汉字ID', example: 1 })
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;
@ApiProperty({ description: '汉字内容', example: '中', maxLength: 10 })
@Column({ type: 'varchar', length: 10, unique: true })
@Index()
char: string;
@ApiProperty({ description: '笔画数', example: 4 })
@Column({ type: 'int' })
@Index()
strokes: number;
@ApiProperty({
description: '拼音数组(支持多音字)',
type: [String],
example: ['zhōng', 'zhòng'],
})
@Column({ type: 'text', array: true })
@Index()
pinyin: string[];
@ApiPropertyOptional({
description: '偏旁部首',
example: '丨',
maxLength: 10,
nullable: true,
})
@Column({ type: 'varchar', length: 10, nullable: true })
@Index()
radicals: string | null;
@ApiPropertyOptional({
description: '使用频率: 0(最常用), 1(较常用), 2(次常用), 3(二级字), 4(三级字), 5(生僻字)',
example: 0,
nullable: true,
})
@Column({ type: 'int', nullable: true })
@Index()
frequency: number | null;
@ApiPropertyOptional({
description: '汉字结构代码,如"D0"(独体结构), "H2"(左窄右宽)等',
example: 'H2',
maxLength: 10,
nullable: true,
})
@Column({ type: 'varchar', length: 10, nullable: true })
@Index()
structure: string | null;
@ApiPropertyOptional({
description: '繁体字写法,可能有多个,用逗号分隔',
example: '乾幹',
maxLength: 50,
nullable: true,
})
@Column({ type: 'varchar', length: 50, nullable: true })
traditional: string | null;
@ApiPropertyOptional({
description: '笔画数据,JSON格式,存储笔画顺序和路径信息',
nullable: true,
})
@Column({ type: 'jsonb', nullable: true, name: 'stroke_data' })
strokeData: any | null;
@ApiPropertyOptional({
description: '年级(0-9',
example: 1,
nullable: true,
})
@Column({ type: 'int', nullable: true })
@Index()
grade: number | null;
@ApiPropertyOptional({
description: '音频文件路径数组',
type: [String],
example: ['/audio/char1.mp3', '/audio/char1_alt.mp3'],
nullable: true,
})
@Column({ type: 'text', array: true, nullable: true })
audios: string[] | null;
@ApiPropertyOptional({
description: '描述信息',
example: '这是一个汉字',
nullable: true,
})
@Column({ type: 'text', nullable: true })
description: string | null;
@ApiProperty({
description: '状态',
enum: CharacterStatus,
example: CharacterStatus.ACTIVE,
default: CharacterStatus.ACTIVE,
})
@Column({
type: 'enum',
enum: CharacterStatus,
default: CharacterStatus.ACTIVE,
})
@Index()
status: CharacterStatus;
// 关联关系
@OneToMany(() => CharacterImage, (image) => image.character, { cascade: true })
images: CharacterImage[];
@OneToMany(() => CharacterSentence, (characterSentence) => characterSentence.character, {
cascade: true,
})
characterSentences: CharacterSentence[];
}
@@ -20,21 +20,20 @@ import {
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import { WordsService } from './words.service';
import { Word } from './word.entity';
import { CreateWordDto } from './dto/create-word.dto';
import { UpdateWordDto } from './dto/update-word.dto';
import { QueryWordsDto } from './dto/query-words.dto';
import { CreateSentenceDto } from './dto/create-sentence.dto';
import { PaginatedWordData } from './dto/paginated-response.dto';
import { CharsService } from './chars.service';
import { Character } from './character.entity';
import { CreateCharDto } from './dto/create-char.dto';
import { UpdateCharDto } from './dto/update-char.dto';
import { QueryCharsDto } from './dto/query-chars.dto';
import { PaginatedCharData } from './dto/paginated-response.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@ApiTags('words')
@Controller('words')
export class WordsController {
constructor(private readonly wordsService: WordsService) {}
@ApiTags('chars')
@Controller('chars')
export class CharsController {
constructor(private readonly charsService: CharsService) {}
/**
*
@@ -50,12 +49,12 @@ export class WordsController {
@ApiResponse({
status: 200,
description: '查询成功',
type: PaginatedWordData,
type: PaginatedCharData,
})
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
findAll(@Query() queryDto: QueryWordsDto): Promise<PaginatedWordData> {
return this.wordsService.findAllPaginated(queryDto);
findAll(@Query() queryDto: QueryCharsDto): Promise<PaginatedCharData> {
return this.charsService.findAllPaginated(queryDto);
}
/**
@@ -73,13 +72,13 @@ export class WordsController {
@ApiResponse({
status: 200,
description: '查询成功',
type: Word,
type: Character,
})
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
@ApiResponse({ status: 404, description: '汉字不存在' })
findOneById(@Param('id', ParseIntPipe) id: number): Promise<Word> {
return this.wordsService.findOneById(id);
findOneById(@Param('id', ParseIntPipe) id: number): Promise<Character> {
return this.charsService.findOneById(id);
}
/**
@@ -94,17 +93,17 @@ export class WordsController {
summary: '创建汉字',
description: '创建新的汉字记录(需要管理员权限)',
})
@ApiBody({ type: CreateWordDto })
@ApiBody({ type: CreateCharDto })
@ApiResponse({
status: 201,
description: '创建成功',
type: Word,
type: Character,
})
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
create(@Body() createWordDto: CreateWordDto): Promise<Word> {
return this.wordsService.create(createWordDto);
create(@Body() createCharDto: CreateCharDto): Promise<Character> {
return this.charsService.create(createCharDto);
}
/**
@@ -119,20 +118,20 @@ export class WordsController {
description: '更新指定汉字的信息(需要管理员权限)',
})
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
@ApiBody({ type: UpdateWordDto })
@ApiBody({ type: UpdateCharDto })
@ApiResponse({
status: 200,
description: '更新成功',
type: Word,
type: Character,
})
@ApiResponse({ status: 404, description: '汉字不存在' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateWordDto: UpdateWordDto,
): Promise<Word> {
return this.wordsService.update(id, updateWordDto);
@Body() updateCharDto: UpdateCharDto,
): Promise<Character> {
return this.charsService.update(id, updateCharDto);
}
/**
@@ -153,7 +152,7 @@ export class WordsController {
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.wordsService.remove(id);
return this.charsService.remove(id);
}
// 图片相关接口
@@ -172,8 +171,8 @@ export class WordsController {
@ApiResponse({ status: 200, description: '成功返回图片列表' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
getWordImages(@Param('id', ParseIntPipe) id: number) {
return this.wordsService.getWordImages(id);
getCharImages(@Param('id', ParseIntPipe) id: number) {
return this.charsService.getCharImages(id);
}
/**
@@ -203,11 +202,11 @@ export class WordsController {
@ApiResponse({ status: 200, description: '成功更新排序' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
updateWordImagesSort(
updateCharImagesSort(
@Param('id', ParseIntPipe) id: number,
@Body('imageIds') imageIds: number[],
) {
return this.wordsService.updateWordImagesSort(id, imageIds);
return this.charsService.updateCharImagesSort(id, imageIds);
}
/**
@@ -228,11 +227,11 @@ export class WordsController {
@ApiResponse({ status: 404, description: '图片不存在' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
deleteWordImage(
deleteCharImage(
@Param('id', ParseIntPipe) id: number,
@Param('imageId', ParseIntPipe) imageId: number,
): Promise<void> {
return this.wordsService.deleteWordImage(id, imageId);
return this.charsService.deleteCharImage(id, imageId);
}
// 句子相关接口
@@ -251,56 +250,7 @@ export class WordsController {
@ApiResponse({ status: 200, description: '成功返回句子列表' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
getWordSentences(@Param('id', ParseIntPipe) id: number) {
return this.wordsService.getWordSentences(id);
}
/**
*
*/
@Post(':id/sentences')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: '添加句子',
description: '为指定汉字添加新的关联句子(需要管理员权限)',
})
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
@ApiBody({ type: CreateSentenceDto })
@ApiResponse({ status: 201, description: '成功添加句子' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
addSentenceToWord(
@Param('id', ParseIntPipe) id: number,
@Body() createSentenceDto: CreateSentenceDto,
) {
return this.wordsService.addSentenceToWord(id, createSentenceDto);
}
/**
*
*/
@Delete(':id/sentences/:sentenceId')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'super_admin')
@ApiBearerAuth()
@ApiOperation({
summary: '删除句子',
description: '删除指定汉字关联的句子(需要管理员权限)',
})
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
@ApiParam({ name: 'sentenceId', description: '句子ID', type: Number })
@ApiResponse({ status: 204, description: '删除成功' })
@ApiResponse({ status: 404, description: '句子关联不存在' })
@ApiResponse({ status: 401, description: '未授权' })
@ApiResponse({ status: 403, description: '权限不足' })
removeSentenceFromWord(
@Param('id', ParseIntPipe) id: number,
@Param('sentenceId', ParseIntPipe) sentenceId: number,
): Promise<void> {
return this.wordsService.removeSentenceFromWord(id, sentenceId);
getCharSentences(@Param('id', ParseIntPipe) id: number) {
return this.charsService.getCharSentences(id);
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharsService } from './chars.service';
import { CharsController } from './chars.controller';
import { Character } from './character.entity';
import { CharacterImage } from './character-image.entity';
import { Sentence } from './sentence.entity';
import { CharacterSentence } from './character-sentence.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Character, CharacterImage, Sentence, CharacterSentence]),
],
controllers: [CharsController],
providers: [CharsService],
exports: [CharsService],
})
export class CharsModule {}
+250
View File
@@ -0,0 +1,250 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
import { Character, CharacterStatus } from './character.entity';
import { CharacterImage } from './character-image.entity';
import { Sentence } from './sentence.entity';
import { CharacterSentence } from './character-sentence.entity';
import { CreateCharDto } from './dto/create-char.dto';
import { UpdateCharDto } from './dto/update-char.dto';
import { QueryCharsDto } from './dto/query-chars.dto';
import { PaginatedCharData } from './dto/paginated-response.dto';
@Injectable()
export class CharsService {
private readonly logger = new Logger(CharsService.name);
constructor(
@InjectRepository(Character)
private readonly charRepository: Repository<Character>,
@InjectRepository(CharacterImage)
private readonly charImageRepository: Repository<CharacterImage>,
@InjectRepository(Sentence)
private readonly sentenceRepository: Repository<Sentence>,
@InjectRepository(CharacterSentence)
private readonly charSentenceRepository: Repository<CharacterSentence>,
) {}
/**
* 创建汉字
*/
async create(createCharDto: CreateCharDto): Promise<Character> {
const char = this.charRepository.create(createCharDto);
return await this.charRepository.save(char);
}
/**
* 查询汉字(支持多种查询条件和分页)
*/
async findAllPaginated(queryDto: QueryCharsDto): Promise<PaginatedCharData> {
const where: FindOptionsWhere<Character> = {};
// 搜索条件
if (queryDto.search) {
// 使用 Like 进行模糊搜索
const queryBuilder = this.charRepository.createQueryBuilder('char');
queryBuilder.where('char.char LIKE :search', {
search: `%${queryDto.search}%`,
});
if (queryDto.grade !== undefined) {
queryBuilder.andWhere('char.grade = :grade', { grade: queryDto.grade });
}
if (queryDto.strokes) {
queryBuilder.andWhere('char.strokes = :strokes', { strokes: queryDto.strokes });
}
if (queryDto.radicals) {
queryBuilder.andWhere('char.radicals = :radicals', { radicals: queryDto.radicals });
}
if (queryDto.frequency !== undefined) {
queryBuilder.andWhere('char.frequency = :frequency', { frequency: queryDto.frequency });
}
if (queryDto.structure) {
queryBuilder.andWhere('char.structure = :structure', { structure: queryDto.structure });
}
// 排序:按年级升序(低年级在前),年级相同时按ID
queryBuilder.orderBy('char.grade', 'ASC', 'NULLS LAST');
queryBuilder.addOrderBy('char.id', 'ASC');
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 9;
const skip = (page - 1) * limit;
// 加载关联数据
queryBuilder.leftJoinAndSelect('char.images', 'images');
queryBuilder.leftJoinAndSelect('char.characterSentences', 'characterSentences');
queryBuilder.leftJoinAndSelect('characterSentences.sentence', 'sentence');
// 分页
queryBuilder.skip(skip).take(limit);
const [list, total] = await queryBuilder.getManyAndCount();
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
// 筛选条件
if (queryDto.grade !== undefined) {
where.grade = queryDto.grade;
}
if (queryDto.strokes) {
where.strokes = queryDto.strokes;
}
if (queryDto.radicals) {
where.radicals = queryDto.radicals;
}
if (queryDto.frequency !== undefined) {
where.frequency = queryDto.frequency;
}
if (queryDto.structure) {
where.structure = queryDto.structure;
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 9;
const skip = (page - 1) * limit;
// 查询总数
const total = await this.charRepository.count({ where });
// 查询分页数据
const list = await this.charRepository.find({
where,
relations: ['images', 'characterSentences', 'characterSentences.sentence'],
order: {
grade: 'ASC',
id: 'ASC',
},
skip,
take: limit,
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 根据 ID 查询单个汉字
*/
async findOneById(id: number): Promise<Character> {
const char = await this.charRepository.findOne({
where: { id },
relations: ['images', 'characterSentences', 'characterSentences.sentence'],
order: {
images: { sortOrder: 'ASC' },
characterSentences: { sortOrder: 'ASC' },
},
});
if (!char) {
throw new NotFoundException(`未找到ID为 ${id} 的汉字`);
}
return char;
}
/**
* 更新汉字信息
*/
async update(id: number, updateCharDto: UpdateCharDto): Promise<Character> {
const char = await this.findOneById(id);
// 更新基础信息
Object.assign(char, updateCharDto);
return await this.charRepository.save(char);
}
/**
* 删除汉字
*/
async remove(id: number): Promise<void> {
const char = await this.findOneById(id);
await this.charRepository.remove(char);
}
// 图片相关方法
/**
* 获取汉字图片列表
*/
async getCharImages(charId: number): Promise<CharacterImage[]> {
return await this.charImageRepository.find({
where: { charId: charId },
order: { sortOrder: 'ASC' },
});
}
/**
* 更新图片排序
*/
async updateCharImagesSort(charId: number, imageIds: number[]): Promise<CharacterImage[]> {
for (let i = 0; i < imageIds.length; i++) {
await this.charImageRepository.update(
{ id: imageIds[i], charId: charId },
{ sortOrder: i },
);
}
return await this.getCharImages(charId);
}
/**
* 删除汉字图片
*/
async deleteCharImage(charId: number, imageId: number): Promise<void> {
const image = await this.charImageRepository.findOne({
where: { id: imageId, charId: charId },
});
if (!image) {
throw new NotFoundException('图片不存在');
}
await this.charImageRepository.remove(image);
}
// 句子相关方法
/**
* 获取汉字句子列表
*/
async getCharSentences(charId: number): Promise<Sentence[]> {
const charSentences = await this.charSentenceRepository.find({
where: { charId: charId },
relations: ['sentence'],
order: { sortOrder: 'ASC' },
});
return charSentences.map((cs) => cs.sentence);
}
}
@@ -0,0 +1,95 @@
import { IsString, IsOptional, IsInt, IsArray, Min, Max, IsNotEmpty } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCharDto {
@ApiProperty({ description: '汉字内容', example: '中' })
@IsString()
@IsNotEmpty()
char: string;
@ApiProperty({
description: '笔画数',
example: 4,
})
@IsInt()
@Min(1)
strokes: number;
@ApiProperty({
description: '拼音数组(支持多音字)',
type: [String],
example: ['zhōng', 'zhòng'],
})
@IsArray()
@IsString({ each: true })
@IsNotEmpty()
pinyin: string[];
@ApiPropertyOptional({
description: '偏旁部首',
example: '丨',
})
@IsOptional()
@IsString()
radicals?: string | null;
@ApiPropertyOptional({
description: '使用频率: 0(最常用), 1(较常用), 2(次常用), 3(二级字), 4(三级字), 5(生僻字)',
example: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(5)
frequency?: number | null;
@ApiPropertyOptional({
description: '汉字结构代码,如"D0"(独体结构), "H2"(左窄右宽)等',
example: 'H2',
})
@IsOptional()
@IsString()
structure?: string | null;
@ApiPropertyOptional({
description: '繁体字写法,可能有多个,用逗号分隔',
example: '乾幹',
})
@IsOptional()
@IsString()
traditional?: string | null;
@ApiPropertyOptional({
description: '笔画数据,JSON格式,存储笔画顺序和路径信息',
})
@IsOptional()
strokeData?: any | null;
@ApiPropertyOptional({
description: '年级(0-9',
example: 1,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(9)
grade?: number | null;
@ApiPropertyOptional({
description: '音频文件路径数组',
type: [String],
example: ['/audio/char1.mp3', '/audio/char1_alt.mp3'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
audios?: string[] | null;
@ApiPropertyOptional({
description: '描述信息',
example: '这是一个汉字',
})
@IsOptional()
@IsString()
description?: string | null;
}
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Word } from '../word.entity';
import { Character } from '../character.entity';
export class PaginationInfo {
@ApiProperty({ description: '总记录数', example: 100 })
@@ -15,9 +15,9 @@ export class PaginationInfo {
current_page: number;
}
export class PaginatedWordData {
@ApiProperty({ description: '汉字列表', type: [Word] })
list: Word[];
export class PaginatedCharData {
@ApiProperty({ description: '汉字列表', type: [Character] })
list: Character[];
@ApiProperty({ description: '分页信息', type: PaginationInfo })
pagination: PaginationInfo;
@@ -1,9 +1,8 @@
import { IsOptional, IsString, IsInt, IsEnum, Min, Max } from 'class-validator';
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WordType } from '../word.entity';
export class QueryWordsDto {
export class QueryCharsDto {
@ApiPropertyOptional({
description: '搜索关键词(汉字内容)',
example: '中',
@@ -13,24 +12,50 @@ export class QueryWordsDto {
search?: string;
@ApiPropertyOptional({
description: '字词类型',
enum: WordType,
example: WordType.CHINESE_CHAR,
})
@IsOptional()
@IsEnum(WordType)
type?: WordType;
@ApiPropertyOptional({
description: '年级(1-9',
minimum: 1,
maximum: 9,
example: 1,
description: '笔画数',
example: 4,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
strokes?: number;
@ApiPropertyOptional({
description: '偏旁部首',
example: '丨',
})
@IsOptional()
@IsString()
radicals?: string;
@ApiPropertyOptional({
description: '使用频率: 0(最常用), 1(较常用), 2(次常用), 3(二级字), 4(三级字), 5(生僻字)',
example: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(5)
frequency?: number;
@ApiPropertyOptional({
description: '汉字结构代码',
example: 'H2',
})
@IsOptional()
@IsString()
structure?: string;
@ApiPropertyOptional({
description: '年级(0-9',
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(9)
grade?: number;
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCharDto } from './create-char.dto';
export class UpdateCharDto extends PartialType(CreateCharDto) {}
@@ -2,13 +2,11 @@ import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WordSentence } from './word-sentence.entity.js';
import { CharacterSentence } from './character-sentence.entity';
@Entity('sentences')
export class Sentence {
@@ -47,22 +45,8 @@ export class Sentence {
@Column({ type: 'varchar', length: 100, nullable: true })
source: string | null;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ApiProperty({
description: '更新时间',
example: '2024-01-01T00:00:00.000Z',
})
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.sentence, {
@OneToMany(() => CharacterSentence, (characterSentence) => characterSentence.sentence, {
cascade: true,
})
wordSentences: WordSentence[];
characterSentences: CharacterSentence[];
}
@@ -1,4 +1,4 @@
import { IsOptional, IsString, IsNumber, Min, IsEnum } from 'class-validator';
import { IsOptional, IsString, IsNumber, Min, IsEnum, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole, UserStatus } from '../user.entity';
@@ -12,6 +12,14 @@ export class QueryUserDto {
@IsString()
username?: string;
@ApiPropertyOptional({
description: '真实姓名',
example: '张三',
})
@IsOptional()
@IsString()
realName?: string;
@ApiPropertyOptional({
description: '邮箱',
example: 'admin@example.com',
@@ -61,4 +69,25 @@ export class QueryUserDto {
@IsNumber()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({
description: '排序字段',
example: 'createdAt',
default: 'createdAt',
})
@IsOptional()
@IsString()
@IsIn(['createdAt', 'updatedAt', 'lastLoginAt'])
sortBy?: string = 'createdAt';
@ApiPropertyOptional({
description: '排序方向',
example: 'DESC',
enum: ['ASC', 'DESC'],
default: 'DESC',
})
@IsOptional()
@IsString()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC' = 'DESC';
}
+12 -4
View File
@@ -81,6 +81,10 @@ export class UsersService {
where.username = queryDto.username;
}
if (queryDto.realName) {
where.realName = queryDto.realName;
}
if (queryDto.email) {
where.email = queryDto.email;
}
@@ -98,16 +102,20 @@ export class UsersService {
const limit = queryDto.limit || 10;
const skip = (page - 1) * limit;
const sortBy = queryDto.sortBy || 'createdAt';
const sortOrder = queryDto.sortOrder || 'DESC';
const order: Record<string, 'ASC' | 'DESC'> = {
[sortBy]: sortOrder,
id: 'ASC',
};
// 查询总数
const total = await this.userRepository.count({ where });
// 查询分页数据
const list = await this.userRepository.find({
where,
order: {
createdAt: 'DESC',
id: 'ASC',
},
order,
skip,
take: limit,
});
@@ -1,33 +0,0 @@
import { IsString, IsOptional, IsNotEmpty } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateSentenceDto {
@ApiProperty({ description: '句子内容', example: '这是一个例句。' })
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional({
description: '翻译(英语句子需要)',
example: 'This is an example sentence.',
})
@IsOptional()
@IsString()
translation?: string;
@ApiPropertyOptional({
description: '音频文件路径',
example: '/audio/sentence1.mp3',
})
@IsOptional()
@IsString()
audioPath?: string;
@ApiPropertyOptional({
description: '来源(如教材名称)',
example: '人教版语文一年级上册',
})
@IsOptional()
@IsString()
source?: string;
}
@@ -1,82 +0,0 @@
import { IsString, IsOptional, IsInt, IsArray, Min, Max, IsEnum, IsNotEmpty } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WordType } from '../word.entity';
export class CreateWordDto {
@ApiProperty({ description: '字词内容', example: '中' })
@IsString()
content: string;
@ApiProperty({
description: '字词类型',
enum: WordType,
example: WordType.CHINESE_CHAR,
})
@IsEnum(WordType)
@IsNotEmpty()
type: WordType;
@ApiProperty({
description: '年级(1-9',
required: false,
minimum: 1,
maximum: 9,
example: 1,
nullable: true,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
grade?: number | null;
@ApiProperty({
description: '拼音数组(支持多音字)',
required: false,
type: [String],
example: ['zhōng', 'zhòng'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
pinyins?: string[];
@ApiProperty({
description: '读音数组',
required: false,
type: [String],
example: ['audio1.mp3', 'audio2.mp3'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
pronunciations?: string[];
@ApiPropertyOptional({
description: 'SVG笔画数据(字符串数组,每个元素是一个SVG字符串)',
type: [String],
example: ['<svg>...</svg>', '<svg>...</svg>'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
svgData?: string[];
@ApiPropertyOptional({
description: '音频文件地址数组',
type: [String],
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
audioFiles?: string[];
@ApiPropertyOptional({
description: '描述信息',
example: '这是一个汉字',
})
@IsOptional()
@IsString()
description?: string;
}
@@ -1,24 +0,0 @@
import { IsArray, IsInt, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class WordImageItemDto {
@ApiProperty({ description: '图片ID', example: 1 })
@IsInt()
id: number;
@ApiProperty({ description: '排序顺序', example: 0 })
@IsInt()
sortOrder: number;
}
export class UpdateWordImagesDto {
@ApiProperty({
description: '图片列表(按新顺序)',
type: [WordImageItemDto],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => WordImageItemDto)
images: WordImageItemDto[];
}
@@ -1,29 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { CreateWordDto } from './create-word.dto';
import { IsOptional, IsArray, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateWordDto extends PartialType(CreateWordDto) {
@ApiPropertyOptional({
description: '关联词语ID数组',
type: [Number],
example: [1, 2, 3],
})
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Type(() => Number)
relatedWordIds?: number[];
@ApiPropertyOptional({
description: '关联句子ID数组',
type: [Number],
example: [1, 2, 3],
})
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Type(() => Number)
sentenceIds?: number[];
}
-4
View File
@@ -1,4 +0,0 @@
export * from './word.entity';
export * from './word-image.entity';
export * from './sentence.entity';
export * from './word-sentence.entity';
-163
View File
@@ -1,163 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
ManyToMany,
JoinTable,
Index,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WordImage } from './word-image.entity';
import { WordSentence } from './word-sentence.entity';
export enum WordType {
CHINESE_CHAR = 'chinese_char',
CHINESE_WORD = 'chinese_word',
ENGLISH_WORD = 'english_word',
ENGLISH_LETTER = 'english_letter',
}
export enum WordStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
@Entity('words')
export class Word {
@ApiProperty({ description: '字词ID', example: 1 })
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;
@ApiProperty({ description: '字词内容', example: '中', maxLength: 50 })
@Column({ type: 'varchar', length: 50 })
@Index()
content: string;
@ApiProperty({
description: '字词类型',
enum: WordType,
example: WordType.CHINESE_CHAR,
})
@Column({
type: 'enum',
enum: WordType,
default: WordType.CHINESE_CHAR,
})
@Index()
type: WordType;
@ApiPropertyOptional({
description: '年级(1-9',
example: 1,
nullable: true,
})
@Column({ type: 'int', nullable: true })
grade: number | null;
@ApiPropertyOptional({
description: '拼音数组(支持多音字)',
type: [String],
example: ['zhōng', 'zhòng'],
nullable: true,
})
@Column({ type: 'text', array: true, nullable: true })
pinyins: string[] | null;
@ApiPropertyOptional({
description: '读音数组',
type: [String],
example: ['audio1.mp3', 'audio2.mp3'],
nullable: true,
})
@Column({ type: 'text', array: true, nullable: true })
pronunciations: string[] | null;
@ApiPropertyOptional({
description: 'SVG笔画数据(字符串数组)',
type: [String],
example: ['<svg>...</svg>', '<svg>...</svg>'],
nullable: true,
})
@Column({ type: 'text', array: true, nullable: true, name: 'svg_data' })
svgData: string[] | null;
@ApiPropertyOptional({
description: '音频文件地址数组',
type: [String],
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
nullable: true,
})
@Column({ type: 'text', array: true, nullable: true, name: 'audio_files' })
audioFiles: string[] | null;
@ApiPropertyOptional({
description: '描述信息',
example: '这是一个汉字',
nullable: true,
})
@Column({ type: 'text', nullable: true })
description: string | null;
@ApiProperty({
description: '状态',
enum: WordStatus,
example: WordStatus.ACTIVE,
default: WordStatus.ACTIVE,
})
@Column({
type: 'enum',
enum: WordStatus,
default: WordStatus.ACTIVE,
})
@Index()
status: WordStatus;
@ApiProperty({
description: '创建时间',
example: '2024-01-01T00:00:00.000Z',
})
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@ApiProperty({
description: '更新时间',
example: '2024-01-01T00:00:00.000Z',
})
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ApiPropertyOptional({
description: '创建人ID',
nullable: true,
})
@Column({ type: 'bigint', nullable: true, name: 'created_by' })
createdBy: number | null;
@ApiPropertyOptional({
description: '更新人ID',
nullable: true,
})
@Column({ type: 'bigint', nullable: true, name: 'updated_by' })
updatedBy: number | null;
// 关联关系
@OneToMany(() => WordImage, (image) => image.word, { cascade: true })
images: WordImage[];
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.word, {
cascade: true,
})
wordSentences: WordSentence[];
// 关联词语(通过关联表)
@ManyToMany(() => Word, (word) => word.id)
@JoinTable({
name: 'word_relations',
joinColumn: { name: 'source_word_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'target_word_id', referencedColumnName: 'id' },
})
relatedWords: Word[];
}
@@ -1,18 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WordsService } from './words.service';
import { WordsController } from './words.controller';
import { Word } from './word.entity';
import { WordImage } from './word-image.entity';
import { Sentence } from './sentence.entity';
import { WordSentence } from './word-sentence.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Word, WordImage, Sentence, WordSentence]),
],
controllers: [WordsController],
providers: [WordsService],
exports: [WordsService],
})
export class WordsModule {}
-291
View File
@@ -1,291 +0,0 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, In } from 'typeorm';
import { Word, WordType, WordStatus } from './word.entity';
import { WordImage } from './word-image.entity';
import { Sentence } from './sentence.entity';
import { WordSentence } from './word-sentence.entity';
import { CreateWordDto } from './dto/create-word.dto';
import { UpdateWordDto } from './dto/update-word.dto';
import { QueryWordsDto } from './dto/query-words.dto';
import { CreateSentenceDto } from './dto/create-sentence.dto';
import { PaginatedWordData } from './dto/paginated-response.dto';
@Injectable()
export class WordsService {
private readonly logger = new Logger(WordsService.name);
constructor(
@InjectRepository(Word)
private readonly wordRepository: Repository<Word>,
@InjectRepository(WordImage)
private readonly wordImageRepository: Repository<WordImage>,
@InjectRepository(Sentence)
private readonly sentenceRepository: Repository<Sentence>,
@InjectRepository(WordSentence)
private readonly wordSentenceRepository: Repository<WordSentence>,
) {}
/**
* 创建汉字
*/
async create(createWordDto: CreateWordDto): Promise<Word> {
const word = this.wordRepository.create(createWordDto);
return await this.wordRepository.save(word);
}
/**
* 查询汉字(支持多种查询条件和分页)
*/
async findAllPaginated(queryDto: QueryWordsDto): Promise<PaginatedWordData> {
const where: FindOptionsWhere<Word> = {};
// 只查询汉字
where.type = WordType.CHINESE_CHAR;
// 搜索条件
if (queryDto.search) {
// 使用 Like 进行模糊搜索
const queryBuilder = this.wordRepository.createQueryBuilder('word');
queryBuilder.where('word.type = :type', { type: WordType.CHINESE_CHAR });
queryBuilder.andWhere('word.content LIKE :search', {
search: `%${queryDto.search}%`,
});
if (queryDto.grade) {
queryBuilder.andWhere('word.grade = :grade', { grade: queryDto.grade });
}
// 排序:按年级升序(低年级在前),年级相同时按创建时间
queryBuilder.orderBy('word.grade', 'ASC', 'NULLS LAST');
queryBuilder.addOrderBy('word.createdAt', 'ASC');
queryBuilder.addOrderBy('word.id', 'ASC');
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 9;
const skip = (page - 1) * limit;
// 加载关联数据
queryBuilder.leftJoinAndSelect('word.images', 'images');
queryBuilder.leftJoinAndSelect('word.wordSentences', 'wordSentences');
queryBuilder.leftJoinAndSelect('wordSentences.sentence', 'sentence');
// 分页
queryBuilder.skip(skip).take(limit);
const [list, total] = await queryBuilder.getManyAndCount();
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
// 年级筛选
if (queryDto.grade) {
where.grade = queryDto.grade;
}
// 分页参数
const page = queryDto.page || 1;
const limit = queryDto.limit || 9;
const skip = (page - 1) * limit;
// 查询总数
const total = await this.wordRepository.count({ where });
// 查询分页数据
const list = await this.wordRepository.find({
where,
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
order: {
grade: 'ASC',
createdAt: 'ASC',
id: 'ASC',
},
skip,
take: limit,
});
// 计算总页数
const total_page = Math.ceil(total / limit);
return {
list,
pagination: {
total,
total_page,
page_size: limit,
current_page: page,
},
};
}
/**
* 根据 ID 查询单个汉字
*/
async findOneById(id: number): Promise<Word> {
const word = await this.wordRepository.findOne({
where: { id },
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
order: {
images: { sortOrder: 'ASC' },
wordSentences: { sortOrder: 'ASC' },
},
});
if (!word) {
throw new NotFoundException(`未找到ID为 ${id} 的汉字`);
}
return word;
}
/**
* 更新汉字信息
*/
async update(id: number, updateWordDto: UpdateWordDto): Promise<Word> {
const word = await this.findOneById(id);
// 更新基础信息
Object.assign(word, updateWordDto);
// 更新关联句子
if (updateWordDto.sentenceIds) {
// 删除现有关联
await this.wordSentenceRepository.delete({ wordId: id });
// 创建新关联
for (let i = 0; i < updateWordDto.sentenceIds.length; i++) {
const sentenceId = updateWordDto.sentenceIds[i];
const wordSentence = this.wordSentenceRepository.create({
wordId: id,
sentenceId: sentenceId,
sortOrder: i,
});
await this.wordSentenceRepository.save(wordSentence);
}
}
return await this.wordRepository.save(word);
}
/**
* 删除汉字
*/
async remove(id: number): Promise<void> {
const word = await this.findOneById(id);
await this.wordRepository.remove(word);
}
// 图片相关方法
/**
* 获取汉字图片列表
*/
async getWordImages(wordId: number): Promise<WordImage[]> {
return await this.wordImageRepository.find({
where: { wordId: wordId },
order: { sortOrder: 'ASC' },
});
}
/**
* 更新图片排序
*/
async updateWordImagesSort(wordId: number, imageIds: number[]): Promise<WordImage[]> {
for (let i = 0; i < imageIds.length; i++) {
await this.wordImageRepository.update(
{ id: imageIds[i], wordId: wordId },
{ sortOrder: i },
);
}
return await this.getWordImages(wordId);
}
/**
* 删除汉字图片
*/
async deleteWordImage(wordId: number, imageId: number): Promise<void> {
const image = await this.wordImageRepository.findOne({
where: { id: imageId, wordId: wordId },
});
if (!image) {
throw new NotFoundException('图片不存在');
}
await this.wordImageRepository.remove(image);
}
// 句子相关方法
/**
* 获取汉字句子列表
*/
async getWordSentences(wordId: number): Promise<Sentence[]> {
const wordSentences = await this.wordSentenceRepository.find({
where: { wordId: wordId },
relations: ['sentence'],
order: { sortOrder: 'ASC' },
});
return wordSentences.map((ws) => ws.sentence);
}
/**
* 添加句子到汉字
*/
async addSentenceToWord(wordId: number, createSentenceDto: CreateSentenceDto): Promise<Sentence> {
// 创建句子
const sentence = this.sentenceRepository.create(createSentenceDto);
const savedSentence = await this.sentenceRepository.save(sentence);
// 创建关联
const maxSort = await this.wordSentenceRepository
.createQueryBuilder('ws')
.where('ws.wordId = :wordId', { wordId })
.select('MAX(ws.sortOrder)', 'max')
.getRawOne();
const wordSentence = this.wordSentenceRepository.create({
wordId: wordId,
sentenceId: savedSentence.id,
sortOrder: (maxSort?.max || 0) + 1,
});
await this.wordSentenceRepository.save(wordSentence);
return savedSentence;
}
/**
* 从汉字中删除句子
*/
async removeSentenceFromWord(wordId: number, sentenceId: number): Promise<void> {
const wordSentence = await this.wordSentenceRepository.findOne({
where: { wordId: wordId, sentenceId: sentenceId },
});
if (!wordSentence) {
throw new NotFoundException('句子关联不存在');
}
await this.wordSentenceRepository.remove(wordSentence);
}
// 词语相关方法(后续实现)
/**
* 获取关联词语
*/
async getRelatedWords(wordId: number): Promise<Word[]> {
// TODO: 实现词语关联查询
return [];
}
}
@@ -0,0 +1,390 @@
import fs from 'fs';
import path from 'path';
import https from 'https';
import { URL } from 'url';
// 配置
const CONFIG = {
// CDN 基础 URL
// CDN_BASE: 'https://unpkg.com/cnchar-data@latest/draw/',
CDN_BASE: 'https://unpkg.com/cnchar-data@1.1.0/draw/',
// 字符数据文件路径
CHAR_STRING_FILE: path.join(__dirname, '../data/char_string.json'),
// 输出文件路径
OUTPUT_FILE: path.join(__dirname, '../data/char_common_draw.json'),
// 并发下载数量(降低并发避免被封IP)
CONCURRENT: 3,
// 请求间隔(毫秒)- 每个请求之间的延迟
REQUEST_DELAY: 500,
// 重试次数
MAX_RETRIES: 3,
// 重试延迟(毫秒)
RETRY_DELAY: 2000,
// 请求超时时间(毫秒)
REQUEST_TIMEOUT: 15000,
};
// 统计信息
const stats = {
total: 0,
success: 0,
failed: 0,
skipped: 0,
startTime: Date.now(),
};
// 失败的字符列表
const failedChars = [];
/**
* 延迟函数
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 从文件读取字符列表
*/
function getCharList() {
console.log('正在读取 char_string.json...');
const data = JSON.parse(fs.readFileSync(CONFIG.CHAR_STRING_FILE, 'utf8'));
// char_string.json 格式: { "3500": "一乙二十丁..." }
const charString = data['3500'] || '';
// 将字符串拆分为字符数组
const chars = charString.split('').filter((char) => char.trim() !== '');
console.log(`从 char_string.json 读取到 ${chars.length} 个字符`);
return chars;
}
/**
* 检查缺失的字符
* @returns {Object} { allChars: Array, existingCharsSet: Set, missingChars: Array }
*/
function checkMissingChars() {
console.log('\n正在检查缺失的字符...');
// 读取所有需要的字符
const allChars = getCharList();
// 读取已有的字符数据
let existingCharsSet = new Set();
if (fs.existsSync(CONFIG.OUTPUT_FILE)) {
try {
const existing = JSON.parse(fs.readFileSync(CONFIG.OUTPUT_FILE, 'utf8'));
existingCharsSet = new Set(Object.keys(existing));
console.log(`已存在 ${existingCharsSet.size} 个字符的数据`);
} catch (error) {
console.warn('读取已有文件失败,将重新下载:', error.message);
}
} else {
console.log('输出文件不存在,将下载所有字符');
}
// 找出缺失的字符
const missingChars = allChars.filter((char) => !existingCharsSet.has(char));
console.log(`\n检查结果:`);
console.log(` 总字符数: ${allChars.length}`);
console.log(` 已存在: ${existingCharsSet.size}`);
console.log(` 缺失: ${missingChars.length}`);
if (missingChars.length > 0) {
console.log(`\n缺失的字符列表 (前50个):`);
const preview = missingChars.slice(0, 50);
console.log(preview.join(', '));
if (missingChars.length > 50) {
console.log(` ... 还有 ${missingChars.length - 50} 个字符`);
}
} else {
console.log('\n✓ 所有字符都已存在,无需下载');
}
return {
allChars,
existingCharsSet,
missingChars,
};
}
/**
* 下载单个字符的 SVG 数据(支持重定向)
*/
function downloadChar(char, retries = 0, redirectUrl = null) {
return new Promise((resolve, reject) => {
const encodedChar = encodeURIComponent(char);
const url = redirectUrl || `${CONFIG.CDN_BASE}${encodedChar}.json`;
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
// 处理重定向(301, 302, 307, 308
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
res.statusCode === 307 ||
res.statusCode === 308) &&
res.headers.location
) {
// 处理相对路径和绝对路径
let redirectUrl = res.headers.location;
if (!redirectUrl.startsWith('http')) {
redirectUrl = new URL(redirectUrl, url).href;
}
// 递归跟随重定向
return downloadChar(char, retries, redirectUrl).then(resolve).catch(reject);
}
if (res.statusCode === 200) {
try {
const jsonData = JSON.parse(data);
resolve({ char, data: jsonData });
} catch (error) {
if (retries < CONFIG.MAX_RETRIES) {
setTimeout(() => {
downloadChar(char, retries + 1)
.then(resolve)
.catch(reject);
}, CONFIG.RETRY_DELAY);
} else {
reject(new Error(`解析 JSON 失败: ${char}`));
}
}
} else if (res.statusCode === 404) {
// 404 表示该字符没有 SVG 数据
resolve({ char, data: null, skipped: true });
} else {
if (retries < CONFIG.MAX_RETRIES) {
setTimeout(() => {
downloadChar(char, retries + 1)
.then(resolve)
.catch(reject);
}, CONFIG.RETRY_DELAY);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${char}`));
}
}
});
});
req.on('error', (error) => {
if (retries < CONFIG.MAX_RETRIES) {
setTimeout(() => {
downloadChar(char, retries + 1)
.then(resolve)
.catch(reject);
}, CONFIG.RETRY_DELAY);
} else {
reject(error);
}
});
req.setTimeout(CONFIG.REQUEST_TIMEOUT, () => {
req.destroy();
if (retries < CONFIG.MAX_RETRIES) {
setTimeout(() => {
downloadChar(char, retries + 1)
.then(resolve)
.catch(reject);
}, CONFIG.RETRY_DELAY);
} else {
reject(new Error(`请求超时: ${char}`));
}
});
req.end();
});
}
/**
* 批量下载(控制并发和延迟)
*/
async function downloadAll(missingChars, existingData = {}) {
const results = { ...existingData };
const queue = [...missingChars];
let currentIndex = 0;
// 如果没有缺失的字符,直接返回已有数据
if (queue.length === 0) {
console.log('\n没有需要下载的字符');
return results;
}
console.log(`\n开始下载 ${queue.length} 个缺失的字符...`);
stats.total = missingChars.length;
// 并发下载控制
const activeTasks = new Set();
let completedCount = 0;
const totalTasks = queue.length;
return new Promise((resolve) => {
function checkComplete() {
// 如果队列已空且没有活跃任务,则完成
if (currentIndex >= queue.length && activeTasks.size === 0) {
resolve(results);
}
}
async function processNext() {
// 如果队列已空,检查是否所有任务都完成
if (currentIndex >= queue.length) {
checkComplete();
return;
}
const char = queue[currentIndex++];
const taskId = `${char}-${Date.now()}`;
activeTasks.add(taskId);
// 添加请求延迟,避免请求过快
await delay(CONFIG.REQUEST_DELAY);
downloadChar(char)
.then((result) => {
if (result.skipped) {
stats.skipped++;
console.log(`[跳过] ${char} - 无 SVG 数据`);
} else {
// 按照 {"好":{}} 格式存储,将整个JSON对象作为值
results[result.char] = result.data;
stats.success++;
const progress = (
((stats.success + stats.failed + stats.skipped) / stats.total) *
100
).toFixed(1);
console.log(
`[成功] ${char} (${stats.success}/${stats.total}, ${progress}%)`,
);
}
// 每下载 50 个字符保存一次(降低保存频率)
if ((stats.success + stats.failed + stats.skipped) % 50 === 0) {
saveResults(results);
}
})
.catch((error) => {
stats.failed++;
failedChars.push(char);
console.error(`[失败] ${char} - ${error.message}`);
})
.finally(() => {
activeTasks.delete(taskId);
completedCount++;
// 继续处理下一个任务
processNext();
});
}
// 启动初始并发任务
const initialTasks = Math.min(CONFIG.CONCURRENT, queue.length);
for (let i = 0; i < initialTasks; i++) {
processNext();
}
});
}
/**
* 保存结果到文件
*/
function saveResults(results) {
const outputDir = path.dirname(CONFIG.OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(CONFIG.OUTPUT_FILE, JSON.stringify(results, null, 2), 'utf8');
}
/**
* 主函数
*/
async function main() {
console.log('='.repeat(60));
console.log('开始下载常用字 3500 的 SVG 笔画数据');
console.log('='.repeat(60));
console.log(`CDN 地址: ${CONFIG.CDN_BASE}`);
console.log(`并发数: ${CONFIG.CONCURRENT}`);
console.log(`请求延迟: ${CONFIG.REQUEST_DELAY}ms`);
console.log(`最大重试次数: ${CONFIG.MAX_RETRIES}`);
console.log('');
try {
// 1. 先检查缺失的字符
const { allChars, existingCharsSet, missingChars } = checkMissingChars();
// 如果没有缺失的字符,直接退出
if (missingChars.length === 0) {
console.log('\n✓ 所有字符数据已完整,无需下载');
return;
}
// 2. 重置统计信息
stats.success = 0;
stats.failed = 0;
stats.skipped = 0;
stats.startTime = Date.now();
failedChars.length = 0;
// 3. 加载已有的数据
let existingData = {};
if (fs.existsSync(CONFIG.OUTPUT_FILE)) {
try {
existingData = JSON.parse(fs.readFileSync(CONFIG.OUTPUT_FILE, 'utf8'));
} catch (error) {
console.warn('加载已有文件失败:', error.message);
}
}
// 4. 下载缺失的字符
console.log(`\n开始下载 ${missingChars.length} 个缺失的字符...`);
const results = await downloadAll(missingChars, existingData);
// 5. 保存最终结果
console.log('\n正在保存结果...');
saveResults(results);
// 6. 打印统计信息
const duration = ((Date.now() - stats.startTime) / 1000).toFixed(2);
console.log('\n' + '='.repeat(60));
console.log('下载完成!');
console.log('='.repeat(60));
console.log(`总字符数: ${allChars.length}`);
console.log(`已存在: ${existingCharsSet.size}`);
console.log(`本次下载: ${missingChars.length}`);
console.log(`成功下载: ${stats.success}`);
console.log(`跳过(无数据): ${stats.skipped}`);
console.log(`失败: ${stats.failed}`);
console.log(`耗时: ${duration}`);
console.log(`输出文件: ${CONFIG.OUTPUT_FILE}`);
console.log(`最终字符数: ${Object.keys(results).length}`);
if (failedChars.length > 0) {
console.log('\n失败的字符:');
console.log(failedChars.join(', '));
console.log('\n提示: 可以重新运行脚本继续下载失败的字符');
}
} catch (error) {
console.error('\n发生错误:', error);
process.exit(1);
}
}
// 运行主函数
main();
@@ -0,0 +1,99 @@
const fs = require('fs');
const path = require('path');
// 配置
const CONFIG = {
// 输入文件路径
INPUT_FILE: path.join(__dirname, '../data/char_common_draw.json'),
// 输出文件路径
OUTPUT_FILE: path.join(__dirname, '../data/char_common_stroke.json'),
};
/**
* 简化数据结构,提取 strokes 属性
*/
function simplifyStrokeData() {
console.log('='.repeat(60));
console.log('开始简化笔画数据');
console.log('='.repeat(60));
console.log(`输入文件: ${CONFIG.INPUT_FILE}`);
console.log(`输出文件: ${CONFIG.OUTPUT_FILE}`);
console.log('');
try {
// 读取原始数据
console.log('正在读取输入文件...');
const startTime = Date.now();
const rawData = JSON.parse(fs.readFileSync(CONFIG.INPUT_FILE, 'utf8'));
const readTime = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`读取完成,耗时: ${readTime}`);
console.log(`原始数据包含 ${Object.keys(rawData).length} 个字符\n`);
// 简化数据结构
console.log('正在简化数据结构...');
const simplifiedData = {};
let processedCount = 0;
let skippedCount = 0;
for (const [char, data] of Object.entries(rawData)) {
if (data && data.strokes && Array.isArray(data.strokes)) {
// 提取 strokes 数组作为值
simplifiedData[char] = data.strokes;
processedCount++;
} else {
// 如果没有 strokes 属性,跳过或设置为空数组
console.warn(`警告: 字符 "${char}" 没有 strokes 属性,跳过`);
skippedCount++;
}
// 每处理 1000 个字符显示一次进度
if (processedCount % 1000 === 0) {
const progress = ((processedCount / Object.keys(rawData).length) * 100).toFixed(1);
console.log(
`已处理: ${processedCount}/${Object.keys(rawData).length} (${progress}%)`,
);
}
}
console.log(`\n处理完成:`);
console.log(` 成功处理: ${processedCount} 个字符`);
if (skippedCount > 0) {
console.log(` 跳过: ${skippedCount} 个字符`);
}
// 保存简化后的数据
console.log('\n正在保存到输出文件...');
const saveStartTime = Date.now();
const outputDir = path.dirname(CONFIG.OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(CONFIG.OUTPUT_FILE, JSON.stringify(simplifiedData, null, 2), 'utf8');
const saveTime = ((Date.now() - saveStartTime) / 1000).toFixed(2);
console.log(`保存完成,耗时: ${saveTime}`);
// 统计信息
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
const fileSize = (fs.statSync(CONFIG.OUTPUT_FILE).size / 1024 / 1024).toFixed(2);
console.log('\n' + '='.repeat(60));
console.log('简化完成!');
console.log('='.repeat(60));
console.log(`总字符数: ${Object.keys(simplifiedData).length}`);
console.log(`输出文件大小: ${fileSize} MB`);
console.log(`总耗时: ${totalTime}`);
console.log(`输出文件: ${CONFIG.OUTPUT_FILE}`);
} catch (error) {
console.error('\n发生错误:', error);
if (error.code === 'ENOENT') {
console.error(`文件不存在: ${error.path}`);
} else if (error instanceof SyntaxError) {
console.error('JSON 解析错误,请检查输入文件格式');
}
process.exit(1);
}
}
// 运行主函数
simplifyStrokeData();