feat:生文
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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/2(3500)+ 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
@@ -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": "下包上"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Word 相关实体已移动到 modules/words 目录
|
||||
export * from '../modules/users/user.entity';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+11
-27
@@ -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;
|
||||
}
|
||||
+10
-14
@@ -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[];
|
||||
}
|
||||
+34
-84
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
+4
-4
@@ -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;
|
||||
+41
-16
@@ -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) {}
|
||||
+3
-19
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './word.entity';
|
||||
export * from './word-image.entity';
|
||||
export * from './sentence.entity';
|
||||
export * from './word-sentence.entity';
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user