Browse Source

feat: save

gemercheung 7 tháng trước cách đây
mục cha
commit
6374369e31

+ 2 - 0
packages/backend/.env

@@ -15,3 +15,5 @@ REDIS_URL=redis://192.168.0.47:6379
 JWT_SECRET="d0!doc15415B0*4G0`"
 # 是否预览环境
 IS_PREVIEW=false  
+
+OSS_DOMAIN=https://ossxiaoan.4dage.com

+ 6 - 2
packages/backend/package.json

@@ -24,10 +24,12 @@
     "@nestjs/platform-express": "^10.0.0",
     "@nestjs/swagger": "^8.1.0",
     "@nestjs/typeorm": "^10.0.0",
+    "ali-oss": "^6.22.0",
     "bcryptjs": "^2.4.3",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.0",
     "express-session": "^1.17.3",
+    "moment": "^2.30.1",
     "mysql2": "^3.6.3",
     "nestjs-pino": "^4.2.0",
     "passport-jwt": "^4.0.1",
@@ -39,7 +41,8 @@
     "rxjs": "^7.8.1",
     "svg-captcha": "^1.4.0",
     "typeorm": "^0.3.17",
-    "typeorm-i18n": "0.2.0-rc.1"
+    "typeorm-i18n": "0.2.0-rc.1",
+    "typeorm-translatable": "^0.2.0"
   },
   "devDependencies": {
     "@nestjs/cli": "^10.0.0",
@@ -48,6 +51,7 @@
     "@types/bcryptjs": "^2.4.5",
     "@types/express": "^4.17.17",
     "@types/jest": "^29.5.2",
+    "@types/multer": "^1.4.12",
     "@types/node": "^20.3.1",
     "@types/supertest": "^2.0.12",
     "@typescript-eslint/eslint-plugin": "^6.0.0",
@@ -64,6 +68,6 @@
     "ts-loader": "^9.4.3",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^4.2.0",
-    "typescript": "^5.1.3"
+    "typescript": "^5.7.3"
   }
 }

+ 0 - 8
packages/backend/src/app.module.ts

@@ -1,11 +1,3 @@
-/**********************************
- * @Author: Ronnie Zhang
- * @LastEditor: Ronnie Zhang
- * @LastEditTime: 2023/12/07 20:30:08
- * @Email: zclzone@outlook.com
- * Copyright © 2023 Ronnie Zhang(大脸怪) | https://isme.top
- **********************************/
-
 import { Module } from '@nestjs/common';
 import { SharedModule } from './shared/shared.module';
 import { ConfigModule } from '@nestjs/config';

+ 5 - 0
packages/backend/src/modules/menu/dto.ts

@@ -94,3 +94,8 @@ export class UpdateMenuDto {
   @IsNumber()
   categoryId?: number;
 }
+
+export class UploadCoverDto {
+  @ApiProperty({ type: 'string', format: 'binary', required: true })
+  file: Express.Multer.File;
+}

+ 45 - 3
packages/backend/src/modules/menu/menu.controller.ts

@@ -7,19 +7,30 @@ import {
   Patch,
   Post,
   Query,
+  UploadedFile,
   UseGuards,
+  UseInterceptors,
 } from '@nestjs/common';
-import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
 import { MenuService } from './menu.service';
-import { CreateMenuDto, GetMenuDto, QueryMenuDto, UpdateMenuDto } from './dto';
+import { CreateMenuDto, GetMenuDto, QueryMenuDto, UpdateMenuDto, UploadCoverDto } from './dto';
 import { JwtGuard } from '@/common/guards';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { diskStorage } from 'multer';
