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();
+3
View File
@@ -0,0 +1,3 @@
select * from words;
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "platform-doodle",
"name": "platform-pipi",
"version": "1.0.0",
"description": "Doodle Platform Monorepo - API backend and admin management system",
"description": "Pipi Platform Monorepo - API backend and admin management system",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel dev",
@@ -24,7 +24,7 @@
},
"keywords": [
"monorepo",
"doodle",
"pipi",
"nestjs",
"react",
"admin"
+239 -99
View File
@@ -22,76 +22,85 @@
- 优势:文档型数据库,灵活性强
- 劣势:关联查询复杂,不适合强关联关系场景
## 2. 数据设计
## 2. 数据库分离设计
### 2.1 核心设计思路
**是否分离英语和汉字?**
**字和词分离为两个独立的数据库**
**建议:统一表设计,通过类型字段区分**
**设计原则:**
1.**汉字数据库**:专门存储汉字相关数据,包含汉字的特有属性(笔画、结构、部首等)
2.**词语数据库**:专门存储词语相关数据,包含词语的组成和关联关系
3.**数据隔离**:字和词的数据结构差异较大,分离后更便于管理和优化
4.**扩展性好**:后续如需支持英语单词,可单独设计英语数据库
**理由**
1.**数据结构相似**:汉字、词语、英语单词、字母的基础字段基本相同
2.**关联关系统一**:都需要关联图片、句子、关联字词等
3.**查询便利**:统一表结构便于统一查询和筛选
4.**维护简单**:减少表数量,降低维护成本
5.**扩展性好**:后续如需新增类型,只需扩展类型枚举
**注意**
- 英语展示不考虑和汉字放在一个库
- 当前版本先完成汉字数据库的设计和实现
- 词语数据库表暂时设计,后续实现
**如果数据量特别大,可以考虑:**
- 按类型分表(水平分表)
- 使用数据库分区功能
## 3. 汉字数据库设计
## 3. 数据表详细设计
### 3.1 汉字主表 (chars)
### 3.1 字词主表 (words)
存储所有字词的基础信息。
存储汉字的基础信息。
```sql
CREATE TABLE words (
CREATE TABLE chars (
id BIGSERIAL PRIMARY KEY,
content VARCHAR(50) NOT NULL COMMENT '字词内容',
type VARCHAR(20) NOT NULL COMMENT '类型: chinese_char(汉字), chinese_word(词语), english_word(英语单词), english_letter(英语字母)',
grade INT COMMENT '年级: 1-9NULL表示未设置',
pinyins TEXT[] COMMENT '拼音数组,支持多音字',
pronunciations TEXT[] COMMENT '读音数组,存储音频文件路径或读音标注',
svg_data JSONB COMMENT 'SVG笔画数据,JSON格式',
char VARCHAR(10) NOT NULL UNIQUE COMMENT '汉字内容,如"中"',
strokes INT NOT NULL COMMENT '笔画数',
pinyin TEXT[] NOT NULL COMMENT '拼音数组,支持多音字,如["zhōng", "zhòng"]',
radicals VARCHAR(10) COMMENT '偏旁部首',
frequency INT COMMENT '使用频率: 0(最常用), 1(较常用), 2(次常用), 3(二级字), 4(三级字), 5(生僻字)',
structure VARCHAR(10) COMMENT '汉字结构代码,如"D0"(独体结构), "H2"(左窄右宽)等',
traditional VARCHAR(50) COMMENT '繁体字写法,可能有多个,用逗号分隔',
stroke_data JSONB COMMENT '笔画数据,JSON格式,存储笔画顺序和路径信息',
grade INT COMMENT '年级: 0-9NULL表示未设置',
audios TEXT[] COMMENT '音频文件路径数组',
description TEXT COMMENT '描述信息',
status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active(启用), inactive(禁用)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT COMMENT '创建人ID',
updated_by BIGINT COMMENT '更新人ID',
-- 索引
INDEX idx_content (content),
INDEX idx_type (type),
INDEX idx_char (char),
INDEX idx_strokes (strokes),
INDEX idx_pinyin (pinyin),
INDEX idx_radicals (radicals),
INDEX idx_frequency (frequency),
INDEX idx_structure (structure),
INDEX idx_grade (grade),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
INDEX idx_status (status)
);
-- 全文搜索索引
CREATE INDEX idx_content_fulltext ON words USING GIN (to_tsvector('simple', content));
CREATE INDEX idx_char_fulltext ON chars USING GIN (to_tsvector('simple', char));
```
**字段说明:**
- `content`: 字内容,如"中"、"中国"、"hello"
- `type`: 类型枚举,便于后续扩展
- `grade`: 年级,1-9年级,NULL 表示未设置
- `pinyins`: PostgreSQL 数组类型,存储多个拼音
- `pronunciations`: 读音数组,可以是音频文件路径或文本标注
- `svg_data`: JSONB 类型,存储 SVG 配置数据
- `char`: 字内容,唯一不重复,如"中"
- `strokes`: 笔画数,整数类型
- `pinyin`: PostgreSQL 数组类型,存储多个拼音,支持多音字
- `radicals`: 偏旁部首,如"中"的部首是"丨"
- `frequency`: 使用频率,0为最常用,1为较常用,2为次常用,3为二级字,4为三级字,5为不在《通用规范汉字》的生僻字
- `structure`: 汉字结构代码,参考结构代码表(见 `structure_codes.json`),如"D0"(独体结构)、"H2"(左窄右宽)等
- `traditional`: 繁体字写法,可能有多个,用逗号分隔,如"乾幹"
- `stroke_data`: JSONB 类型,存储笔画数据,包含笔画顺序和路径信息(原 svg_data 字段)
- `grade`: 年级,0-9年级,NULL 表示未设置
- `audios`: 音频文件路径数组,存储音频文件路径
- `status`: 启用/禁用状态,支持软删除
### 3.2 图片表 (word_images)
**结构代码说明:**
- 结构代码定义见 `structure_codes.json` 文件
- 常见结构代码:D0(独体结构)、D1(镶嵌结构)、B1-B4(上下结构)、H1-H3(左右结构)、R1-R6(半包围结构)等
存储字词关联的图片信息。
### 3.2 汉字图片表 (char_images)
存储汉字关联的图片信息。
```sql
CREATE TABLE word_images (
CREATE TABLE char_images (
id BIGSERIAL PRIMARY KEY,
word_id BIGINT NOT NULL COMMENT '关联的字ID',
char_id BIGINT NOT NULL COMMENT '关联的字ID',
image_type VARCHAR(20) NOT NULL COMMENT '图片类型: original(原图), standard(标准图)',
file_path VARCHAR(500) NOT NULL COMMENT '图片文件路径',
file_name VARCHAR(200) NOT NULL COMMENT '原始文件名',
@@ -101,50 +110,47 @@ CREATE TABLE word_images (
mime_type VARCHAR(50) COMMENT 'MIME类型',
is_primary BOOLEAN DEFAULT FALSE COMMENT '是否为主图',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE,
INDEX idx_word_id (word_id),
FOREIGN KEY (char_id) REFERENCES chars(id) ON DELETE CASCADE,
INDEX idx_char_id (char_id),
INDEX idx_image_type (image_type),
INDEX idx_is_primary (is_primary)
);
```
**字段说明:**
- `word_id`: 关联的字ID
- `char_id`: 关联的字ID
- `image_type`: 区分原图和标准图
- `file_path`: 图片存储路径
- `is_primary`: 标记主图,用于列表展示
- `sort_order`: 排序字段,支持多图排序
### 3.3 字关联表 (word_relations)
### 3.3 字关联表 (char_relations)
存储字之间的关联关系。
存储字之间的关联关系(如形近字、同音字等)
```sql
CREATE TABLE word_relations (
CREATE TABLE char_relations (
id BIGSERIAL PRIMARY KEY,
source_word_id BIGINT NOT NULL COMMENT '源字ID',
target_word_id BIGINT NOT NULL COMMENT '目标字ID',
relation_type VARCHAR(20) NOT NULL COMMENT '关联类型: word_to_char(词包含字), char_to_word(字的组词), synonym(同义词), antonym(反义词)',
source_char_id BIGINT NOT NULL COMMENT '字ID',
target_char_id BIGINT NOT NULL COMMENT '目标字ID',
relation_type VARCHAR(20) NOT NULL COMMENT '关联类型: similar_shape(形近字), same_pinyin(同音字), same_radical(同部首), similar_structure(同结构)',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (source_word_id) REFERENCES words(id) ON DELETE CASCADE,
FOREIGN KEY (target_word_id) REFERENCES words(id) ON DELETE CASCADE,
UNIQUE (source_word_id, target_word_id, relation_type),
INDEX idx_source_word (source_word_id),
INDEX idx_target_word (target_word_id),
FOREIGN KEY (source_char_id) REFERENCES chars(id) ON DELETE CASCADE,
FOREIGN KEY (target_char_id) REFERENCES chars(id) ON DELETE CASCADE,
UNIQUE (source_char_id, target_char_id, relation_type),
INDEX idx_source_char (source_char_id),
INDEX idx_target_char (target_char_id),
INDEX idx_relation_type (relation_type)
);
```
**关联类型说明:**
- `word_to_char`: 词包含字,如"中国"关联"中"和""
- `char_to_word`: 字的组词,如"中"关联"中国"、"中间"等
- `synonym`: 同义词(后续扩展)
- `antonym`: 反义词(后续扩展)
- `similar_shape`: 形近字,如""和""
- `same_pinyin`: 同音字,如"中"和"钟"
- `same_radical`: 同部首,如"中"和"串"
- `similar_structure`: 同结构,如都是左右结构
### 3.4 句子表 (sentences)
@@ -164,22 +170,21 @@ CREATE TABLE sentences (
);
```
### 3.5 字-句子关联表 (word_sentences)
### 3.5 字-句子关联表 (char_sentences)
存储字和句子的关联关系。
存储字和句子的关联关系。
```sql
CREATE TABLE word_sentences (
CREATE TABLE char_sentences (
id BIGSERIAL PRIMARY KEY,
word_id BIGINT NOT NULL COMMENT 'ID',
char_id BIGINT NOT NULL COMMENT '字ID',
sentence_id BIGINT NOT NULL COMMENT '句子ID',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE,
FOREIGN KEY (char_id) REFERENCES chars(id) ON DELETE CASCADE,
FOREIGN KEY (sentence_id) REFERENCES sentences(id) ON DELETE CASCADE,
UNIQUE (word_id, sentence_id),
INDEX idx_word_id (word_id),
UNIQUE (char_id, sentence_id),
INDEX idx_char_id (char_id),
INDEX idx_sentence_id (sentence_id)
);
```
@@ -234,65 +239,200 @@ CREATE TABLE operation_logs (
);
```
## 4. 数据库关系图
## 4. 词语数据库设计(暂时设计)
### 4.1 词语主表 (chinese_words)
存储词语的基础信息。
```sql
CREATE TABLE chinese_words (
id BIGSERIAL PRIMARY KEY,
word VARCHAR(100) NOT NULL UNIQUE COMMENT '词语内容,如"中国"',
pinyin TEXT[] COMMENT '拼音数组,如["zhōng", "guó"]',
grade INT COMMENT '年级: 0-9NULL表示未设置',
description TEXT COMMENT '词语解释',
audios TEXT[] COMMENT '音频文件路径数组',
status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active(启用), inactive(禁用)',
-- 索引
INDEX idx_word (word),
INDEX idx_grade (grade),
INDEX idx_status (status)
);
-- 全文搜索索引
CREATE INDEX idx_word_fulltext ON chinese_words USING GIN (to_tsvector('simple', word));
```
### 4.2 词语-汉字关联表 (word_characters)
存储词语和组成汉字的关联关系。
```sql
CREATE TABLE word_characters (
id BIGSERIAL PRIMARY KEY,
word_id BIGINT NOT NULL COMMENT '词语ID',
char_id BIGINT NOT NULL COMMENT '汉字ID(关联到汉字数据库)',
position INT NOT NULL COMMENT '汉字在词语中的位置,从1开始',
FOREIGN KEY (word_id) REFERENCES chinese_words(id) ON DELETE CASCADE,
INDEX idx_word_id (word_id),
INDEX idx_char_id (char_id),
INDEX idx_position (position)
);
```
**注意:** 词语数据库和汉字数据库是分离的,`char_id` 字段存储的是汉字数据库中的汉字ID,需要通过跨库查询或应用层关联。
### 4.3 词语图片表 (word_images)
存储词语关联的图片信息。
```sql
CREATE TABLE word_images (
id BIGSERIAL PRIMARY KEY,
word_id BIGINT NOT NULL COMMENT '关联的词语ID',
image_type VARCHAR(20) NOT NULL COMMENT '图片类型: original(原图), standard(标准图)',
file_path VARCHAR(500) NOT NULL COMMENT '图片文件路径',
file_name VARCHAR(200) NOT NULL COMMENT '原始文件名',
file_size BIGINT COMMENT '文件大小(字节)',
width INT COMMENT '图片宽度',
height INT COMMENT '图片高度',
mime_type VARCHAR(50) COMMENT 'MIME类型',
is_primary BOOLEAN DEFAULT FALSE COMMENT '是否为主图',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
FOREIGN KEY (word_id) REFERENCES chinese_words(id) ON DELETE CASCADE,
INDEX idx_word_id (word_id),
INDEX idx_image_type (image_type),
INDEX idx_is_primary (is_primary)
);
```
### 4.4 词语-句子关联表 (word_sentences)
存储词语和句子的关联关系。
```sql
CREATE TABLE word_sentences (
id BIGSERIAL PRIMARY KEY,
word_id BIGINT NOT NULL COMMENT '词语ID',
sentence_id BIGINT NOT NULL COMMENT '句子ID',
sort_order INT DEFAULT 0 COMMENT '排序顺序',
FOREIGN KEY (word_id) REFERENCES chinese_words(id) ON DELETE CASCADE,
FOREIGN KEY (sentence_id) REFERENCES sentences(id) ON DELETE CASCADE,
UNIQUE (word_id, sentence_id),
INDEX idx_word_id (word_id),
INDEX idx_sentence_id (sentence_id)
);
```
## 5. 数据库关系图
### 5.1 汉字数据库关系图
```
words (字主表)
├── word_images (图片表) - 一对多
├── word_relations (字关联) - 自关联(多对多)
├── word_sentences (字-句子关联) - 多对多
chars (字主表)
├── char_images (图片表) - 一对多
├── char_relations (字关联) - 自关联(多对多)
├── char_sentences (字-句子关联) - 多对多
└── operation_logs (操作日志) - 关联资源
sentences (句子表)
└── word_sentences (字-句子关联) - 多对多
users (用户表)
└── operation_logs (操作日志) - 一对多
└── char_sentences (字-句子关联) - 多对多
```
## 5. 索引优化建议
### 5.2 词语数据库关系图
### 5.1 查询优化索引
- 字词内容索引:支持快速查找
- 类型+年级组合索引:支持类型和年级筛选
```
chinese_words (词语主表)
├── word_characters (词语-汉字关联) - 多对多(跨库关联)
├── word_images (图片表) - 一对多
├── word_sentences (词语-句子关联) - 多对多
└── operation_logs (操作日志) - 关联资源
sentences (句子表)
└── word_sentences (词语-句子关联) - 多对多
```
### 5.3 公共表
```
users (用户表)
└── operation_logs (操作日志) - 一对多
sentences (句子表)
├── character_sentences (汉字-句子关联) - 多对多
└── word_sentences (词语-句子关联) - 多对多
```
## 6. 索引优化建议
### 6.1 汉字数据库查询优化索引
- 汉字内容索引:支持快速查找
- 笔画数索引:支持按笔画数筛选
- 拼音索引:支持按拼音查找
- 部首索引:支持按部首查找
- 结构代码索引:支持按结构筛选
- 频率索引:支持按使用频率筛选
- 全文搜索索引:支持模糊搜索
### 5.2 关联查询优化
### 6.2 词语数据库查询优化索引
- 词语内容索引:支持快速查找
- 年级索引:支持按年级筛选
- 全文搜索索引:支持模糊搜索
### 6.3 关联查询优化
- 外键索引:所有外键字段建立索引
- 关联表索引:word_relations、word_sentences 的关联字段索引
- 关联表索引:character_relations、character_sentences、word_characters、word_sentences 的关联字段索引
## 6. 数据迁移策略
## 7. 数据迁移策略
### 6.1 初始化数据
### 7.1 初始化数据
- 创建默认超级管理员账户
- 初始化年级数据字典(可选)
- 导入汉字结构代码数据(structure_codes.json
### 6.2 数据导入
- 支持批量导入字词数据
### 7.2 数据导入
- 支持批量导入汉字数据(参考 char_common_base.json 格式)
- 支持批量导入词语数据
- 支持 CSV/Excel 格式导入
## 7. 数据备份策略
### 7.3 数据迁移注意事项
- 从旧的 words 表迁移到 chinese_characters 表时,需要:
-`content` 字段映射到 `char` 字段
-`svg_data` 字段重命名为 `stroke_data`
- 补充 `strokes``pinyin``radicals``frequency``structure``traditional` 字段
- 过滤出 `type = 'chinese_char'` 的数据
### 7.1 备份方案
## 8. 数据备份策略
### 8.1 备份方案
- 每日全量备份
- 实时增量备份(可选)
- 汉字数据库和词语数据库分别备份
### 7.2 恢复方案
### 8.2 恢复方案
- 支持时间点恢复
- 定期恢复演练
## 8. 性能优化建议
## 9. 性能优化建议
### 8.1 查询优化
### 9.1 查询优化
- 使用连接池管理数据库连接
- 复杂查询使用视图或物化视图
- 合理使用缓存(Redis
- 跨库查询(词语-汉字关联)建议在应用层实现,避免跨库JOIN
### 8.2 数据量预估
-数据:预计 10,000+ 条
### 9.2 数据量预估
- 字数据:预计 8,000+ 条(通用规范汉字表)
- 词语数据:预计 10,000+ 条
- 图片数据:预计 50,000+ 条
- 关联关系:预计 100,000+ 条
### 8.3 分表策略(如需要)
-words 表数据量超过 100 万时,考虑按类型分表
### 9.3 分表策略(如需要)
-chinese_characters 表数据量超过 100 万时,考虑按频率分表
- 当 chinese_words 表数据量超过 100 万时,考虑按年级分表
- 使用 PostgreSQL 分区功能
@@ -0,0 +1,3 @@
{
"3500": "一乙二十丁厂七天"
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "voice-production",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"generate:char-introduce": "node scripts/generate_char_introduce.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.28.0"
}
@@ -0,0 +1,253 @@
const fs = require("fs");
const path = require("path");
const https = require("https");
const crypto = require("crypto");
// 腾讯云混元大模型配置
// 请设置环境变量 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY
const SECRET_ID = process.env.TENCENT_SECRET_ID;
const SECRET_KEY = process.env.TENCENT_SECRET_KEY;
// API配置
const HOST = "hunyuan.tencentcloudapi.com";
const SERVICE = "hunyuan";
const REGION = "ap-guangzhou";
const ACTION = "ChatCompletions";
const VERSION = "2023-09-01";
const MODEL = "hunyuan-lite"; // 使用混元lite模型,可根据需要更换
// 签名相关函数
function sha256(message, secret = "", encoding = "hex") {
const hmac = secret
? crypto.createHmac("sha256", secret)
: crypto.createHash("sha256");
return hmac.update(message).digest(encoding);
}
function getDate(timestamp) {
const date = new Date(timestamp * 1000);
const year = date.getUTCFullYear();
const month = ("0" + (date.getUTCMonth() + 1)).slice(-2);
const day = ("0" + date.getUTCDate()).slice(-2);
return `${year}-${month}-${day}`;
}
function generateSignature(secretId, secretKey, host, payload, timestamp) {
const date = getDate(timestamp);
// 步骤1:拼接规范请求串
const httpRequestMethod = "POST";
const canonicalUri = "/";
const canonicalQueryString = "";
const contentType = "application/json";
const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-tc-action:${ACTION.toLowerCase()}\n`;
const signedHeaders = "content-type;host;x-tc-action";
const hashedRequestPayload = sha256(payload);
const canonicalRequest = `${httpRequestMethod}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedRequestPayload}`;
// 步骤2:拼接待签名字符串
const algorithm = "TC3-HMAC-SHA256";
const credentialScope = `${date}/${SERVICE}/tc3_request`;
const hashedCanonicalRequest = sha256(canonicalRequest);
const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`;
// 步骤3:计算签名
const secretDate = sha256(date, "TC3" + secretKey, "buffer");
const secretService = sha256(SERVICE, secretDate, "buffer");
const secretSigning = sha256("tc3_request", secretService, "buffer");
const signature = sha256(stringToSign, secretSigning);
// 步骤4:拼接Authorization
const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
return authorization;
}
// 调用混元API
async function callHunyuan(prompt) {
return new Promise((resolve, reject) => {
const timestamp = Math.floor(Date.now() / 1000);
const payload = JSON.stringify({
Model: MODEL,
Messages: [
{
Role: "user",
Content: prompt,
},
],
Stream: false,
});
const authorization = generateSignature(
SECRET_ID,
SECRET_KEY,
HOST,
payload,
timestamp
);
const options = {
hostname: HOST,
port: 443,
path: "/",
method: "POST",
headers: {
"Content-Type": "application/json",
Host: HOST,
"X-TC-Action": ACTION,
"X-TC-Version": VERSION,
"X-TC-Timestamp": timestamp.toString(),
"X-TC-Region": REGION,
Authorization: authorization,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
const result = JSON.parse(data);
if (result.Response && result.Response.Choices) {
resolve(result.Response.Choices[0].Message.Content.trim());
} else if (result.Response && result.Response.Error) {
reject(new Error(result.Response.Error.Message));
} else {
reject(new Error("Unknown API response: " + data));
}
} catch (e) {
reject(e);
}
});
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
// 为单个汉字生成介绍
async function generateIntroduce(char) {
const prompt = `请为汉字"${char}"生成一个简短的词组介绍,格式为"${char}XXX的${char}",其中XXX是一个包含该汉字的常见词语。
例如:
- 汉字"天"的介绍是"天,天空的天"
- 汉字"地"的介绍是"地,大地的地"
- 汉字"人"的介绍是"人,人民的人"
请只输出介绍内容,不要包含其他解释。比如对于"天",只输出"天,天空的天"。`;
try {
const response = await callHunyuan(prompt);
// 清理响应,只保留核心内容
let introduce = response.replace(/["""]/g, "").trim();
// 如果响应包含多余内容,尝试提取核心部分
const match = introduce.match(/..+的./);
if (match) {
introduce = match[0];
}
// 确保格式正确:如果没有逗号,添加逗号
if (!introduce.startsWith(char + "")) {
// 尝试提取词组部分
const wordMatch = introduce.match(/(.+的.)/);
if (wordMatch) {
introduce = `${char}${wordMatch[1]}`;
} else {
introduce = `${char}${char}字的${char}`;
}
}
return introduce;
} catch (error) {
console.error(`生成 "${char}" 介绍失败:`, error.message);
return `${char}${char}字的${char}`;
}
}
// 延时函数
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 主函数
async function main() {
// 检查环境变量
if (!SECRET_ID || !SECRET_KEY) {
console.error("请设置环境变量 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY");
console.error("例如:");
console.error(
" export TENCENT_SECRET_ID=your_secret_id"
);
console.error(
" export TENCENT_SECRET_KEY=your_secret_key"
);
process.exit(1);
}
// 读取汉字数据
const charStringPath = path.join(
__dirname,
"../data/char/char_string.json"
);
const charString = JSON.parse(fs.readFileSync(charStringPath, "utf-8"));
// 获取所有汉字
const chars = charString["3500"].split("");
console.log(`共有 ${chars.length} 个汉字需要处理`);
// 检查是否有已存在的进度文件
const outputPath = path.join(
__dirname,
"../data/char/char_introduce.json"
);
let results = [];
let startIndex = 0;
if (fs.existsSync(outputPath)) {
try {
results = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
startIndex = results.length;
console.log(`发现已有进度,从第 ${startIndex + 1} 个汉字继续`);
} catch (e) {
console.log("已有文件解析失败,从头开始");
}
}
// 批量处理
const BATCH_SIZE = 10; // 每批处理10个
const DELAY_BETWEEN_BATCHES = 1000; // 批次间延迟1秒
for (let i = startIndex; i < chars.length; i += BATCH_SIZE) {
const batch = chars.slice(i, Math.min(i + BATCH_SIZE, chars.length));
console.log(
`处理第 ${i + 1} - ${Math.min(i + BATCH_SIZE, chars.length)} 个汉字...`
);
// 并行处理当前批次
const batchResults = await Promise.all(
batch.map(async (char, idx) => {
// 每个请求之间稍微错开
await delay(idx * 100);
const introduce = await generateIntroduce(char);
console.log(` ${char}: ${introduce}`);
return { char, introduce };
})
);
results.push(...batchResults);
// 每批处理完后保存进度
fs.writeFileSync(outputPath, JSON.stringify(results, null, 2), "utf-8");
console.log(`已保存进度:${results.length}/${chars.length}`);
// 批次间延迟,避免API限流
if (i + BATCH_SIZE < chars.length) {
await delay(DELAY_BETWEEN_BATCHES);
}
}
console.log(`\n完成!共生成 ${results.length} 个汉字介绍`);
console.log(`结果已保存到: ${outputPath}`);
}
main().catch(console.error);