Explorar o código

✨ feat(vocabulary): 新增单词管理功能

- 添加词汇管理菜单项到后台导航
- 创建词汇列表页面,支持搜索、分页和CRUD操作
- 实现词汇实体、Schema和服务层
- 集成到API路由和数据源配置
- 提供完整的创建、编辑、删除功能界面
yourname hai 4 meses
pai
achega
aa209e538a

+ 9 - 1
src/client/admin-shadcn/menu.tsx

@@ -8,7 +8,8 @@ import {
   LogOut,
   BarChart3,
   LayoutDashboard,
-  File
+  File,
+  BookOpen
 } from 'lucide-react';
 
 export interface MenuItem {
@@ -94,6 +95,13 @@ export const useMenu = () => {
       path: '/admin/files',
       permission: 'file:manage'
     },
+    {
+      key: 'vocabulary',
+      label: '单词管理',
+      icon: <BookOpen className="h-4 w-4" />,
+      path: '/admin/vocabulary',
+      permission: 'vocabulary:manage'
+    },
     {
       key: 'analytics',
       label: '数据分析',

+ 543 - 0
src/client/admin-shadcn/pages/Vocabulary.tsx

@@ -0,0 +1,543 @@
+import React, { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import { Plus, Search, Edit, Trash2 } from 'lucide-react';
+import { vocabularyClient } from '@/client/api';
+import type { InferRequestType, InferResponseType } from 'hono/client';
+import { Button } from '@/client/components/ui/button';
+import { Input } from '@/client/components/ui/input';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
+import { Badge } from '@/client/components/ui/badge';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form';
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { toast } from 'sonner';
+import { Skeleton } from '@/client/components/ui/skeleton';
+import { Switch } from '@/client/components/ui/switch';
+import { DisabledStatus } from '@/share/types';
+import { CreateVocabularyDto, UpdateVocabularyDto } from '@/server/modules/vocabulary/vocabulary.schema';
+
+// 使用RPC方式提取类型
+type CreateVocabularyRequest = InferRequestType<typeof vocabularyClient.$post>['json'];
+type UpdateVocabularyRequest = InferRequestType<typeof vocabularyClient[':id']['$put']>['json'];
+type VocabularyResponse = InferResponseType<typeof vocabularyClient.$get, 200>['data'][0];
+
+// 直接使用后端定义的 schema
+const createVocabularyFormSchema = CreateVocabularyDto;
+const updateVocabularyFormSchema = UpdateVocabularyDto;
+
+type CreateVocabularyFormData = CreateVocabularyRequest;
+type UpdateVocabularyFormData = UpdateVocabularyRequest;
+
+export const VocabularyPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: ''
+  });
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [editingVocabulary, setEditingVocabulary] = useState<any>(null);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [vocabularyToDelete, setVocabularyToDelete] = useState<number | null>(null);
+  const [isCreateForm, setIsCreateForm] = useState(true);
+  
+  const createForm = useForm<CreateVocabularyFormData>({
+    resolver: zodResolver(createVocabularyFormSchema),
+    defaultValues: {
+      word: '',
+      pronunciation: null,
+      meaning: null,
+      example: null,
+      isDisabled: DisabledStatus.ENABLED,
+    },
+  });
+
+  const updateForm = useForm<UpdateVocabularyFormData>({
+    resolver: zodResolver(updateVocabularyFormSchema),
+    defaultValues: {
+      word: undefined,
+      pronunciation: null,
+      meaning: null,
+      example: null,
+      isDisabled: undefined,
+    },
+  });
+
+  const { data: vocabulariesData, isLoading, refetch } = useQuery({
+    queryKey: ['vocabularies', searchParams],
+    queryFn: async () => {
+      const res = await vocabularyClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search
+        }
+      });
+      if (res.status !== 200) {
+        throw new Error('获取单词列表失败');
+      }
+      return await res.json();
+    }
+  });
+
+  const vocabularies = vocabulariesData?.data || [];
+  const totalCount = vocabulariesData?.pagination?.total || 0;
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault();
+    setSearchParams(prev => ({ ...prev, page: 1 }));
+  };
+
+  // 处理分页
+  const handlePageChange = (page: number, limit: number) => {
+    setSearchParams(prev => ({ ...prev, page, limit }));
+  };
+
+  // 打开创建单词对话框
+  const handleCreateVocabulary = () => {
+    setEditingVocabulary(null);
+    setIsCreateForm(true);
+    createForm.reset({
+      word: '',
+      pronunciation: null,
+      meaning: null,
+      example: null,
+      isDisabled: DisabledStatus.ENABLED,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 打开编辑单词对话框
+  const handleEditVocabulary = (vocabulary: VocabularyResponse) => {
+    setEditingVocabulary(vocabulary);
+    setIsCreateForm(false);
+    updateForm.reset({
+      word: vocabulary.word,
+      pronunciation: vocabulary.pronunciation,
+      meaning: vocabulary.meaning,
+      example: vocabulary.example,
+      isDisabled: vocabulary.isDisabled,
+    });
+    setIsModalOpen(true);
+  };
+
+  // 处理创建表单提交
+  const handleCreateSubmit = async (data: CreateVocabularyFormData) => {
+    try {
+      const res = await vocabularyClient.$post({
+        json: data
+      });
+      if (res.status !== 201) {
+        throw new Error('创建单词失败');
+      }
+      toast.success('单词创建成功');
+      setIsModalOpen(false);
+      refetch();
+    } catch (error) {
+      console.error('创建单词失败:', error);
+      toast.error('创建失败,请重试');
+    }
+  };
+
+  // 处理更新表单提交
+  const handleUpdateSubmit = async (data: UpdateVocabularyFormData) => {
+    if (!editingVocabulary) return;
+    
+    try {
+      const res = await vocabularyClient[':id']['$put']({
+        param: { id: editingVocabulary.id },
+        json: data
+      });
+      if (res.status !== 200) {
+        throw new Error('更新单词失败');
+      }
+      toast.success('单词更新成功');
+      setIsModalOpen(false);
+      refetch();
+    } catch (error) {
+      console.error('更新单词失败:', error);
+      toast.error('更新失败,请重试');
+    }
+  };
+
+  // 处理删除单词
+  const handleDeleteVocabulary = (id: number) => {
+    setVocabularyToDelete(id);
+    setDeleteDialogOpen(true);
+  };
+
+  const confirmDelete = async () => {
+    if (!vocabularyToDelete) return;
+    
+    try {
+      const res = await vocabularyClient[':id']['$delete']({
+        param: { id: vocabularyToDelete }
+      });
+      if (res.status !== 204) {
+        throw new Error('删除单词失败');
+      }
+      toast.success('单词删除成功');
+      refetch();
+    } catch (error) {
+      console.error('删除单词失败:', error);
+      toast.error('删除失败,请重试');
+    } finally {
+      setDeleteDialogOpen(false);
+      setVocabularyToDelete(null);
+    }
+  };
+
+  // 渲染加载骨架
+  if (isLoading) {
+    return (
+      <div className="space-y-4">
+        <div className="flex justify-between items-center">
+          <h1 className="text-2xl font-bold">单词管理</h1>
+          <Button disabled>
+            <Plus className="mr-2 h-4 w-4" />
+            创建单词
+          </Button>
+        </div>
+        
+        <Card>
+          <CardHeader>
+            <Skeleton className="h-6 w-1/4" />
+          </CardHeader>
+          <CardContent>
+            <div className="space-y-2">
+              <Skeleton className="h-4 w-full" />
+              <Skeleton className="h-4 w-full" />
+              <Skeleton className="h-4 w-full" />
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <h1 className="text-2xl font-bold">单词管理</h1>
+        <Button onClick={handleCreateVocabulary}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建单词
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>单词列表</CardTitle>
+          <CardDescription>
+            管理系统中的所有单词,共 {totalCount} 个单词
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="mb-4">
+            <form onSubmit={handleSearch} className="flex gap-2">
+              <div className="relative flex-1 max-w-sm">
+                <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+                <Input
+                  placeholder="搜索单词、发音或含义..."
+                  value={searchParams.search}
+                  onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                  className="pl-8"
+                />
+              </div>
+              <Button type="submit" variant="outline">
+                搜索
+              </Button>
+            </form>
+          </div>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>单词</TableHead>
+                  <TableHead>发音</TableHead>
+                  <TableHead>含义</TableHead>
+                  <TableHead>例句</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {vocabularies.map((vocabulary) => (
+                  <TableRow key={vocabulary.id}>
+                    <TableCell className="font-medium">{vocabulary.word}</TableCell>
+                    <TableCell>{vocabulary.pronunciation || '-'}</TableCell>
+                    <TableCell>{vocabulary.meaning || '-'}</TableCell>
+                    <TableCell>{vocabulary.example || '-'}</TableCell>
+                    <TableCell>
+                      <Badge
+                        variant={vocabulary.isDisabled === 1 ? 'secondary' : 'default'}
+                      >
+                        {vocabulary.isDisabled === 1 ? '禁用' : '启用'}
+                      </Badge>
+                    </TableCell>
+                    <TableCell>
+                      {format(new Date(vocabulary.createdAt), 'yyyy-MM-dd HH:mm')}
+                    </TableCell>
+                    <TableCell className="text-right">
+                      <div className="flex justify-end gap-2">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleEditVocabulary(vocabulary)}
+                        >
+                          <Edit className="h-4 w-4" />
+                        </Button>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          onClick={() => handleDeleteVocabulary(vocabulary.id)}
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+
+          <DataTablePagination
+            currentPage={searchParams.page}
+            totalCount={totalCount}
+            pageSize={searchParams.limit}
+            onPageChange={handlePageChange}
+          />
+        </CardContent>
+      </Card>
+
+      {/* 创建/编辑单词对话框 */}
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>
+              {editingVocabulary ? '编辑单词' : '创建单词'}
+            </DialogTitle>
+            <DialogDescription>
+              {editingVocabulary ? '编辑现有单词信息' : '创建一个新的单词'}
+            </DialogDescription>
+          </DialogHeader>
+          
+          {isCreateForm ? (
+            <Form {...createForm}>
+              <form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
+                <FormField
+                  control={createForm.control}
+                  name="word"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        单词
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入单词" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="pronunciation"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>发音</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入发音(如:/həˈloʊ/)" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="meaning"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>含义</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入单词含义" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="example"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>例句</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入例句" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={createForm.control}
+                  name="isDisabled"
+                  render={({ field }) => (
+                    <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+                      <div className="space-y-0.5">
+                        <FormLabel className="text-base">单词状态</FormLabel>
+                        <FormDescription>
+                          禁用后单词将不会显示在学习列表中
+                        </FormDescription>
+                      </div>
+                      <FormControl>
+                        <Switch
+                          checked={field.value === 1}
+                          onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
+                        />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit">
+                    创建单词
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          ) : (
+            <Form {...updateForm}>
+              <form onSubmit={updateForm.handleSubmit(handleUpdateSubmit)} className="space-y-4">
+                <FormField
+                  control={updateForm.control}
+                  name="word"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        单词
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入单词" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="pronunciation"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>发音</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入发音(如:/həˈloʊ/)" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="meaning"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>含义</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入单词含义" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="example"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>例句</FormLabel>
+                      <FormControl>
+                        <Input placeholder="请输入例句" {...field} />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={updateForm.control}
+                  name="isDisabled"
+                  render={({ field }) => (
+                    <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+                      <div className="space-y-0.5">
+                        <FormLabel className="text-base">单词状态</FormLabel>
+                        <FormDescription>
+                          禁用后单词将不会显示在学习列表中
+                        </FormDescription>
+                      </div>
+                      <FormControl>
+                        <Switch
+                          checked={field.value === 1}
+                          onCheckedChange={(checked) => field.onChange(checked ? 1 : 0)}
+                        />
+                      </FormControl>
+                    </FormItem>
+                  )}
+                />
+
+                <DialogFooter>
+                  <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                    取消
+                  </Button>
+                  <Button type="submit">
+                    更新单词
+                  </Button>
+                </DialogFooter>
+              </form>
+            </Form>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>确认删除</DialogTitle>
+            <DialogDescription>
+              确定要删除这个单词吗?此操作无法撤销。
+            </DialogDescription>
+          </DialogHeader>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
+              取消
+            </Button>
+            <Button variant="destructive" onClick={confirmDelete}>
+              删除
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

+ 6 - 0
src/client/admin-shadcn/routes.tsx

@@ -8,6 +8,7 @@ import { DashboardPage } from './pages/Dashboard';
 import { UsersPage } from './pages/Users';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
+import { VocabularyPage } from './pages/Vocabulary';
 
 export const router = createBrowserRouter([
   {
@@ -45,6 +46,11 @@ export const router = createBrowserRouter([
         element: <FilesPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'vocabulary',
+        element: <VocabularyPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,

+ 3 - 0
src/server/api.ts

@@ -5,6 +5,7 @@ import usersRouter from './api/users/index'
 import authRoute from './api/auth/index'
 import rolesRoute from './api/roles/index'
 import fileRoutes from './api/files/index'
+import vocabularyRoutes from './api/vocabularies/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
@@ -103,11 +104,13 @@ const userRoutes = api.route('/api/v1/users', usersRouter)
 const authRoutes = api.route('/api/v1/auth', authRoute)
 const roleRoutes = api.route('/api/v1/roles', rolesRoute)
 const fileApiRoutes = api.route('/api/v1/files', fileRoutes)
+const vocabRoutes = api.route('/api/v1/vocabularies', vocabularyRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type RoleRoutes = typeof roleRoutes
 export type FileRoutes = typeof fileApiRoutes
+export type VocabularyRoutes = typeof vocabRoutes
 
 app.route('/', api)
 export default app

+ 16 - 0
src/server/api/vocabularies/index.ts

@@ -0,0 +1,16 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { VocabularyEntity } from '@/server/modules/vocabulary/vocabulary.entity';
+import { VocabularySchema, CreateVocabularyDto, UpdateVocabularyDto } from '@/server/modules/vocabulary/vocabulary.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const vocabularyRoutes = createCrudRoutes({
+  entity: VocabularyEntity,
+  createSchema: CreateVocabularyDto,
+  updateSchema: UpdateVocabularyDto,
+  getSchema: VocabularySchema,
+  listSchema: VocabularySchema,
+  searchFields: ['word', 'pronunciation', 'meaning'],
+  middleware: [authMiddleware]
+});
+
+export default vocabularyRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -6,6 +6,7 @@ import process from 'node:process'
 import { UserEntity as User } from "./modules/users/user.entity"
 import { Role } from "./modules/users/role.entity"
 import { File } from "./modules/files/file.entity"
+import { VocabularyEntity } from "./modules/vocabulary/vocabulary.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -15,7 +16,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
-    User, Role, File,
+    User, Role, File, VocabularyEntity,
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 36 - 0
src/server/modules/vocabulary/vocabulary.entity.ts

@@ -0,0 +1,36 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { DeleteStatus, DisabledStatus } from '@/share/types';
+
+@Entity({ name: 'vocabularies' })
+export class VocabularyEntity {
+  @PrimaryGeneratedColumn({ unsigned: true, comment: '单词ID' })
+  id!: number;
+
+  @Column({ name: 'word', type: 'varchar', length: 255, comment: '单词名' })
+  word!: string;
+
+  @Column({ name: 'pronunciation', type: 'varchar', length: 255, nullable: true, comment: '单词发音' })
+  pronunciation!: string | null;
+
+  @Column({ name: 'meaning', type: 'text', nullable: true, comment: '单词含义' })
+  meaning!: string | null;
+
+  @Column({ name: 'example', type: 'text', nullable: true, comment: '单词例句' })
+  example!: string | null;
+
+  @Column({ name: 'is_disabled', type: 'int', default: DisabledStatus.ENABLED, comment: '是否禁用(0:启用,1:禁用)' })
+  isDisabled!: DisabledStatus;
+
+  @Column({ name: 'is_deleted', type: 'int', default: DeleteStatus.NOT_DELETED, comment: '是否删除(0:未删除,1:已删除)' })
+  isDeleted!: DeleteStatus;
+
+  @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
+  updatedAt!: Date;
+
+  constructor(partial?: Partial<VocabularyEntity>) {
+    Object.assign(this, partial);
+  }
+}

+ 106 - 0
src/server/modules/vocabulary/vocabulary.schema.ts

@@ -0,0 +1,106 @@
+import { z } from '@hono/zod-openapi';
+import { DeleteStatus, DisabledStatus } from '@/share/types';
+
+// 基础单词 schema(包含所有字段)
+export const VocabularySchema = z.object({
+  id: z.number().int().positive().openapi({ description: '单词ID' }),
+  word: z.string().min(1, '单词不能为空').max(255, '单词最多255个字符').openapi({
+    example: 'hello',
+    description: '单词名'
+  }),
+  pronunciation: z.string().max(255, '发音最多255个字符').nullable().openapi({
+    example: '/həˈloʊ/',
+    description: '单词发音'
+  }),
+  meaning: z.string().nullable().openapi({
+    example: '你好,打招呼用语',
+    description: '单词含义'
+  }),
+  example: z.string().nullable().openapi({
+    example: 'Hello, how are you today?',
+    description: '单词例句'
+  }),
+  isDisabled: z.number().int().min(0).max(1).default(DisabledStatus.ENABLED).openapi({
+    example: DisabledStatus.ENABLED,
+    description: '是否禁用(0:启用,1:禁用)'
+  }),
+  isDeleted: z.number().int().min(0).max(1).default(DeleteStatus.NOT_DELETED).openapi({
+    example: DeleteStatus.NOT_DELETED,
+    description: '是否删除(0:未删除,1:已删除)'
+  }),
+  createdAt: z.coerce.date().openapi({ description: '创建时间' }),
+  updatedAt: z.coerce.date().openapi({ description: '更新时间' })
+});
+
+// 创建单词请求 schema
+export const CreateVocabularyDto = z.object({
+  word: z.string().min(1, '单词不能为空').max(255, '单词最多255个字符').openapi({
+    example: 'hello',
+    description: '单词名'
+  }),
+  pronunciation: z.string().max(255, '发音最多255个字符').nullable().optional().openapi({
+    example: '/həˈloʊ/',
+    description: '单词发音'
+  }),
+  meaning: z.string().nullable().optional().openapi({
+    example: '你好,打招呼用语',
+    description: '单词含义'
+  }),
+  example: z.string().nullable().optional().openapi({
+    example: 'Hello, how are you today?',
+    description: '单词例句'
+  }),
+  isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').default(DisabledStatus.ENABLED).optional().openapi({
+    example: DisabledStatus.ENABLED,
+    description: '是否禁用(0:启用,1:禁用)'
+  })
+});
+
+// 更新单词请求 schema
+export const UpdateVocabularyDto = z.object({
+  word: z.string().min(1, '单词不能为空').max(255, '单词最多255个字符').optional().openapi({
+    example: 'hello',
+    description: '单词名'
+  }),
+  pronunciation: z.string().max(255, '发音最多255个字符').nullable().optional().openapi({
+    example: '/həˈloʊ/',
+    description: '单词发音'
+  }),
+  meaning: z.string().nullable().optional().openapi({
+    example: '你好,打招呼用语',
+    description: '单词含义'
+  }),
+  example: z.string().nullable().optional().openapi({
+    example: 'Hello, how are you today?',
+    description: '单词例句'
+  }),
+  isDisabled: z.number().int().min(0, '状态值只能是0或1').max(1, '状态值只能是0或1').optional().openapi({
+    example: DisabledStatus.ENABLED,
+    description: '是否禁用(0:启用,1:禁用)'
+  })
+});
+
+// 单词列表响应 schema
+export const VocabularyListResponse = z.object({
+  data: z.array(VocabularySchema),
+  pagination: z.object({
+    total: z.number().openapi({
+      example: 100,
+      description: '总记录数'
+    }),
+    current: z.number().openapi({
+      example: 1,
+      description: '当前页码'
+    }),
+    pageSize: z.number().openapi({
+      example: 10,
+      description: '每页数量'
+    })
+  })
+});
+
+// 类型导出
+export type Vocabulary = z.infer<typeof VocabularySchema>;
+export type CreateVocabularyRequest = z.infer<typeof CreateVocabularyDto>;
+export type UpdateVocabularyRequest = z.infer<typeof UpdateVocabularyDto>;
+export type VocabularyListResponseType = z.infer<typeof VocabularyListResponse>;

+ 57 - 0
src/server/modules/vocabulary/vocabulary.service.ts

@@ -0,0 +1,57 @@
+import { DataSource } from 'typeorm';
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { VocabularyEntity } from './vocabulary.entity';
+
+export class VocabularyService extends GenericCrudService<VocabularyEntity> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, VocabularyEntity);
+  }
+
+  /**
+   * 根据单词名获取单词
+   */
+  async getVocabularyByWord(word: string): Promise<VocabularyEntity | null> {
+    try {
+      return await this.createQueryBuilder()
+        .where('word = :word', { word })
+        .andWhere('is_deleted = 0')
+        .getOne();
+    } catch (error) {
+      console.error('Error getting vocabulary by word:', error);
+      throw new Error('Failed to get vocabulary by word');
+    }
+  }
+
+  /**
+   * 批量创建单词
+   */
+  async batchCreate(vocabularies: Partial<VocabularyEntity>[]): Promise<VocabularyEntity[]> {
+    try {
+      const entities = this.createQueryBuilder()
+        .create(vocabularies as any);
+      return await this.createQueryBuilder()
+        .save(entities);
+    } catch (error) {
+      console.error('Error batch creating vocabularies:', error);
+      throw new Error('Failed to batch create vocabularies');
+    }
+  }
+
+  /**
+   * 搜索单词(支持模糊匹配)
+   */
+  async searchVocabularies(keyword: string, limit: number = 10): Promise<VocabularyEntity[]> {
+    try {
+      return await this.createQueryBuilder()
+        .where('word LIKE :keyword', { keyword: `%${keyword}%` })
+        .orWhere('meaning LIKE :keyword', { keyword: `%${keyword}%` })
+        .andWhere('is_deleted = 0')
+        .orderBy('word', 'ASC')
+        .limit(limit)
+        .getMany();
+    } catch (error) {
+      console.error('Error searching vocabularies:', error);
+      throw new Error('Failed to search vocabularies');
+    }
+  }
+}