+import { extname } from 'path';
+import { OSSService } from '../oss/oss.service';
+import { ConfigService } from '@nestjs/config';
 
 @Controller('menu')
 @ApiTags('menu')
 // @ApiBearerAuth('JWT')
 // @UseGuards(JwtGuard)
 export class MenuController {
-  constructor(private readonly menuService: MenuService) {}
+  constructor(
+    private readonly menuService: MenuService,
+    private readonly oSSService: OSSService,
+    private readonly configService: ConfigService,
+  ) { }
 
   @Post()
   create(@Body() createMenuDto: CreateMenuDto) {
@@ -49,4 +60,35 @@ export class MenuController {
   getLevelMenu(@Param('id') id: string) {
     return this.menuService.findLevel(+id);
   }
+
+  @Post('cover/upload')
+  @ApiConsumes('multipart/form-data')
+  @UseInterceptors(FileInterceptor('file'))
+  // @UseInterceptors(
+  //   FileInterceptor('file', {
+  //     storage: diskStorage({
+  //       destination: './uploads',
+  //       filename: (req, file, cb) => {
+  //         // Generating a 32 random chars long string
+  //         const randomName = Array(32)
+  //           .fill(null)
+  //           .map(() => Math.round(Math.random() * 16).toString(16))
+  //           .join('');
+  //         //Calling the callback passing the random name generated with the original extension name
+  //         cb(null, `${randomName}${extname(file.originalname)}`);
+  //       },
+  //     }),
+  //   }),
+  // )
+  async updateCover(@Body() data: UploadCoverDto, @UploadedFile() file) {
+    console.log('file', file);
+    const result = await this.oSSService.upload(file, { filepath: '/helperCenter' });
+    console.log('result', result);
+    if (result && result.length > 0) {
+      return this.configService.get('OSS_DOMAIN') + '/' + result[0].path;
+    } else {
+      return false
+    }
+
+  }
 }

+ 1 - 1
packages/backend/src/modules/menu/menu.service.ts

@@ -9,7 +9,7 @@ export class MenuService {
   constructor(
     @InjectRepository(Menu)
     private menuRepo: Repository<Menu>,
-  ) { }
+  ) {}
 
   async create(createMenuDto: CreateMenuDto) {
     const menu = this.menuRepo.create(createMenuDto);

+ 272 - 0
packages/backend/src/modules/oss/oss.base.ts

@@ -0,0 +1,272 @@
+import { createHmac } from 'crypto';
+import { OSSOptions } from './oss.provider';
+import * as stream from 'stream';
+import * as moment from 'moment';
+import * as Path from 'path';
+import * as OSS from 'ali-oss';
+
+export interface UploadResult {
+  uploaded: boolean;
+  path: string;
+  src: string;
+  srcSign: string;
+  message: string;
+}
+
+export interface File {
+  fieldname: string;
+  originalname: string;
+  encoding: string;
+  mimetype: string;
+  buffer: Buffer;
+  size: number;
+}
+
+export interface OSSSucessResponse {
+  name: string;
+  url?: string;
+  res: OSS.NormalSuccessResponse;
+  size?: number;
+  aborted?: boolean;
+  rt?: number;
+  keepAliveSocket?: boolean;
+  data?: Buffer;
+  requestUrls?: string[];
+  timing?: null;
+  remoteAddress?: string;
+  remotePort?: number;
+  socketHandledRequests?: number;
+  socketHandledResponses?: number;
+}
+
+export interface ClientSign {
+  name?: string;
+  key?: string;
+  policy: string;
+  OSSAccessKeyId: string;
+  success_action_status?: number;
+  signature: string;
+}
+
+export interface UploadFileOptions {
+  filepath?: string;
+  filename?: string;
+  isInitDateDic?: boolean;
+  dateDicFormat?: string;
+}
+
+export class OSSBase {
+  protected ossClient: OSS;
+  protected options: OSSOptions;
+  protected version = parseFloat(process.versions.node);
+
+  /**
+   * 流式上传
+   * @param target
+   * @param imageStream
+   */
+  protected async putStream(
+    target: string,
+    imageStream: stream.PassThrough,
+  ): Promise<OSSSucessResponse> {
+    return await this.ossClient.putStream(target, imageStream);
+  }
+
+  /**
+   * 上传到OSS
+   * @param file
+   */
+  protected async uploadOSS(file: File | File[], options?: UploadFileOptions) {
+    const result: UploadResult[] = [];
+    const files = Array.isArray(file) ? file : [file];
+
+    if (files && files.length > 0) {
+      for (const item of files) {
+        const filename = options?.filename
+          ? options?.filename + Path.extname(item.originalname).toLowerCase()
+          : this.getImgName(item.originalname);
+
+        const filepath = this.parseFilepath(options?.filepath);
+        const path = `${filepath}/${this.initDateDic(options?.isInitDateDic, options?.dateDicFormat)}`;
+        const target = path + filename;
+
+        const info: UploadResult = {
+          uploaded: true,
+          path: '',
+          src: '',
+          srcSign: '',
+          message: '上传成功',
+        };
+
+        try {
+          const imageStream = new stream.PassThrough();
+          imageStream.end(item.buffer);
+          const uploadResult = await this.putStream(target, imageStream);
+
+          if (uploadResult.res.status === 200) {
+            info.path = uploadResult.name;
+            info.src = this.formatDomain(uploadResult.url);
+            info.srcSign = this.getOssSign(info.src);
+          }
+        } catch (error) {
+          console.error('error', error);
+          info.uploaded = false;
+          info.path = item.originalname;
+          info.message = `上传失败: ${error}`;
+        }
+
+        result.push(info);
+      }
+    }
+
+    return result;
+  }
+
+  /** 格式化自定义域名 */
+  private formatDomain(url?: string) {
+    if (!url) return '';
+
+    const domain = this.options.domain;
+    if (!domain) return url;
+
+    const { bucket, endpoint } = this.options.client;
+    return url.replace(`${bucket}.${endpoint}`, domain);
+  }
+
+  /**
+   * 生成文件名(按时间)
+   * @param {*} filename
+   */
+  protected getImgName(filename: string) {
+    const time = moment().format('HHmmss');
+    const randomStamp = `${Math.floor(Math.random() * 1000)}`;
+    const extname = Path.extname(filename).toLowerCase();
+
+    return time + randomStamp + extname;
+  }
+
+  /**
+   * 转化为可用路径格式
+   * @param filepath 自定义上传oss文件路径
+   */
+  private parseFilepath(filepath?: string) {
+    if (!filepath) return 'image';
+    if (filepath.lastIndexOf('/') === filepath.length - 1) {
+      return filepath.substring(0, filepath.length - 1);
+    }
+    return filepath;
+  }
+
+  /**
+   * 自定义初始化文件夹
+   * @param isInitDateDic 是否自动按照日期创建文件夹
+   * @param format 自定义格式(与momentjs format一致)
+   */
+  private initDateDic(isInitDateDic?: boolean, format?: string) {
+    let isInit = false;
+    if (typeof isInitDateDic === 'undefined') isInit = true;
+    else isInit = isInitDateDic;
+
+    if (!isInit) return '';
+    return !format ? moment().format('YYYYMMDD') + '/' : moment().format(format) + '/';
+  }
+
+  /**
+   * 获取私密bucket访问地址
+   * @param {*} url
+   * @param {*} width
+   * @param {*} height
+   */
+  public getOssSign(url: string, width?: number, height?: number) {
+    let target = url;
+    // 拼装签名后访问地址
+    let urlReturn = '';
+
+    if (url) {
+      const isSelfUrl = `${this.options.client.bucket}.${this.options.client.endpoint}`;
+      const isSelfUrlX: string = this.options.domain || '';
+
+      // 判断是否包含有效地址
+      if (url.indexOf(isSelfUrl) > 0 || url.indexOf(isSelfUrlX) > 0) {
+        let targetArray: string[] = [];
+        if (url.indexOf('?') > 0) {
+          targetArray = url.split('?');
+          target = targetArray[0];
+        }
+        targetArray = target.split('com/');
+        target = targetArray[1];
+      } else {
+        return url;
+      }
+      // 读取配置初始化参数
+      const accessId = this.options.client.accessKeyId;
+      const accessKey = this.options.client.accessKeySecret;
+      let endpoint = `${this.options.client.bucket}.${this.options.client.endpoint}`;
+      const signDateTime = parseInt(moment().format('X'), 10);
+      const outTime = 2 * 3600; // 失效时间
+      const expireTime = signDateTime + outTime;
+
+      if (this.options.domain) {
+        endpoint = this.options.domain;
+      }
+
+      // 拼装签名字符串
+      let toSignString = '';
+      toSignString = 'GET\n';
+      const md5 = '';
+      toSignString = `${toSignString}${md5}\n`;
+      const contentType = '';
+      toSignString = `${toSignString}${contentType}\n`;
+      toSignString = `${toSignString}${expireTime}\n`;
+      let resource = '';
+
+      if (width && height) {
+        resource = `/${this.options.client.bucket}/${target}?x-oss-process=image/resize,m_fill,w_${width},h_${height},limit_0`;
+      } else {
+        resource = `/${this.options.client.bucket}/${target}`;
+      }
+
+      const ossHeaders = '';
+      toSignString = toSignString + ossHeaders;
+      toSignString = toSignString + resource;
+
+      // hmacsha1 签名
+      const sign = encodeURIComponent(
+        createHmac('sha1', accessKey).update(toSignString).digest('base64'),
+      );
+      const h = this.options.client.secure ? 'https' : 'http';
+
+      if (width && height) {
+        urlReturn = `${h}://${endpoint}/${target}?x-oss-process=image/resize,m_fill,w_${width},h_${height},limit_0&OSSAccessKeyId=${accessId}&Expires=${expireTime}&Signature=${sign}`;
+      } else {
+        urlReturn = `${h}://${endpoint}/${target}?OSSAccessKeyId=${accessId}&Expires=${expireTime}&Signature=${sign}`;
+      }
+    }
+
+    return urlReturn;
+  }
+
+  /**
+   * 前端直传签名
+   */
+  public getUploadSgin() {
+    const policyText = {
+      expiration: `${moment().add(1, 'hours').format('YYYY-MM-DDTHH:mm:ss')}.000Z`, // 设置Policy的失效时间
+      conditions: [
+        ['content-length-range', 0, 50048576000], // 设置上传文件的大小限制
+      ],
+    };
+    const policyBase64 = Buffer.from(JSON.stringify(policyText)).toString('base64');
+    const uploadSignature = createHmac('sha1', this.options.client.accessKeySecret)
+      .update(policyBase64)
+      .digest('base64');
+
+    const params: ClientSign = {
+      policy: policyBase64,
+      OSSAccessKeyId: this.options.client.accessKeyId,
+      signature: uploadSignature,
+    };
+
+    return params;
+  }
+}

+ 24 - 0
packages/backend/src/modules/oss/oss.module.ts

@@ -0,0 +1,24 @@
+import { Module, Global, DynamicModule } from '@nestjs/common';
+import { OSS_OPTIONS, OSSOptions, ossProvider } from './oss.provider';
+import { OSSService } from './oss.service';
+
+/**
+ * oss方法实例化模块
+ * @export
+ * @class BaseModule
+ */
+@Global()
+@Module({
+  imports: [],
+  providers: [OSSService],
+  exports: [OSSService],
+})
+export class OSSModule {
+  public static forRoot(options: OSSOptions): DynamicModule {
+    return {
+      module: OSSModule,
+      providers: [ossProvider(), { provide: OSS_OPTIONS, useValue: options }],
+      exports: [OSSService],
+    };
+  }
+}

+ 19 - 0
packages/backend/src/modules/oss/oss.provider.ts

@@ -0,0 +1,19 @@
+import * as OSS from 'ali-oss';
+
+export const OSS_CONST = Symbol('OSS');
+export const OSS_OPTIONS = Symbol('OSS_OPTIONS');
+
+export interface OSSOptions {
+  client: OSS.Options;
+  domain?: string;
+  multi?: boolean;
+  workers?: number;
+}
+
+export const ossProvider = () => ({
+  provide: OSS_CONST,
+  useFactory: (options: OSSOptions) => {
+    return new OSS(options.client);
+  },
+  inject: [OSS_OPTIONS],
+});

+ 78 - 0
packages/backend/src/modules/oss/oss.service.ts

@@ -0,0 +1,78 @@
+import { Injectable, Inject } from '@nestjs/common';
+import { NormalSuccessResponse, DeleteMultiResult } from 'ali-oss';
+import { OSS_CONST, OSS_OPTIONS, OSSOptions } from './oss.provider';
+import { OSSBase, UploadResult, File, UploadFileOptions } from './oss.base';
+import * as OSS from 'ali-oss';
+// import { threadpool } from '../lib/src/oss.works.js';
+// import { threadpool } from './oss.works';
+
+/**
+ * OSS
+ * @export
+ * @class OSSService
+ */
+@Injectable()
+export class OSSService extends OSSBase {
+  constructor(
+    @Inject(OSS_CONST) protected readonly ossClient: OSS,
+    @Inject(OSS_OPTIONS) protected readonly options: OSSOptions,
+  ) {
+    super();
+    // if (this.version >= 11.7 && this.options.multi) {
+    //     threadpool.mainThread(this.options);
+    // }
+  }
+
+  /**
+   * 流式下载
+   * @param target
+   */
+  public async getStream(target: string): Promise<OSS.GetStreamResult> {
+    return await this.ossClient.getStream(target);
+  }
+
+  /**
+   * 删除
+   * @param target
+   */
+  public async delete(target: string): Promise<NormalSuccessResponse> {
+    return await this.ossClient.delete(target);
+  }
+
+  /**
+   * 批量删除
+   * @param target
+   */
+  public async deleteMulti(targets: string[]): Promise<DeleteMultiResult> {
+    return await this.ossClient.deleteMulti(targets);
+  }
+
+  /**
+   * 上传
+   * @param file
+   */
+  public async upload(files: File | File[], options?: UploadFileOptions): Promise<UploadResult[]> {
+    //if (this.version >= 11.7 && this.options.multi) {
+    //    return await this.uploadOSSMuit(files);
+    //} else {
+    return await this.uploadOSS(files, options);
+    //}
+  }
+
+  /**
+   * 上传到OSS(多线程并行上传)
+   * @param file
+   */
+  // private async uploadOSSMuit(files: File[]): Promise<UploadResult[]> {
+  //     const result: UploadResult[] = await threadpool.sendData(files);
+
+  //     return result;
+  // }
+
+  /**
+   * 结束上传进程(仅作为单元测试结束调用)
+   */
+  // public endThread() {
+  //     threadpool.endThread();
+  // }
+}

+ 108 - 0
packages/backend/src/modules/oss/oss.works.ts

@@ -0,0 +1,108 @@
+import { isMainThread, workerData, parentPort, Worker } from 'worker_threads';
+import { cpus } from 'os';
+import { OSSOptions } from './oss.provider';
+import { OSSBase, UploadResult, File } from './oss.base';
+import * as OSS from 'ali-oss';
+
+class Threadpool extends OSSBase {
+  private workPool: Map<string, Worker> = new Map();
+  public workNum: number;
+
+  constructor() {
+    super();
+    this.workNum = Math.floor(cpus().length / 2);
+    if (!isMainThread && this.version >= 11.7) {
+      this.workerThread();
+    }
+  }
+
+  /**
+   * 创建线程池
+   * @param options
+   */
+  public mainThread(options: OSSOptions) {
+    this.workNum = options.workers || this.workNum;
+
+    for (let i = 0; i < this.workNum; i++) {
+      const worker = new Worker(__filename, { workerData: options });
+
+      this.workPool.set(`worker${i}`, worker);
+    }
+  }
+
+  /**
+   * 工作线程
+   */
+  public workerThread() {
+    this.ossClient = new OSS(workerData.client);
+    this.options = workerData;
+    // console.log(`[nest-oss]:worker started with threadId:${threadId}`);
+    if (parentPort) {
+      parentPort.on('message', async (msg: File | File[] | string) => {
+        if (typeof msg !== 'string') {
+          const result = await this.uploadOSS(msg);
+
+          if (parentPort) {
+            parentPort.postMessage(result);
+          }
+        } else if (typeof msg === 'string' && msg === 'endThread') {
+          // console.log(`[nest-oss]:Close worker with threadId:${threadId}`);
+          process.exit(0);
+        }
+      });
+    }
+  }
+
+  /**
+   * 向工作线程发送任务
+   * @param data
+   */
+  public sendData(data: File[]): Promise<UploadResult[]> {
+    const fileLength = data.length;
+    const splicData: File[][] = new Array(this.workNum);
+
+    for (let i = 0; i < fileLength; i++) {
+      if (i < this.workNum) {
+        splicData[i] = [];
+        splicData[i].push(data[i]);
+      } else {
+        splicData[i % this.workNum].push(data[i]);
+      }
+    }
+
+    return new Promise((resolve) => {
+      const result: UploadResult[] = [];
+      for (let i = 0; i < this.workNum; i++) {
+        const item = splicData[i];
+        const worker = this.workPool.get(`worker${i}`);
+
+        if (worker && item) {
+          worker.postMessage(item);
+          worker.once('message', (msg: UploadResult[]) => {
+            result.push(...msg);
+            if (result.length === fileLength) {
+              resolve(result);
+            }
+          });
+        } else {
+          break;
+        }
+      }
+    });
+  }
+
+  /**
+   * 结束工作线程
+   */
+  public endThread() {
+    for (let i = 0; i < this.workNum; i++) {
+      const worker = this.workPool.get(`worker${i}`);
+
+      if (worker) {
+        worker.postMessage('endThread');
+      }
+    }
+  }
+}
+
+export const threadpool = new Threadpool();

+ 14 - 0
packages/backend/src/shared/shared.module.ts

@@ -16,10 +16,24 @@ import { ConfigService } from '@nestjs/config';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { RedisService } from './redis.service';
 import { createClient } from 'redis';
+import { OSSModule } from '../modules/oss/oss.module';
 
 @Global()
 @Module({
   imports: [
+    OSSModule.forRoot({
+      client: {
+        endpoint: 'oss-cn-shenzhen.aliyuncs.com', // endpoint域名
+        accessKeyId: 'LTAI5tHDYawJiSyoB9kvK9By', // 账号
+        accessKeySecret: 'Tg4kPeIOQ23oXGo9FdgLH0F2Z7tFRI', // 密码
+        bucket: 'oss-xiaoan', // 存储桶
+        internal: false, // 是否使用阿里云内部网访问
+        secure: true, // 使用 HTTPS
+        cname: false, // 自定义endpoint
+        timeout: '90s',
+      },
+      domain: '', // 自定义域名
+    }),
     TypeOrmModule.forRootAsync({
       inject: [ConfigService],
       useFactory: (configService: ConfigService) => {

+ 1 - 1
packages/frontend/src/views/category/index.vue

@@ -177,5 +177,5 @@ async function handleEnable(row) {
 }
 
 const allCategory = ref([])
-api.getAll().then(({ data = [] }) => (allCategory.value = data.map(item => ({ label: item.title, value: item.id }))))
+api.getAll().then(({ data = [] }) => (allCategory.value = data.map(item => ({ label: item.title, value: item.id }))))
 </script>

+ 10 - 0
packages/frontend/src/views/menu/api.js

@@ -0,0 +1,10 @@
+import { request } from '@/utils'
+
+export default {
+  create: data => request.post('/menu', data),
+  read: (params = {}) => request.get('/menu/page', { params }),
+  update: data => request.patch(`/menu/${data.id}`, data),
+  delete: id => request.delete(`/menu/${id}`),
+  getOne: id => request.get(`/menu/detail/${id}`),
+  getLevel: id => request.get(`/menu/level/${id}`),
+}

+ 93 - 14
packages/frontend/src/views/menu/index.vue

@@ -1,30 +1,83 @@
 <template>
   <CommonPage>
     <template #action>
-      <NButton type="primary" @click="router.push('article/add')">
+      <NButton type="primary" @click="handleAdd">
         <i class="i-material-symbols:add mr-4 text-18" />
         新增一级菜单
       </NButton>
     </template>
 
     <n-flex class="flex" justify="justify-center" size="large">
-      <n-card class="min-w-200 min-h-220 w-30%" title="新手入门">
-        <div class="ml-20 flex-col">
-          xxx
-        </div>
-        <template #header-extra>
-          <n-dropdown trigger="click" :options="options" :show-arrow="true" @select="handleSelect">
-            <n-button text>
-              <i class="i-material-symbols:more-horiz text-24" />
-            </n-button>
-          </n-dropdown>
-        </template>
-      </n-card>
+      <template v-for="(item) of topMenu" :key="item.id">
+        <n-card class="min-h-220 min-w-200 w-30%" :title="item.title">
+          <div class="ml-20 flex-col">
+            xxx
+          </div>
+          <template #header-extra>
+            <n-dropdown trigger="click" :options="options" :show-arrow="true"
+              @select="(key) => handleSelect(key, item)">
+              <n-button text>
+                <i class="i-material-symbols:more-horiz text-24" />
+              </n-button>
+            </n-dropdown>
+          </template>
+        </n-card>
+      </template>
     </n-flex>
+
+    <MeModal ref="modalRef" width="520px">
+      <n-form ref="modalFormRef" label-placement="left" label-align="left" :label-width="80" :model="modalForm">
+        <n-form-item label="名称" path="title" :rule="{
+          required: true,
+          message: '请输入名称',
+          trigger: ['input', 'blur'],
+        }">
+          <n-input v-model:value="modalForm.title" />
+        </n-form-item>
+
+        <n-form-item label="备注" path="remark">
+          <n-input v-model:value="modalForm.remark" />
+        </n-form-item>
+
+        <n-form-item label="状态" path="enable">
+          <NSwitch v-model:value="modalForm.enable">
+            <template #checked>
+              启用
+            </template>
+            <template #unchecked>
+              停用
+            </template>
+          </NSwitch>
+        </n-form-item>
+      </n-form>
+    </MeModal>
   </CommonPage>
 </template>
 
 <script setup>
+import { MeModal } from '@/components'
+import { useCrud } from '@/composables'
+import { onMounted } from 'vue'
+import MenuApi from './api.js'
+
+const topMenu = ref([])
+
+const $table = ref(null)
+/** QueryBar筛选参数(可选) */
+const queryItems = ref({})
+
+const { modalRef, modalFormRef, modalAction, modalForm, handleAdd, handleDelete, handleEdit }
+  = useCrud({
+    name: '菜单',
+    doCreate: MenuApi.create,
+    doDelete: MenuApi.delete,
+    doUpdate: MenuApi.update,
+    initForm: { enable: true },
+    refresh: (_, keepCurrentPage) => $table.value?.handleSearch(keepCurrentPage),
+  })
+onMounted(() => {
+  $table.value?.handleSearch()
+})
 const options = [
   {
     label: '编辑',
@@ -35,7 +88,33 @@ const options = [
     key: 'delete',
   },
 ]
-function handleSelect() {
+function handleSelect(key, item) {
+  const { id } = item
+  switch (key) {
+    case 'edit':
+      handleTopMenuEdit(id)
+      break
+    case 'delete':
+      handleTopMenuDelete(id)
+      break
+  }
+}
+
+async function handleTopMenuDelete(id) {
+  const res = await MenuApi.delete(id)
+  if (res.code === 0) {
+    $message.success('操作成功!')
+    getTopMenuList()
+  }
+}
+async function handleTopMenuEdit(id) {
+  console.log('id', id)
+}
 
+function getTopMenuList() {
+  MenuApi.getLevel(0).then(({ data = [] }) => (topMenu.value = data))
 }
+onMounted(() => {
+  getTopMenuList()
+})
 </script>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 547 - 200
pnpm-lock.yaml