diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 574e8ba9..8620e99b 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -30,19 +30,31 @@ jobs: run: pnpm install --frozen-lockfile - name: Install Playwright browsers run: pnpm exec playwright install --with-deps + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Set up Docker Compose + uses: docker/setup-compose-action@v1 - name: Start Backend Services + working-directory: ./template/nestJs + env: + COMPOSE_BAKE: "false" run: | - cd template/nestJs cp .env.example .env docker compose up -d - name: Wait for Backend Services + working-directory: ./template/nestJs run: | echo "Waiting for Backend Services to start..." - cd template/nestJs timeout 120s bash -c 'until curl -f http://localhost:3000/healthCheck 2>/dev/null; do echo "Backend API not ready yet..." sleep 3 done' echo "Backend Services are ready!" + - name: Dump backend logs + if: failure() + working-directory: ./template/nestJs + run: docker compose logs - name: Run E2E tests + env: + PLAYWRIGHT_WEB_SERVER_COMMAND: pnpm dev:full run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index b367ac18..eefa1b33 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist yarn.lock .env .history +playwright-report diff --git a/README.md b/README.md index 6abc52bc..1be8353f 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,45 @@ npm i && npm start ## 前端启动 +前端支持 **Mock 独立启动** 与 **连接真实后端** 两种方式。 + +### Mock 独立启动(推荐快速体验) + 在 `tiny-pro/web` 下依次执行以下命令: - 安装依赖:`npm i` - 启动前端项目:`npm start` +`npm start` 会同时拉起 Mock 服务和 Vite 开发服务,无需启动后端即可访问 [http://localhost:3031/](http://localhost:3031/)。 + +### 连接真实后端 + +请先按上文启动后端服务,然后在 `tiny-pro/web` 下执行: + +```bash +npm run dev:full +``` + 更详细的文档请参考 TinyPro 官网:[https://opentiny.design/vue-pro](https://opentiny.design/vue-pro) ## 本地启动 -如果你有意愿参与本项目的贡献,可以通过以下方式启动前后端(前提是 MySQL/Redis 服务已成功启动),并进行项目开发和调试。 +如果你有意愿参与本项目的贡献,可以通过以下方式启动项目。 + +### 仅启动前端(Mock) + +无需 MySQL / Redis / 后端,适合前端开发与调试: + +```shell +pnpm i +pnpm dev +``` + +`pnpm dev` 会调用前端的 `start` 脚本,同时启动 Mock 与 Vite。启动成功之后,会自动打开浏览器,并访问:[http://localhost:3031/](http://localhost:3031/)。 + +### 启动前后端联调 + +前提是 MySQL / Redis 服务已成功启动。 1. 创建一个空的 MySQL 数据库 `demo_tiny_pro` @@ -131,8 +160,8 @@ pnpm i # 启动后端,执行该命令之后,会初始化 MySQL 数据库 demo_tiny_pro 的表结构,并填充表数据 pnpm dev:backend -# 启动前端 -pnpm dev +# 启动前端(关闭 Mock,代理到真实后端) +pnpm -F tiny-pro-vue dev:full ``` 启动成功之后,会自动打开浏览器,并访问:[http://localhost:3031/](http://localhost:3031/)。 diff --git a/package.json b/package.json index 98a5166e..0fdd5497 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different", "test:unit:backend": "pnpm -F tinyui-nestjs-server test", "test:cov:backend": "pnpm -F tinyui-nestjs-server test:cov", - "test:e2e": "playwright test", + "test:e2e": "node --test tests/playwright-webserver.test.mjs && playwright test", "watch": "run-s clean build:main && run-p \"build:main -- -w\" ", "cov": "run-s build test:unit cov:html && open-cli coverage/index.html", "cov:html": "nyc report --reporter=html", diff --git a/playwright-webserver.mjs b/playwright-webserver.mjs new file mode 100644 index 00000000..6401fbc0 --- /dev/null +++ b/playwright-webserver.mjs @@ -0,0 +1,7 @@ +export function resolvePlaywrightWebServerCommand(env = process.env) { + const override = env.PLAYWRIGHT_WEB_SERVER_COMMAND?.trim() + if (override) { + return override + } + return env.CI ? 'pnpm dev:full' : 'pnpm start' +} diff --git a/playwright.config.ts b/playwright.config.ts index 70d2cf31..7d2e8472 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; + import { resolvePlaywrightWebServerCommand } from './playwright-webserver.mjs'; export default defineConfig({ workers: 1, @@ -35,7 +36,7 @@ } ], webServer: { - command: 'pnpm start', + command: resolvePlaywrightWebServerCommand(), cwd: 'template/tinyvue', url: 'http://localhost:3031/vue-pro', reuseExistingServer: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 286a561f..6ed61c1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,9 +352,6 @@ importers: '@babel/core': specifier: ^7.25.2 version: 7.26.10 - '@gaonengwww/mock-server': - specifier: ^1.0.5 - version: 1.0.5 '@mcp-b/webmcp-polyfill': specifier: ^3.0.0 version: 3.0.0 @@ -527,6 +524,9 @@ importers: mockjs: specifier: ^1.1.0 version: 1.1.0 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 rollup-plugin-visualizer: specifier: ^5.12.0 version: 5.14.0(rollup@2.79.2) @@ -2125,10 +2125,6 @@ packages: '@gaonengwww/jose@6.1.0': resolution: {integrity: sha512-4LLQNSYJH/EnEd9IPZpgVL72i9FKjyY19iNaII9gsF7yzWs8uFXeI5xaEL6rWlM0YyUDHP76PjFU5vkVkh9kmA==} - '@gaonengwww/mock-server@1.0.5': - resolution: {integrity: sha512-Od/7ks6OIcvYqpzJe3lprI2aCJE8cSbiZ5QleIISaJtHf6/xe4dPSEp9Yd/kR5o7OyGiMKTxe9oViyx3vGMT8g==} - hasBin: true - '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -7174,10 +7170,6 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@2.15.1: resolution: {integrity: sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==} @@ -17946,11 +17938,6 @@ snapshots: - unrun - vue-tsc - '@gaonengwww/mock-server@1.0.5': - dependencies: - commander: 12.1.0 - fast-glob: 3.3.3 - '@gar/promisify@1.1.3': {} '@hapi/address@5.1.1': @@ -27874,8 +27861,6 @@ snapshots: commander@10.0.1: {} - commander@12.1.0: {} - commander@2.15.1: {} commander@2.20.3: {} diff --git a/template/nestJs/dockerfile b/template/nestJs/dockerfile index 5f86d0ba..1b6cefbc 100644 --- a/template/nestJs/dockerfile +++ b/template/nestJs/dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine as BUILDER WORKDIR /builder/ ADD . . RUN apk add wait4x && \ - npm install pnpm -g && \ + npm install pnpm@10 -g && \ pnpm i && \ pnpm build diff --git a/template/nestJs/locales.json b/template/nestJs/locales.json index 72b14080..41b6b6ba 100644 --- a/template/nestJs/locales.json +++ b/template/nestJs/locales.json @@ -486,6 +486,21 @@ "work.index.questions5": "What should I do if I am unable to log in to the online course service platform?", "work.index.questions6": "How to create a new project ticket?", "work.index.netonline": "Online consultation", + "work.chart.finished": "Finished", + "work.chart.month1": "Jan", + "work.chart.month2": "Feb", + "work.chart.month3": "Mar", + "work.chart.month4": "Apr", + "work.chart.month5": "May", + "work.chart.month6": "Jun", + "work.chart.month7": "Jul", + "work.chart.month8": "Aug", + "work.chart.month9": "Sep", + "work.chart.month10": "Oct", + "work.chart.finance": "Finance", + "work.chart.rd": "R&D", + "work.chart.ops": "Operations", + "work.chart.hr": "HR", "home.main.one": "first screen", "home.main.up": "Page Onload", "home.main.down": "Sampling PV", @@ -1163,6 +1178,21 @@ "work.index.questions5": "无法登录网课服务平台怎么办?", "work.index.questions6": "如何创建新项目工单?", "work.index.netonline": "在线咨询", + "work.chart.finished": "已完成", + "work.chart.month1": "1月", + "work.chart.month2": "2月", + "work.chart.month3": "3月", + "work.chart.month4": "4月", + "work.chart.month5": "5月", + "work.chart.month6": "6月", + "work.chart.month7": "7月", + "work.chart.month8": "8月", + "work.chart.month9": "9月", + "work.chart.month10": "10月", + "work.chart.finance": "财务部", + "work.chart.rd": "研发部", + "work.chart.ops": "运营部", + "work.chart.hr": "人力部", "home.main.one": "首屏可见", "home.main.up": "页面Onload", "home.main.down": "采样PV", @@ -1352,4 +1382,4 @@ "component.error": "组件错误", "component.error.contact": "请联系管理员或重新登录" } -} \ No newline at end of file +} diff --git a/template/nestJs/src/application/init/data.ts b/template/nestJs/src/application/init/data.ts index 5c0dabf0..3ba416ef 100644 --- a/template/nestJs/src/application/init/data.ts +++ b/template/nestJs/src/application/init/data.ts @@ -1,87 +1,96 @@ export const applicationData = [ { - name: 'Tiny Design 设计体系', + name: 'TinyVue 组件库', description: - '华为云产品和服务的规范体系,包括交互视觉设计、业务流程、国际化、术语词条。', - tag: '[{ "type": "", "value": "机会点定义" }, { "type": "danger", "value": "交互设计" }]', - classify: 'design', + 'OpenTiny 企业级 UI 组件库,同时支持 Vue.js 2 与 Vue.js 3,以及 PC 与移动端。', + tag: '[{ "type": "success", "value": "开发" }]', + classify: 'dev', icon: 'card-list-application-default.png', }, { - name: 'Tiny DesignLink 设计流水线工具', + name: 'TinyEngine 低代码引擎', description: - '设计+协同+资源管理,一个工具就够了,在线原型设计、设计过程融入DevOps流程。', - tag: '[{ "type": "error", "value": "交互设计" }, { "type": "warning", "value": "视觉设计" }]', + 'TinyEngine低代码引擎,AI时代的智能低代码基座。', + tag: '[{ "type": "success", "value": "开发" }, { "type": "danger", "value": "交互设计" }]', + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'TinyTheme 主题配置系统', + description: 'OpenTiny 主题配置系统,支持在线定制组件主题与视觉风格。', + tag: '[{ "type": "danger", "value": "交互设计" }, { "type": "warning", "value": "视觉设计" }]', classify: 'design', icon: 'card-list-application-default.png', }, { - name: 'TinyUI3.0 开发工具 ', - description: 'Cloud Design System 提供了丰富的规范文档及开发组件。', - tag: '[{ "type": "success", "value": "开发" }]', - classify: 'dev', + name: 'OpenTiny Icons 图标库', + description: + 'OpenTiny Design Icons 图标库,为组件与业务页面提供统一图标资源。', + tag: '[{ "type": "warning", "value": "视觉设计" }]', + classify: 'design', icon: 'card-list-application-default.png', }, { - name: 'TinyPlus3.0 开发工具', + name: 'TinyCLI 命令行工具', description: - 'TinyPlus3.0 是基于Angular + Typescript的Web前端云业务组件库。', + '灵活可扩展的 OpenTiny 前端命令行工具,提供工程化套件与插件。', tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', - icon: 'card-list-tiny-plus.png', + icon: 'card-list-console-framework.png', }, { - name: 'Tiny Stage 工程工具 ', + name: 'TinyPro 中后台模板', description: - '一个跨平台的前端工程化cli工具,为开发提供一系列开发套件和工程插件', - tag: '[{ "type": "success", "value": "开发" }]', + '基于 TinyVue 的前后端分离后台管理系统,支持在线配置菜单、路由、国际化,开箱即用。', + tag: '[{ "type": "success", "value": "开发" }, { "type": "warning", "value": "上线" }]', classify: 'dev', icon: 'card-list-console-framework.png', }, { - name: 'Tiny Flow 接口编排工具 ', + name: 'TinyEditor 富文本编辑器', description: - '端到端的API编排解决方案,通过可视化编程的方式快速生成、发布、调试的API编排。', + '基于 Quill 2.0 的富文本编辑器,扩展丰富模块与格式,功能强大、开箱即用。', tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', icon: 'card-list-console-framework.png', }, { - name: 'Tiny Gate 门禁系统', + name: 'TinyRobot AI 对话组件库', description: - '门禁系统,通过卡点方式集成到伏羲流水线,在服务发布时生成预览页面。', - tag: '[{ "type": "info", "value": "测试验证" }]', + 'AI 对话组件库,提供丰富的 AI 交互组件,助力快速构建企业级 AI 应用。', + tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', - icon: 'card-list-console-framework.png', + icon: 'card-list-furion.png', }, { - name: 'Console Framework 控制台框架', - description: '华为云各服务快速构建管理控制台的平台。', - tag: '[{ "type": "success", "value": "开发" },{ "type": "info", "value": "测试验证" },{ "type": "warning", "value": "上线" }]', + name: 'TinyVue Mobile 移动端组件', + description: + '基于 OpenTiny 设计规范的 Vue3 移动端组件库,支持 TypeScript、主题切换与国际化。', + tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', - icon: 'card-list-console-framework.png', + icon: 'card-list-application-default.png', }, { - name: 'Nodejs Framework Nodejs应用', + name: 'TinyCharts 可视化图表库', description: - '基于egg的定制化web服务框架,让你快速上手Nodejs做BFF意见微服务。', - tag: '[{ "type": "success", "value": "开发" },{ "type": "info", "value": "测试验证" }]', + '前端可视化图表库,提供 40 多个图表组件,支持主题定制、响应式和无障碍,兼容 ECharts API。', + tag: '[{ "type": "success", "value": "开发" }, { "type": "warning", "value": "视觉设计" }]', classify: 'dev', - icon: 'card-list-console-framework.png', + icon: 'card-list-application-default.png', }, { - name: 'Furion 前端体验监控', + name: 'WebMCP SDK', description: - '提供端到端前端用户体验度量,让产品用户体验可度量、可监控、可优化。', - tag: '[{ "type": "", "value": "机会点定义" }]', + '前端 AI 与浏览器自动化工具包,通过 WebMCP + WebSkills 构建 AI 原生应用。', + tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', - icon: 'card-list-furion.png', + icon: 'card-list-console-framework.png', }, { - name: 'Tiny Mock API 管理', + name: 'GenUI SDK', description: - '功能强大的API管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。', - tag: '[{ "type": "success", "value": "开发" },{ "type": "warning", "value": "视觉设计" }]', + '面向 Vue、Angular 的 Generative UI SDK,帮助快速生成交互界面。', + tag: '[{ "type": "success", "value": "开发" }]', classify: 'dev', icon: 'card-list-application-default.png', }, diff --git a/template/springboot/src/main/resources/locales.json b/template/springboot/src/main/resources/locales.json index ae885aab..5d819c29 100644 --- a/template/springboot/src/main/resources/locales.json +++ b/template/springboot/src/main/resources/locales.json @@ -453,6 +453,21 @@ "work.index.questions5": "What should I do if I am unable to log in to the online course service platform?", "work.index.questions6": "How to create a new project ticket?", "work.index.netonline": "Online consultation", + "work.chart.finished": "Finished", + "work.chart.month1": "Jan", + "work.chart.month2": "Feb", + "work.chart.month3": "Mar", + "work.chart.month4": "Apr", + "work.chart.month5": "May", + "work.chart.month6": "Jun", + "work.chart.month7": "Jul", + "work.chart.month8": "Aug", + "work.chart.month9": "Sep", + "work.chart.month10": "Oct", + "work.chart.finance": "Finance", + "work.chart.rd": "R&D", + "work.chart.ops": "Operations", + "work.chart.hr": "HR", "home.main.one": "first screen", "home.main.up": "Page Onload", "home.main.down": "Sampling PV", @@ -1097,6 +1112,21 @@ "work.index.questions5": "无法登录网课服务平台怎么办?", "work.index.questions6": "如何创建新项目工单?", "work.index.netonline": "在线咨询", + "work.chart.finished": "已完成", + "work.chart.month1": "1月", + "work.chart.month2": "2月", + "work.chart.month3": "3月", + "work.chart.month4": "4月", + "work.chart.month5": "5月", + "work.chart.month6": "6月", + "work.chart.month7": "7月", + "work.chart.month8": "8月", + "work.chart.month9": "9月", + "work.chart.month10": "10月", + "work.chart.finance": "财务部", + "work.chart.rd": "研发部", + "work.chart.ops": "运营部", + "work.chart.hr": "人力部", "home.main.one": "首屏可见", "home.main.up": "页面Onload", "home.main.down": "采样PV", diff --git a/template/tinyvue/README.md b/template/tinyvue/README.md index 1142a171..8885d637 100644 --- a/template/tinyvue/README.md +++ b/template/tinyvue/README.md @@ -14,15 +14,27 @@ npm i ``` -### 启动开发环境 +### 启动开发环境(含 Mock) + +`npm start` 会同时启动 Mock 服务和前端开发服务,无需单独启动后端即可访问。 + +```bash +npm start +``` + +启动成功后访问:[http://localhost:3031/](http://localhost:3031/) + +### 连接真实后端 + +若已启动 NestJS / Spring Boot 后端,可关闭 Mock,将接口代理到真实服务: ```bash -npm run dev +npm run dev:full ``` -### 启动mock服务 +### 单独启动 Mock 服务 -部分场景使用了mock数据, 所以请确保您启动了mock服务 +如需单独启动 Mock 服务: ```bash npm run mock diff --git a/template/tinyvue/config/dev-proxy.ts b/template/tinyvue/config/dev-proxy.ts new file mode 100644 index 00000000..f19d2915 --- /dev/null +++ b/template/tinyvue/config/dev-proxy.ts @@ -0,0 +1,42 @@ +export interface DevProxyEnv { + VITE_BASE_API: string + VITE_MOCK_SERVER_HOST: string + VITE_SERVER_HOST: string + VITE_MOCK_HOST: string +} + +export interface DevProxyRewrite { + target: string + changeOrigin: true + logLevel?: 'debug' + rewrite: (path: string) => string +} + +export function createDevProxyConfig( + env: DevProxyEnv, + useMock: boolean, +): Record { + const apiTarget = useMock ? env.VITE_MOCK_HOST : env.VITE_SERVER_HOST + + return { + [env.VITE_BASE_API]: { + target: apiTarget, + changeOrigin: true, + logLevel: 'debug', + rewrite: (path: string) => + path.replace( + new RegExp(`^${env.VITE_BASE_API}`), + useMock ? env.VITE_BASE_API : '', + ), + }, + [env.VITE_MOCK_SERVER_HOST]: { + target: apiTarget, + changeOrigin: true, + rewrite: (path: string) => + path.replace( + new RegExp(`^${env.VITE_MOCK_SERVER_HOST}`), + useMock ? '' : env.VITE_MOCK_SERVER_HOST, + ), + }, + } +} diff --git a/template/tinyvue/config/vite.config.dev.ts b/template/tinyvue/config/vite.config.dev.ts index 9e47f0db..ff758113 100644 --- a/template/tinyvue/config/vite.config.dev.ts +++ b/template/tinyvue/config/vite.config.dev.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path' import process from 'node:process' import { configDotenv } from 'dotenv' import { loadEnv, mergeConfig } from 'vite' +import { createDevProxyConfig } from './dev-proxy' import baseConfig from './vite.config.base' // 加载 dev.env 文件 @@ -11,31 +12,14 @@ configDotenv({ // 加载环境变量(development 模式会读取 .env.development 和 .env) const env = loadEnv('development', process.cwd()) +const useMock = env.VITE_USE_MOCK === 'true' +const proxyConfig = createDevProxyConfig(env, useMock) -const proxyConfig = { - [env.VITE_BASE_API]: { - target: env.VITE_SERVER_HOST, - changeOrigin: true, - logLevel: 'debug', - rewrite: (path: string) => - path.replace( - new RegExp(`${env.VITE_BASE_API}`), - '', - ), - }, - [env.VITE_MOCK_SERVER_HOST]: { - target: env.VITE_SERVER_HOST, - changeOrigin: true, - rewrite: (path: string) => { - return path.replace(new RegExp(`${env.VITE_MOCK_SERVER_HOST}`), '/mock') - }, - }, -} export default mergeConfig( { mode: 'development', server: { - open: true, + open: process.env.CI !== 'true', fs: { strict: true, }, diff --git a/template/tinyvue/dev.env b/template/tinyvue/dev.env index c99d7ccf..2d62f91b 100644 --- a/template/tinyvue/dev.env +++ b/template/tinyvue/dev.env @@ -3,7 +3,7 @@ VITE_BASE_API=/api VITE_SERVER_HOST= http://127.0.0.1:3000 VITE_MOCK_HOST= http://127.0.0.1:8848 VITE_USE_MOCK= false -VITE_MOCK_IGNORE= /api/user/userInfo,/api/user/login,/api/user/register,/api/employee/getEmployee +VITE_MOCK_IGNORE= VITE_MOCK_SERVER_HOST=/mock VITE_LOWCODE_DESIGNER_ENABLED=true diff --git a/template/tinyvue/package.json b/template/tinyvue/package.json index f6febf77..ef84d6be 100644 --- a/template/tinyvue/package.json +++ b/template/tinyvue/package.json @@ -7,10 +7,12 @@ "author": "Tiny Team", "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=18.18.0" }, "scripts": { - "start": "vite --config ./config/vite.config.dev.ts --port 3031", + "start": "cross-env VITE_USE_MOCK=true run-p mock dev:vite", + "dev:vite": "vite --config ./config/vite.config.dev.ts --port 3031", + "dev:full": "cross-env VITE_USE_MOCK=false pnpm dev:vite", "build": "vite build --config ./config/vite.config.prod.ts", "preview": "vite preview", "build:wp": "webpack --config webpack.config.js", @@ -20,6 +22,7 @@ "report": "cross-env REPORT=true npm run build", "lint-staged": "npx lint-staged", "mock": "tsx ./src/mock/index.ts", + "test:mock": "node --import tsx --test src/mock/*.test.ts config/*.test.ts", "dev:fr": "farm", "build:fr": "farm build", "lint": "eslint", @@ -27,7 +30,6 @@ }, "dependencies": { "@babel/core": "^7.25.2", - "@gaonengwww/mock-server": "^1.0.5", "@mcp-b/webmcp-polyfill": "^3.0.0", "@mcp-b/webmcp-types": "^3.0.0", "@opentiny/icons": "^0.1.3", @@ -87,6 +89,7 @@ "less-loader": "^12.2.0", "lint-staged": "^11.2.6", "mockjs": "^1.1.0", + "npm-run-all": "^4.1.5", "rollup-plugin-visualizer": "^5.12.0", "style-loader": "^4.0.0", "style-resources-loader": "1.5.0", diff --git a/template/tinyvue/src/locales.json b/template/tinyvue/src/locales.json index 57a06b87..f22f396d 100644 --- a/template/tinyvue/src/locales.json +++ b/template/tinyvue/src/locales.json @@ -21,6 +21,7 @@ "menu.menuPage.second": "Second Page", "menu.menuPage.third": "Menu Demo Page", "menu.user": "User Center", + "menu.systemManager": "System Manager", "menu.userManager": "User Manager", "menu.userManager.info": "All User Info", "menu.userManager.setting": "All User Setting", @@ -40,6 +41,8 @@ "messageBox.userSettings": "User Settings", "messageBox.logout": "Logout", "messageBox.updatePwd": "Update Password", + "message.delete.success": "Delete Success", + "menu.cloud": "Cloud service capability", "menu.btn.confirm": "Submit", "menu.i18n": "I18n Manage", "theme.title.main": "Personalized configuration", @@ -415,11 +418,25 @@ "work.mock.week2": "Zero promotion practice (2 weeks)", "work.mock.week3": "Zero promotion practice (3 weeks)", "work.mock.network": "Network Reality", + "work.mock.collectValue1": "Institutional Learning Video Course", + "work.mock.collectDescription1": "Convert attendance, promotion, and other systems into interactive video courses for quick understanding and approval processes (such as leave applications, supervisor approval, HR filing).", + "work.mock.collectHotLabel1": "Popular", + "work.mock.collectLabel2": "Template for Leave Application Process", + "work.mock.collectValue2": "How to become a Business Mentor Classic Course Review", + "work.mock.collectDescription2": "Match domain experts according to the skill matrix and track and guide progress in real-time through task dashboards", + "work.mock.collectLabel3": "Develop a Personal Development IDP", + "work.mock.collectValue3": "Student workbook", + "work.mock.collectDescription3": "Build a PK ranking for newcomers in the same period, and generate a 'growth index' ranking based on learning progress and task completion", + "work.mock.collectLabel4": "Experience Sharing Points Pool", + "work.mock.collectValue4": "Teacher's Online Course Platform", + "work.mock.collectDescription4": "Skill training, collaborative support, and dynamic feedback mechanism to build a full lifecycle growth system", + "work.mock.collectLabel5": "The Three Order Model of 'Cognition Practice Practice Practice'", "work.mock.centralized": "Centralized training for new employees", "work.mock.hardware": "Hardware Installation Practice", "work.index.learn": "Learning Planning", "work.index.coach": "Learning coaching", "work.index.formalization": "Learning Formalization", + "work.index.collect": "Related Collection Functions", "work.index.practiced": "Learning practiced", "work.index.train": "Centralized training", "work.index.Inquiry": "Life little helper", @@ -462,7 +479,28 @@ "work.index.Numbers": "Number", "work.index.Person": "Person", "work.index.net": "Net", + "work.index.questions1": "How to publish articles on employee forums?", + "work.index.questions2": "How to solve login failure?", + "work.index.questions3": "What should I do if I am unable to log in to the Employee Home platform?", + "work.index.questions4": "Welcome to Document Library and Community", + "work.index.questions5": "What should I do if I am unable to log in to the online course service platform?", + "work.index.questions6": "How to create a new project ticket?", "work.index.netonline": "Online consultation", + "work.chart.finished": "Finished", + "work.chart.month1": "Jan", + "work.chart.month2": "Feb", + "work.chart.month3": "Mar", + "work.chart.month4": "Apr", + "work.chart.month5": "May", + "work.chart.month6": "Jun", + "work.chart.month7": "Jul", + "work.chart.month8": "Aug", + "work.chart.month9": "Sep", + "work.chart.month10": "Oct", + "work.chart.finance": "Finance", + "work.chart.rd": "R&D", + "work.chart.ops": "Operations", + "work.chart.hr": "HR", "home.main.one": "first screen", "home.main.up": "Page Onload", "home.main.down": "Sampling PV", @@ -481,6 +519,29 @@ "home.roundtable.play": "Visible on the first screen", "home.roundtable.page": "Page Onload", "home.region.title": "Geographical distribution", + "menu.cloud.hello": "Hello World", + "menu.cloud.contracts": "Contract Management", + "menu.cloud.create": "Create Contract", + "menu.cloud.edit": "Edit Contract", + "menu.cloud.del": "Delete Contract", + "menu.cloud.name": "Project Name", + "menu.cloud.id": "Contract No", + "menu.cloud.customer": "Customer Name", + "menu.cloud.description": "Description", + "menu.cloud.updatedAt": "Creation Time", + "menu.cloud.editOpa": "Edits", + "menu.cloud.editDel": "Delete", + "menu.cloud.registerErro": "The project name does not meet the verification rules", + "menu.cloud.sure": "OK", + "menu.cloud.cancel": "Cancel", + "menu.cloud.tip": "The value can contain 3 to 255 characters, including Chinese characters, digits, hyphens (-), underscores (_), dots (.), slashes (/), parentheses (:) and colons (:) in Chinese and English formats, and periods (). The value can start with only English, Chinese characters, and digits.", + "menu.cloud.askDel": "Are you sure you want to delete the following", + "menu.cloud.askContracts": "Contract", + "menu.cloud.askInput": "Input", + "menu.cloud.askSure": "confirm", + "menu.cloud.verification": "Verification failed", + "menu.cloud.editpass": "If the verification is successful, the modification is successful", + "menu.cloud.delpass": "Deleted successfully", "menu.contracts.name": "The contract name is:", "http.error.TokenExpire": "Login expired, please log in again", "http.error.UserNotFound": "user does not exist", @@ -549,6 +610,8 @@ "userAdd.address": "Address", "userAdd.status": "Status", "menu.allPermission.info": "Permission", + "permissionInfo.add.success": "Permission added successfully", + "permissionInfo.edit.success": "Modified successfully", "permissionInfo.table.id": "ID", "permissionInfo.table.name": "Name", "permissionInfo.table.desc": "Desc", @@ -572,6 +635,7 @@ "roleInfo.table.operations.delete": "Delete", "roleInfo.modal.title.update": "Update Role", "roleInfo.modal.title.add": "Add Role", + "roleInfo.modal.add.success": "Successfully added role", "roleInfo.modal.input.id": "ID", "roleInfo.modal.input.name": "Name", "roleInfo.modal.input.desc": "Desc", @@ -583,6 +647,7 @@ "roleInfo.permissionTable.desc": "Description", "roleInfo.menuUpdate.confirm": "Confirm", "roleInfo.menuUpdate.cancel": "Cancel", + "roleInfo.table.bind": "Bind Directory", "menu.allMenu.info": "All Menu Info", "menuInfo.table.id": "ID", "menuInfo.table.name": "Name", @@ -597,13 +662,17 @@ "menuInfo.table.operations.info": "Detail", "menuInfo.table.operations.update": "Update", "menuInfo.table.operations.delete": "Delete", + "menuInfo.modal.add.success": "Menu created successfully", + "menuInfo.modal.edit.success": "The data has been successfully modified", "menuInfo.modal.title.info": "Menu Detail", "menuInfo.modal.title.update": "Update Menu", "menuInfo.modal.title.add": "Add Menu", "menuInfo.modal.title.confirm": "Are you sure you want to delete this data?", "menuInfo.modal.message.error": "ParentId is not as same as id", "menuInfo.modal.message.notNull": "Not Null", + "menuInfo.modal.tips.upd-id": "Before modifying the menu ID, please ensure that the front-end engineer is aware of this matter!", "menu.add.demo": "Menu Demo Page", + "menu.add.placeholder": "Please enter keywords to search", "exception.result.demo.description": "This is a new menu demo page!", "locale.add.btn": "Add Record", "locale.add.title": "Add Record", @@ -617,7 +686,9 @@ "lang.manage.title": "Mange Language", "lang.manage.remove": "Remove", "locale.remove": "Remove", - "locale.batchRemove": "Batch Remove" + "locale.batchRemove": "Batch Remove", + "component.error": "Component Error", + "component.error.contact": "Please contact the administrator or log in again" }, "zhCN": { "en-US": "English", @@ -641,6 +712,7 @@ "menu.menuPage.second": "二级菜单", "menu.menuPage.third": "菜单demo页", "menu.user": "个人中心", + "menu.systemManager": "系统管理", "menu.userManager": "用户管理", "menu.userManager.info": "查看用户", "menu.userManager.setting": "修改信息", @@ -660,6 +732,8 @@ "messageBox.userSettings": "用户设置", "messageBox.logout": "退出登录", "messageBox.updatePwd": "修改密码", + "message.delete.success": "删除成功", + "menu.cloud": "云服务能力展示", "menu.btn.confirm": "确认", "menu.i18n": "国际化管理", "theme.title.main": "个性化配置", @@ -1036,11 +1110,25 @@ "work.mock.week2": "零促实践(2周)", "work.mock.week3": "零促实践(3周)", "work.mock.network": "网络实践", + "work.mock.collectValue1": "制度学习视频课", + "work.mock.collectDescription1": "将考勤、晋升等制度转换为互动式视频课,快速了解,审批流程(如请假申请,主管审批,HR备案)", + "work.mock.collectHotLabel1": "热门", + "work.mock.collectLabel2": "请假申请流程模板", + "work.mock.collectValue2": "如何成为业务导师经典课程回顾", + "work.mock.collectDescription2": "按技能矩阵匹配领域专家,通过任务看板实时跟踪指导进展", + "work.mock.collectLabel3": "制定个人发展IDP", + "work.mock.collectValue3": "学员练习册", + "work.mock.collectDescription3": "构建同期新人PK榜单,按学习进度,任务完成度生成“成长力指数”排名", + "work.mock.collectLabel4": "经验共享积分池", + "work.mock.collectValue4": "教师网课平台", + "work.mock.collectDescription4": "技能训练、协作支持与动态反馈机制,构建全生命周期成长体系", + "work.mock.collectLabel5": "“认知-实训-实战”三阶模型", "work.mock.centralized": "新员工集中培训", "work.mock.hardware": "硬装实践", "work.index.learn": "学习规划", "work.index.coach": "学习辅导", "work.index.formalization": "学习转正", + "work.index.collect": "相关收藏功能", "work.index.practiced": "学习实践", "work.index.train": "学习集训", "work.index.Inquiry": "生活小助手", @@ -1083,7 +1171,28 @@ "work.index.Numbers": "个", "work.index.Person": "人", "work.index.net": "网络", + "work.index.questions1": "如何在员工论坛发表文章?", + "work.index.questions2": "登录失败怎么解决?", + "work.index.questions3": "无法登录员工之家平台怎么办?", + "work.index.questions4": "文档库与社区欢迎您", + "work.index.questions5": "无法登录网课服务平台怎么办?", + "work.index.questions6": "如何创建新项目工单?", "work.index.netonline": "在线咨询", + "work.chart.finished": "已完成", + "work.chart.month1": "1月", + "work.chart.month2": "2月", + "work.chart.month3": "3月", + "work.chart.month4": "4月", + "work.chart.month5": "5月", + "work.chart.month6": "6月", + "work.chart.month7": "7月", + "work.chart.month8": "8月", + "work.chart.month9": "9月", + "work.chart.month10": "10月", + "work.chart.finance": "财务部", + "work.chart.rd": "研发部", + "work.chart.ops": "运营部", + "work.chart.hr": "人力部", "home.main.one": "首屏可见", "home.main.up": "页面Onload", "home.main.down": "采样PV", @@ -1102,6 +1211,29 @@ "home.roundtable.play": "首屏可见", "home.roundtable.page": "页面Onload", "home.region.title": "地域分布", + "menu.cloud.hello": "Hello World", + "menu.cloud.contracts": "合同管理", + "menu.cloud.create": "创建合同", + "menu.cloud.edit": "编辑合同", + "menu.cloud.del": "删除合同", + "menu.cloud.name": "项目名称:", + "menu.cloud.id": "合同编号", + "menu.cloud.customer": "客户名称:", + "menu.cloud.description": "项目描述:", + "menu.cloud.updatedAt": "创建时间", + "menu.cloud.editOpa": "编辑", + "menu.cloud.editDel": "删除", + "menu.cloud.registerErro": "项目名称不满足校验规则", + "menu.cloud.sure": "确认", + "menu.cloud.cancel": "取消", + "menu.cloud.tip": "支持汉字、英文、数字、中划线、下划线、点、斜杠、中英文格式下的小括号和冒号、中文格式下的顿号,且只能以英文、汉字和数字开头,3-255个字符。", + "menu.cloud.askDel": "您确定要删除以下", + "menu.cloud.askContracts": "合同", + "menu.cloud.askInput": "输入", + "menu.cloud.askSure": "确认", + "menu.cloud.verification": "校验不通过", + "menu.cloud.editpass": "校验通过, 修改成功", + "menu.cloud.delpass": "删除成功", "menu.contracts.name": "合同名称为:", "http.error.TokenExpire": "登录过期,请重新登录", "http.error.UserNotFound": "用户不存在", @@ -1170,6 +1302,8 @@ "userAdd.address": "地址", "userAdd.status": "状态", "menu.allPermission.info": "查看权限", + "permissionInfo.add.success": "添加权限成功", + "permissionInfo.edit.success": "修改成功", "permissionInfo.table.id": "ID", "permissionInfo.table.name": "名称", "permissionInfo.table.desc": "权限", @@ -1193,6 +1327,7 @@ "roleInfo.table.operations.delete": "删除", "roleInfo.modal.title.update": "修改角色", "roleInfo.modal.title.add": "添加角色", + "roleInfo.modal.add.success": "添加角色成功", "roleInfo.modal.input.id": "ID", "roleInfo.modal.input.name": "名称", "roleInfo.modal.input.desc": "权限", @@ -1204,6 +1339,7 @@ "roleInfo.permissionTable.desc": "权限介绍", "roleInfo.menuUpdate.confirm": "确认修改", "roleInfo.menuUpdate.cancel": "取消", + "roleInfo.table.bind": "绑定目录", "menu.allMenu.info": "查看菜单", "menuInfo.table.id": "ID", "menuInfo.table.name": "名称", @@ -1218,13 +1354,17 @@ "menuInfo.table.operations.info": "查看", "menuInfo.table.operations.update": "修改", "menuInfo.table.operations.delete": "删除", + "menuInfo.modal.add.success": "创建菜单成功", + "menuInfo.modal.edit.success": "数据已修改成功", "menuInfo.modal.title.info": "查看菜单", "menuInfo.modal.title.update": "修改菜单", "menuInfo.modal.title.add": "添加菜单", "menuInfo.modal.title.confirm": "你确认要删除该数据吗?", "menuInfo.modal.message.error": "parentId不能和id相同", "menuInfo.modal.message.notNull": "不能为空", + "menuInfo.modal.tips.upd-id": "修改菜单ID前, 请确保前端工程师知晓此事!", "menu.add.demo": "菜单Demo页", + "menu.add.placeholder": "请输入关键字进行搜索", "exception.result.demo.description": "这是一个新增的菜单demo页", "locale.add.btn": "添加词条", "locale.add.title": "添加词条", @@ -1238,6 +1378,8 @@ "lang.manage.title": "管理语言", "lang.manage.remove": "删除", "locale.remove": "删除", - "locale.batchRemove": "批量删除" + "locale.batchRemove": "批量删除", + "component.error": "组件错误", + "component.error.contact": "请联系管理员或重新登录" } } diff --git a/template/tinyvue/src/mock/application.test.ts b/template/tinyvue/src/mock/application.test.ts new file mode 100644 index 00000000..27ec9ea5 --- /dev/null +++ b/template/tinyvue/src/mock/application.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test +import test from 'node:test' +import application from './application' +import { dispatchMockRequest } from './server' + +test('GET /api/application returns paginated card-list data', async () => { + const result = await dispatchMockRequest(application, { + method: 'get', + url: '/api/application?page=1&limit=10&keywords=&classify=all', + }) + + assert.equal(result.statusCode, 200) + const body = result.body as { + data: Array<{ + id: number + name: string + description: string + icon: string + classify: string + tag: Array<{ type: string, value: string }> + }> + total: number + } + + assert.ok(Array.isArray(body.data)) + assert.equal(body.data.length, 10) + assert.ok(body.total > 10) + assert.equal(typeof body.data[0].id, 'number') + assert.equal(body.data[0].name, 'TinyVue 组件库') + assert.equal(body.data[1].name, 'TinyEngine 低代码引擎') + assert.ok(Array.isArray(body.data[0].tag)) +}) + +test('GET /api/application filters by classify and keywords', async () => { + const design = await dispatchMockRequest(application, { + method: 'get', + url: '/api/application?page=1&limit=10&classify=design', + }) + const designBody = design.body as { data: Array<{ classify: string }>, total: number } + assert.equal(design.statusCode, 200) + assert.ok(designBody.total > 0) + assert.ok(designBody.data.every(item => item.classify === 'design')) + + const search = await dispatchMockRequest(application, { + method: 'get', + url: '/api/application?page=1&limit=10&keywords=TinyRobot&classify=all', + }) + const searchBody = search.body as { data: Array<{ name: string }>, total: number } + assert.equal(search.statusCode, 200) + assert.equal(searchBody.total, 1) + assert.match(searchBody.data[0].name, /TinyRobot/) +}) diff --git a/template/tinyvue/src/mock/application.ts b/template/tinyvue/src/mock/application.ts new file mode 100644 index 00000000..6a6970ce --- /dev/null +++ b/template/tinyvue/src/mock/application.ts @@ -0,0 +1,136 @@ +import type { MockMethod } from './server' + +interface ApplicationTag { + type: string + value: string +} + +interface ApplicationItem { + id: number + name: string + description: string + tag: ApplicationTag[] + classify: string + icon: string +} + +const applications: ApplicationItem[] = [ + { + name: 'TinyVue 组件库', + description: 'OpenTiny 企业级 UI 组件库,同时支持 Vue.js 2 与 Vue.js 3,以及 PC 与移动端。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-application-default.png', + }, + { + name: 'TinyEngine 低代码引擎', + description: 'TinyEngine低代码引擎,AI时代的智能低代码基座。', + tag: [{ type: 'success', value: '开发' }, { type: 'danger', value: '交互设计' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'TinyTheme 主题配置系统', + description: 'OpenTiny 主题配置系统,支持在线定制组件主题与视觉风格。', + tag: [{ type: 'danger', value: '交互设计' }, { type: 'warning', value: '视觉设计' }], + classify: 'design', + icon: 'card-list-application-default.png', + }, + { + name: 'OpenTiny Icons 图标库', + description: 'OpenTiny Design Icons 图标库,为组件与业务页面提供统一图标资源。', + tag: [{ type: 'warning', value: '视觉设计' }], + classify: 'design', + icon: 'card-list-application-default.png', + }, + { + name: 'TinyCLI 命令行工具', + description: '灵活可扩展的 OpenTiny 前端命令行工具,提供工程化套件与插件。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'TinyPro 中后台模板', + description: '基于 TinyVue 的前后端分离后台管理系统,支持在线配置菜单、路由、国际化,开箱即用。', + tag: [{ type: 'success', value: '开发' }, { type: 'warning', value: '上线' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'TinyEditor 富文本编辑器', + description: '基于 Quill 2.0 的富文本编辑器,扩展丰富模块与格式,功能强大、开箱即用。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'TinyRobot AI 对话组件库', + description: 'AI 对话组件库,提供丰富的 AI 交互组件,助力快速构建企业级 AI 应用。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-furion.png', + }, + { + name: 'TinyVue Mobile 移动端组件', + description: '基于 OpenTiny 设计规范的 Vue3 移动端组件库,支持 TypeScript、主题切换与国际化。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-application-default.png', + }, + { + name: 'TinyCharts 可视化图表库', + description: '前端可视化图表库,提供 40 多个图表组件,支持主题定制、响应式和无障碍,兼容 ECharts API。', + tag: [{ type: 'success', value: '开发' }, { type: 'warning', value: '视觉设计' }], + classify: 'dev', + icon: 'card-list-application-default.png', + }, + { + name: 'WebMCP SDK', + description: '前端 AI 与浏览器自动化工具包,通过 WebMCP + WebSkills 构建 AI 原生应用。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'GenUI SDK', + description: '面向 Vue、Angular 的 Generative UI SDK,帮助快速生成交互界面。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-application-default.png', + }, +].map((item, index) => ({ ...item, id: index + 1 })) + +function filterApplications(query: URLSearchParams) { + const page = Math.max(1, Number(query.get('page') ?? 1) || 1) + const limit = Math.max(1, Number(query.get('limit') ?? 10) || 10) + const keywords = (query.get('keywords') ?? '').trim().toLowerCase() + const classify = query.get('classify') ?? 'all' + + const filtered = applications.filter((item) => { + if (classify !== 'all' && item.classify !== classify) { + return false + } + if (!keywords) { + return true + } + const tagText = item.tag.map(tag => tag.value).join(' ') + return [item.name, item.description, tagText].some(field => + field.toLowerCase().includes(keywords), + ) + }) + + const start = (page - 1) * limit + return { + data: filtered.slice(start, start + limit), + total: filtered.length, + } +} + +export default [ + { + url: '/api/application', + method: 'get', + response: ({ query }) => filterApplications(query), + }, +] satisfies MockMethod[] diff --git a/template/tinyvue/src/mock/backend-data.ts b/template/tinyvue/src/mock/backend-data.ts new file mode 100644 index 00000000..54f1b039 --- /dev/null +++ b/template/tinyvue/src/mock/backend-data.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs' + +export interface MenuNode { + children: MenuNode[] + component: string + customIcon: string + id: number + label: string + locale: string + menuType: string + order: number + parentId: number | null + url: string +} + +const permissions = [ + { id: 1, name: '*', desc: '全部权限' }, + { id: 2, name: 'user::query', desc: '查询用户' }, + { id: 3, name: 'permission::get', desc: '查询权限' }, + { id: 4, name: 'role::query', desc: '查询角色' }, + { id: 5, name: 'menu::query', desc: '查询菜单' }, + { id: 6, name: 'i18n::query', desc: '查询国际化词条' }, +] + +let nextMenuId = 1 + +function menu( + label: string, + url: string, + component: string, + locale: string, + children: MenuNode[] = [], + customIcon = '', +): MenuNode { + const id = nextMenuId++ + children.forEach((child) => { + child.parentId = id + }) + return { + id, + label, + url, + component, + customIcon, + menuType: 'normal', + parentId: null, + order: id, + locale, + children, + } +} + +export const menuTree = [ + menu('Board', 'board', 'board/index', 'menu.board', [ + menu('Home', 'home', 'board/home/index', 'menu.home'), + menu('Work', 'work', 'board/work/index', 'menu.work'), + ], 'IconApplication'), + menu('List', 'list', 'list/index', 'menu.list', [ + menu('Table', 'table', 'list/search-table/index', 'menu.list.searchTable'), + menu('Card', 'card', 'list/card-list/index', 'menu.list.cardList'), + ], 'IconFiles'), + menu('Form', 'form', 'form/index', 'menu.form', [ + menu('Base', 'base', 'form/base/index', 'menu.form.base'), + menu('Step', 'step', 'form/step/index', 'menu.form.step'), + ], 'IconSetting'), + menu('Profile', 'profile', 'profile/index', 'menu.profile', [ + menu('Detail', 'detail', 'profile/detail/index', 'menu.profile.detail'), + ], 'IconFiletext'), + menu('Result', 'result', 'result/index', 'menu.result', [ + menu('Success', 'success', 'result/success/index', 'menu.result.success'), + menu('Error', 'error', 'result/error/index', 'menu.result.error'), + ], 'IconSuccessful'), + menu('Exception', 'exception', 'exception/index', 'menu.exception', [ + menu('403', '403', 'exception/403/index', 'menu.exception.403'), + menu('404', '404', 'exception/404/index', 'menu.exception.404'), + menu('500', '500', 'exception/500/index', 'menu.exception.500'), + ], 'IconCueL'), + menu('User', 'user', 'user/index', 'menu.user', [ + menu('Info', 'info', 'user/info/index', 'menu.user.info'), + ], 'IconUser'), + menu('SystemManager', '', 'menu/index', 'menu.systemManager', [ + menu('AllMenu', 'menu/allMenu', 'menu/info/index', 'menu.menu.info'), + menu('AllPermission', 'permission/allPermission', 'permission/info/index', 'menu.permission.info'), + menu('AllRole', 'role/allRole', 'role/info/index', 'menu.role.info'), + menu('AllInfo', 'userManager/allInfo', 'userManager/info/index', 'menu.userManager.info'), + menu('Local', 'locale', 'locale/index', 'menu.i18n'), + ], 'IconTotal'), +] + +export const localeTable = JSON.parse( + readFileSync(new URL('../locales.json', import.meta.url), 'utf8'), +) + +export function createBackendState() { + const role = { + id: 1, + name: 'admin', + permission: structuredClone(permissions), + menus: structuredClone(menuTree), + } + const user = { + id: '1', + name: 'admin', + email: 'admin@no-reply.com', + department: 'Tiny-Vue-Pro', + employeeType: 'social recruitment', + probationStart: '2021-04-19', + probationEnd: '2021-10-15', + probationDuration: '180', + protocolStart: '2021-04-19', + protocolEnd: '2024-04-19', + address: 'xian', + status: 'normal', + role: [role], + } + const localeRecords = Object.entries(localeTable).flatMap( + ([language, messages]: [string, any]) => Object.entries(messages).map( + ([key, content], index) => ({ + id: language === 'enUS' ? index + 1 : index + 10001, + key, + content, + lang: language === 'enUS' + ? { id: 1, name: 'enUS' } + : { id: 2, name: 'zhCN' }, + }), + ), + ) + + return { + credentials: new Map([['admin@no-reply.com', 'admin']]), + languages: [{ id: 1, name: 'enUS' }, { id: 2, name: 'zhCN' }], + localeTable: structuredClone(localeTable), + localeRecords, + menuTree: structuredClone(menuTree), + permissions: structuredClone(permissions), + roles: [role], + refreshTokens: new Map(), + tokens: new Map(), + users: [user], + } +} diff --git a/template/tinyvue/src/mock/backend.test.ts b/template/tinyvue/src/mock/backend.test.ts new file mode 100644 index 00000000..af9a5ab2 --- /dev/null +++ b/template/tinyvue/src/mock/backend.test.ts @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test +import test from 'node:test' +import { createBackendMocks } from './backend' +import { dispatchMockRequest } from './server' + +function createClient() { + const mocks = createBackendMocks() + + return async (method: string, url: string, body?: unknown, headers: Record = {}) => { + return dispatchMockRequest(mocks, { method, url, body, headers }) + } +} + +test('default credentials return the backend token-pair contract', async () => { + const request = createClient() + const response = await request('post', '/api/auth/login', { + email: 'admin@no-reply.com', + password: 'admin', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(Object.keys(response.body as object).sort(), [ + 'accessToken', + 'accessTokenTTL', + 'refreshToken', + 'refreshTokenTTL', + ]) +}) + +test('invalid credentials are rejected instead of creating a fake session', async () => { + const request = createClient() + const response = await request('post', '/api/auth/login', { + email: 'admin@no-reply.com', + password: 'wrong-password', + }) + + assert.equal(response.statusCode, 401) + assert.deepEqual(response.body, { message: '邮箱或密码错误' }) +}) + +test('bootstrap endpoints expose the current frontend contract', async () => { + const request = createClient() + const login = await request('post', '/api/auth/login', { + email: 'admin@no-reply.com', + password: 'admin', + }) + const token = (login.body as { accessToken: string }).accessToken + const headers = { authorization: `Bearer ${token}` } + + const user = await request('get', '/api/user/info/admin@no-reply.com', undefined, headers) + const currentUser = await request('get', '/api/user/info/', undefined, headers) + const role = await request('get', '/api/role/info/1', undefined, headers) + const menu = await request('get', '/api/menu/role/admin@no-reply.com', undefined, headers) + const languages = await request('get', '/api/lang', undefined, headers) + const localeTable = await request('get', '/api/i18/format', undefined, headers) + + assert.equal((user.body as { email: string }).email, 'admin@no-reply.com') + assert.equal((currentUser.body as { email: string }).email, 'admin@no-reply.com') + assert.equal((role.body as { name: string }).name, 'admin') + assert.ok((menu.body as unknown[]).length > 0) + assert.deepEqual(languages.body, [ + { id: 1, name: 'enUS' }, + { id: 2, name: 'zhCN' }, + ]) + assert.ok((localeTable.body as { zhCN: object }).zhCN) +}) + +test('permission mutations are visible in following queries', async () => { + const request = createClient() + const created = await request('post', '/api/permission', { + name: 'demo::read', + desc: 'Demo permission', + }) + const page = await request('get', '/api/permission?page=1&limit=10') + + assert.equal(created.statusCode, 200) + assert.ok( + (page.body as { items: { id: number }[] }).items.some( + item => item.id === (created.body as { id: number }).id, + ), + ) + + const updated = await request('patch', '/api/permission', { + ...(created.body as object), + desc: 'Updated permission', + }) + assert.equal((updated.body as { desc: string }).desc, 'Updated permission') + + const removed = await request( + 'delete', + `/api/permission/${(created.body as { id: number }).id}`, + ) + assert.deepEqual(removed.body, created.body) +}) + +test('language mutations use dynamic path parameters', async () => { + const request = createClient() + const updated = await request('patch', '/api/lang/1', { name: 'en-US' }) + const languages = await request('get', '/api/lang') + + assert.equal((updated.body as { name: string }).name, 'en-US') + assert.equal((languages.body as { name: string }[])[0].name, 'en-US') +}) + +test('system management list endpoints return the shapes consumed by views', async () => { + const request = createClient() + const users = await request('get', '/api/user?page=1&limit=10') + const roles = await request('get', '/api/role/detail?page=1&limit=10') + const permissions = await request('get', '/api/permission') + const menus = await request('get', '/api/menu') + const locales = await request('get', '/api/i18?page=1&limit=10') + + assert.ok(Array.isArray((users.body as { items: unknown[] }).items)) + assert.ok(Array.isArray((roles.body as { roleInfo: { items: unknown[] } }).roleInfo.items)) + assert.ok(Array.isArray(permissions.body)) + assert.ok(Array.isArray(menus.body)) + assert.ok(Array.isArray((locales.body as { items: unknown[] }).items)) +}) + +test('nested menu create, update and delete persist in the menu tree', async () => { + const request = createClient() + const initial = (await request('get', '/api/menu')).body as any[] + const board = initial.find(item => item.label === 'Board') + const created = await request('post', '/api/menu', { + name: 'Demo', + path: 'demo', + component: 'board/demo/index', + icon: '', + menuType: 'normal', + parentId: board.id, + order: 99, + locale: 'menu.demo', + }) + + await request('patch', '/api/menu', { + ...(created.body as object), + name: 'DemoUpdated', + path: 'demo-updated', + }) + const updated = (await request('get', '/api/menu')).body as any[] + assert.equal( + updated.find(item => item.id === board.id).children.find( + item => item.id === (created.body as { id: number }).id, + ).label, + 'DemoUpdated', + ) + + await request( + 'delete', + `/api/menu?id=${(created.body as { id: number }).id}&parentId=${board.id}`, + ) + const afterDelete = (await request('get', '/api/menu')).body as any[] + assert.equal( + afterDelete.find(item => item.id === board.id).children.some( + item => item.id === (created.body as { id: number }).id, + ), + false, + ) +}) + +test('role menu assignments control the menu returned for its users', async () => { + const request = createClient() + const menus = (await request('get', '/api/menu')).body as any[] + const list = menus.find(item => item.label === 'List') + const table = list.children.find(item => item.label === 'Table') + + await request('patch', '/api/role', { + id: 1, + menuIds: [list.id, table.id], + }) + const assigned = (await request( + 'get', + '/api/menu/role/admin@no-reply.com', + )).body as any[] + + assert.deepEqual(assigned.map(item => item.label), ['List']) + assert.deepEqual(assigned[0].children.map(item => item.label), ['Table']) +}) + +test('updating user roleIds changes the user role and assigned menu', async () => { + const request = createClient() + const menus = (await request('get', '/api/menu')).body as any[] + const list = menus.find(item => item.label === 'List') + const role = await request('post', '/api/role', { + name: 'list-reader', + permissionIds: [2], + menuIds: [list.id], + }) + + await request('patch', '/api/user/update', { + email: 'admin@no-reply.com', + roleIds: [(role.body as { id: number }).id], + }) + const assigned = (await request( + 'get', + '/api/menu/role/admin@no-reply.com', + )).body as any[] + + assert.deepEqual(assigned.map(item => item.label), ['List']) +}) + +test('referenced permissions, roles and languages cannot be deleted', async () => { + const request = createClient() + + assert.equal((await request('delete', '/api/permission/1')).statusCode, 409) + assert.equal((await request('delete', '/api/role/1')).statusCode, 409) + assert.equal((await request('delete', '/api/lang/1')).statusCode, 409) +}) + +test('user list applies name, email and role filters', async () => { + const request = createClient() + await request('post', '/api/user/reg', { + email: 'reader@example.com', + password: 'reader-password', + name: 'Reader', + roleIds: [1], + }) + + const match = await request( + 'get', + '/api/user?page=1&limit=10&name=Read&email=reader%40example.com&role=1', + ) + const miss = await request( + 'get', + '/api/user?page=1&limit=10&email=missing%40example.com&role=1', + ) + + assert.deepEqual( + (match.body as { items: { email: string }[] }).items.map(item => item.email), + ['reader@example.com'], + ) + assert.equal((miss.body as { items: unknown[] }).items.length, 0) +}) + +test('registered and password-updated users authenticate with current credentials', async () => { + const request = createClient() + await request('post', '/api/user/reg', { + username: 'reader@example.com', + password: 'reader-password', + }) + const login = await request('post', '/api/auth/login', { + email: 'reader@example.com', + password: 'reader-password', + }) + assert.equal(login.statusCode, 200) + + const token = (login.body as { accessToken: string }).accessToken + const user = await request( + 'get', + '/api/user/info/', + undefined, + { authorization: `Bearer ${token}` }, + ) + assert.equal((user.body as { email: string }).email, 'reader@example.com') + const roleId = (user.body as { role: { id: number }[] }).role[0]?.id + assert.ok(roleId) + assert.equal((await request('get', `/api/role/info/${roleId}`)).statusCode, 200) + + await request('patch', '/api/user/admin/updatePwd', { + email: 'reader@example.com', + newPassword: 'updated-password', + }) + assert.equal((await request('post', '/api/auth/login', { + email: 'reader@example.com', + password: 'reader-password', + })).statusCode, 401) + assert.equal((await request('post', '/api/auth/login', { + email: 'reader@example.com', + password: 'updated-password', + })).statusCode, 200) +}) + +test('locale updates keep records, language filters and formatted output synchronized', async () => { + const request = createClient() + const allRecords = await request('get', '/api/i18?page=1&limit=0&all=1') + assert.ok((allRecords.body as { items: unknown[] }).items.length > 100) + const created = await request('post', '/api/i18', { + key: 'demo.title', + content: 'Demo', + lang: 1, + }) + await request('patch', `/api/i18/${(created.body as { id: number }).id}`, { + key: 'demo.heading', + content: '演示', + lang: 2, + }) + + const zhRecords = await request('get', '/api/i18?page=1&limit=2000&lang=2') + const formatted = await request('get', '/api/i18/format') + const item = (zhRecords.body as { items: any[] }).items.find( + record => record.id === (created.body as { id: number }).id, + ) + assert.deepEqual(item.lang, { id: 2, name: 'zhCN' }) + assert.equal((formatted.body as any).enUS['demo.title'], undefined) + assert.equal((formatted.body as any).zhCN['demo.heading'], '演示') + + await request('delete', `/api/i18/${item.id}`) + const afterDelete = await request('get', '/api/i18/format') + assert.equal((afterDelete.body as any).zhCN['demo.heading'], undefined) +}) diff --git a/template/tinyvue/src/mock/backend.ts b/template/tinyvue/src/mock/backend.ts new file mode 100644 index 00000000..40b3bf29 --- /dev/null +++ b/template/tinyvue/src/mock/backend.ts @@ -0,0 +1,628 @@ +import type { MenuNode } from './backend-data' +import type { MockMethod } from './server' +import { createBackendState } from './backend-data' +import { mockHttpResponse } from './server' + +function nextId(items: { id: number }[]) { + return Math.max(0, ...items.map(item => item.id)) + 1 +} + +function paginate(items: T[], query: URLSearchParams) { + const page = Math.max(1, Number(query.get('page') ?? 1)) + const limit = Math.max(1, Number(query.get('limit') ?? 10)) + const start = (page - 1) * limit + return { + items: items.slice(start, start + limit), + meta: { + currentPage: page, + itemCount: Math.min(limit, Math.max(0, items.length - start)), + itemsPerPage: limit, + totalItems: items.length, + totalPages: Math.ceil(items.length / limit), + }, + } +} + +function includesFilter(value: string, filter: string | null) { + return !filter || value.toLowerCase().includes(filter.replaceAll('%', '').toLowerCase()) +} + +function flattenMenus(nodes: MenuNode[]): MenuNode[] { + return nodes.flatMap(node => [node, ...flattenMenus(node.children)]) +} + +function selectMenus(nodes: MenuNode[], ids: Set): MenuNode[] { + return nodes.flatMap((node) => { + const children = selectMenus(node.children, ids) + if (!ids.has(node.id) && !children.length) { + return [] + } + return [{ ...node, children }] + }) +} + +function findMenuLocation(nodes: MenuNode[], id: number): { + node: MenuNode + siblings: MenuNode[] +} | null { + for (const node of nodes) { + if (node.id === id) { + return { node, siblings: nodes } + } + const child = findMenuLocation(node.children, id) + if (child) { + return child + } + } + return null +} + +function bearerToken(headers: Record | import('node:http').IncomingHttpHeaders) { + const value = headers.authorization + return Array.isArray(value) ? value[0]?.replace(/^Bearer\s+/i, '') : value?.replace(/^Bearer\s+/i, '') +} + +export function createBackendMocks(): MockMethod[] { + const state = createBackendState() + + const syncRoleMenus = (roleMenuIds: Map>) => { + state.roles.forEach((role) => { + role.menus = selectMenus(state.menuTree, roleMenuIds.get(role.id) ?? new Set()) + }) + } + + const snapshotRoleMenuIds = () => new Map( + state.roles.map(role => [role.id, new Set(flattenMenus(role.menus).map(item => item.id))]), + ) + + const removeFormattedLocale = (record: any) => { + delete state.localeTable[record.lang.name]?.[record.key] + } + + const writeFormattedLocale = (record: any) => { + state.localeTable[record.lang.name] ??= {} + state.localeTable[record.lang.name][record.key] = record.content + } + + const authenticatedEmail = ( + headers: Record | import('node:http').IncomingHttpHeaders, + ) => { + const token = bearerToken(headers) + return token ? state.tokens.get(token) : undefined + } + + return [ + { + url: '/api/auth/login', + method: 'post', + response: ({ body }) => { + if (!body?.email || state.credentials.get(body.email) !== body?.password) { + return mockHttpResponse(401, { message: '邮箱或密码错误' }) + } + const accessToken = `mock-access-token:${body.email}` + const refreshToken = `mock-refresh-token:${body.email}` + state.tokens.set(accessToken, body.email) + state.refreshTokens.set(refreshToken, body.email) + return { + accessToken, + accessTokenTTL: 3600, + refreshToken, + refreshTokenTTL: 86400, + } + }, + }, + { + url: '/api/auth/token/refresh', + method: 'post', + response: ({ body }) => { + const email = state.refreshTokens.get(body?.token) + if (!email) { + return mockHttpResponse(401, { message: '刷新令牌无效' }) + } + const accessToken = `mock-access-token:${email}` + const refreshToken = `mock-refresh-token:${email}` + state.tokens.set(accessToken, email) + return { + accessToken, + accessTokenTTL: 3600, + refreshToken, + refreshTokenTTL: 86400, + } + }, + }, + { + url: '/api/auth/logout', + method: 'post', + response: ({ headers }) => { + const token = bearerToken(headers) + if (token) { + state.tokens.delete(token) + } + return true + }, + }, + { + url: '/api/user/info/:email?', + response: ({ headers, params }) => { + const authenticatedUser = authenticatedEmail(headers) + if (!authenticatedUser) { + return mockHttpResponse(401, { message: '请先登录' }) + } + const email = params.email || authenticatedUser + const user = state.users.find(item => item.email === email) + return user ?? mockHttpResponse(404, { message: '用户不存在' }) + }, + }, + { + url: '/api/role/info/:id', + response: ({ params }) => { + const role = state.roles.find(item => item.id === Number(params.id)) + return role ?? mockHttpResponse(404, { message: '角色不存在' }) + }, + }, + { + url: '/api/menu/role/:email', + response: ({ params }) => { + const user = state.users.find(item => item.email === params.email) + if (!user) { + return mockHttpResponse(404, { message: '用户不存在' }) + } + const ids = new Set( + user.role.flatMap(role => flattenMenus(role.menus).map(item => item.id)), + ) + return selectMenus(state.menuTree, ids) + }, + }, + { + url: '/api/lang', + response: () => state.languages, + }, + { + url: '/api/lang', + method: 'post', + response: ({ body }) => { + const language = { id: nextId(state.languages), name: body.name } + state.languages.push(language) + state.localeTable[language.name] = {} + return language + }, + }, + { + url: '/api/lang/:id', + method: 'patch', + response: ({ body, params }) => { + const language = state.languages.find(item => item.id === Number(params.id)) + if (!language) { + return mockHttpResponse(404, { message: '语言不存在' }) + } + const oldName = language.name + Object.assign(language, body) + if (oldName !== language.name) { + state.localeTable[language.name] = state.localeTable[oldName] ?? {} + delete state.localeTable[oldName] + state.localeRecords + .filter(item => item.lang.id === language.id) + .forEach((item) => { + item.lang.name = language.name + }) + } + return language + }, + }, + { + url: '/api/lang/:id', + method: 'delete', + response: ({ params }) => { + const index = state.languages.findIndex(item => item.id === Number(params.id)) + if (index < 0) { + return mockHttpResponse(404, { message: '语言不存在' }) + } + if (state.localeRecords.some(item => item.lang.id === Number(params.id))) { + return mockHttpResponse(409, { message: '语言仍被国际化词条引用' }) + } + const language = state.languages.splice(index, 1)[0] + delete state.localeTable[language.name] + state.localeRecords = state.localeRecords.filter(item => item.lang.id !== language.id) + return language + }, + }, + { + url: '/api/i18/format', + response: ({ query }) => { + const language = query.get('lang') + if (!language) { + return state.localeTable + } + return { [language]: state.localeTable[language] ?? {} } + }, + }, + { + url: '/api/permission', + response: ({ query }) => { + const filtered = state.permissions.filter(item => includesFilter(item.name, query.get('name'))) + return query.has('page') || query.has('limit') ? paginate(filtered, query) : filtered + }, + }, + { + url: '/api/permission', + method: 'post', + response: ({ body }) => { + const permission = { id: nextId(state.permissions), name: body.name, desc: body.desc ?? '' } + state.permissions.push(permission) + return permission + }, + }, + { + url: '/api/permission', + method: 'patch', + response: ({ body }) => { + const permission = state.permissions.find(item => item.id === Number(body.id)) + if (!permission) { + return mockHttpResponse(404, { message: '权限不存在' }) + } + Object.assign(permission, body) + return permission + }, + }, + { + url: '/api/permission/:id', + method: 'delete', + response: ({ params }) => { + const index = state.permissions.findIndex(item => item.id === Number(params.id)) + if (index < 0) { + return mockHttpResponse(404, { message: '权限不存在' }) + } + if (state.roles.some(role => role.permission.some(item => item.id === Number(params.id)))) { + return mockHttpResponse(409, { message: '权限仍被角色引用' }) + } + return state.permissions.splice(index, 1)[0] + }, + }, + { + url: '/api/user', + response: ({ query }) => { + const roleIds = new Set( + (query.get('role') ?? '').split(',').filter(Boolean).map(Number), + ) + const filtered = state.users.filter(item => ( + includesFilter(item.name, query.get('name')) + && includesFilter(item.email, query.get('email')) + && (!roleIds.size || item.role.some(role => roleIds.has(role.id))) + )) + return paginate(filtered, query) + }, + }, + { + url: '/api/user/reg', + method: 'post', + response: ({ body }) => { + const email = body.email ?? body.username + if (!email || state.users.some(item => item.email === email)) { + return mockHttpResponse(409, { message: '用户已存在或邮箱为空' }) + } + const { password, username: _username, ...userData } = body + const roleIds = body.roleIds ?? (state.roles[0] ? [state.roles[0].id] : []) + const user = { + ...state.users[0], + ...userData, + email, + name: body.name ?? email, + id: String(nextId(state.users.map(item => ({ id: Number(item.id) })))), + role: state.roles.filter(role => roleIds.includes(role.id)), + } + state.users.push(user) + state.credentials.set(email, password) + return user + }, + }, + { + url: '/api/user/update', + method: 'patch', + response: ({ body }) => { + const user = state.users.find(item => item.email === body.email) + if (!user) { + return mockHttpResponse(404, { message: '用户不存在' }) + } + const { roleIds, ...userInfo } = body + Object.assign(user, userInfo) + if (roleIds) { + user.role = state.roles.filter(role => roleIds.includes(role.id)) + } + return user + }, + }, + { + url: '/api/user/:email', + method: 'delete', + response: ({ params }) => { + const index = state.users.findIndex(item => item.email === params.email) + if (index < 0) { + return mockHttpResponse(404, { message: '用户不存在' }) + } + const user = state.users.splice(index, 1)[0] + state.credentials.delete(user.email) + return user + }, + }, + { + url: '/api/user/batch', + method: 'post', + response: ({ body }) => { + const emails = Array.isArray(body) ? body : [] + const removed = state.users.filter(item => emails.includes(item.email)) + state.users = state.users.filter(item => !emails.includes(item.email)) + removed.forEach(user => state.credentials.delete(user.email)) + return removed + }, + }, + { + url: '/api/user/admin/updatePwd', + method: 'patch', + response: ({ body }) => { + if (!state.credentials.has(body.email)) { + return mockHttpResponse(404, { message: '用户不存在' }) + } + state.credentials.set(body.email, body.newPassword) + return true + }, + }, + { + url: '/api/user/updatePwd', + method: 'patch', + response: ({ body }) => { + if (!body.email || state.credentials.get(body.email) !== body.oldPassword) { + return mockHttpResponse(401, { message: '旧密码错误' }) + } + state.credentials.set(body.email, body.newPassword) + return true + }, + }, + { + url: '/api/role', + response: () => state.roles, + }, + { + url: '/api/role/detail', + response: ({ query }) => { + const filtered = state.roles.filter(item => includesFilter(item.name, query.get('name'))) + return { + roleInfo: paginate(filtered, query), + menuTree: filtered.map(role => role.menus), + } + }, + }, + { + url: '/api/role', + method: 'post', + response: ({ body }) => { + const role = { + id: nextId(state.roles), + name: body.name, + permission: state.permissions.filter(item => (body.permissionIds ?? []).includes(item.id)), + menus: selectMenus(state.menuTree, new Set(body.menuIds ?? [])), + } + state.roles.push(role) + return role + }, + }, + { + url: '/api/role', + method: 'patch', + response: ({ body }) => { + const role = state.roles.find(item => item.id === Number(body.id)) + if (!role) { + return mockHttpResponse(404, { message: '角色不存在' }) + } + if (body.name) { + role.name = body.name + } + if (body.permissionIds) { + role.permission = state.permissions.filter(item => body.permissionIds.includes(item.id)) + } + if (body.menuIds) { + role.menus = selectMenus(state.menuTree, new Set(body.menuIds)) + } + return role + }, + }, + { + url: '/api/role/:id', + method: 'delete', + response: ({ params }) => { + const index = state.roles.findIndex(item => item.id === Number(params.id)) + if (index < 0) { + return mockHttpResponse(404, { message: '角色不存在' }) + } + if (state.users.some(user => user.role.some(role => role.id === Number(params.id)))) { + return mockHttpResponse(409, { message: '角色仍被用户引用' }) + } + return state.roles.splice(index, 1)[0] + }, + }, + { + url: '/api/menu', + response: () => state.menuTree, + }, + { + url: '/api/menu', + method: 'post', + response: ({ body }) => { + const roleMenuIds = snapshotRoleMenuIds() + const item = { + id: nextId(flattenMenus(state.menuTree)), + label: body.name, + url: body.path, + component: body.component, + customIcon: body.icon ?? '', + menuType: body.menuType ?? 'normal', + parentId: body.parentId ?? null, + order: body.order ?? 0, + locale: body.locale, + children: [], + } + if (item.parentId === null) { + state.menuTree.push(item) + } + else { + const parent = findMenuLocation(state.menuTree, Number(item.parentId)) + if (!parent) { + return mockHttpResponse(404, { message: '父菜单不存在' }) + } + parent.node.children.push(item) + } + syncRoleMenus(roleMenuIds) + return item + }, + }, + { + url: '/api/menu', + method: 'patch', + response: ({ body }) => { + const roleMenuIds = snapshotRoleMenuIds() + const location = findMenuLocation(state.menuTree, Number(body.id)) + if (!location) { + return mockHttpResponse(404, { message: '菜单不存在' }) + } + const index = location.siblings.indexOf(location.node) + location.siblings.splice(index, 1) + Object.assign(location.node, { + label: body.name ?? location.node.label, + url: body.path ?? location.node.url, + component: body.component ?? location.node.component, + customIcon: body.icon ?? location.node.customIcon, + menuType: body.menuType ?? location.node.menuType, + parentId: body.parentId ?? null, + order: body.order ?? location.node.order, + locale: body.locale ?? location.node.locale, + }) + if (location.node.parentId === null) { + state.menuTree.push(location.node) + } + else { + const parent = findMenuLocation(state.menuTree, Number(location.node.parentId)) + if (!parent) { + location.siblings.splice(index, 0, location.node) + return mockHttpResponse(404, { message: '父菜单不存在' }) + } + parent.node.children.push(location.node) + } + syncRoleMenus(roleMenuIds) + return location.node + }, + }, + { + url: '/api/menu', + method: 'delete', + response: ({ query }) => { + const roleMenuIds = snapshotRoleMenuIds() + const location = findMenuLocation(state.menuTree, Number(query.get('id'))) + if (!location) { + return mockHttpResponse(404, { message: '菜单不存在' }) + } + const index = location.siblings.indexOf(location.node) + const removed = location.siblings.splice(index, 1)[0] + const parentId = Number(query.get('parentId')) + const target = parentId === -1 + ? state.menuTree + : findMenuLocation(state.menuTree, parentId)?.node.children + if (target) { + removed.children.forEach((child) => { + child.parentId = parentId === -1 ? null : parentId + target.push(child) + }) + } + roleMenuIds.forEach(ids => ids.delete(removed.id)) + syncRoleMenus(roleMenuIds) + return removed + }, + }, + { + url: '/api/i18', + response: ({ query }) => { + const languageIds = new Set( + (query.get('lang') ?? '').split(',').filter(Boolean).map(Number), + ) + const records = state.localeRecords.filter(item => ( + includesFilter(item.key, query.get('key')) + && includesFilter(String(item.content), query.get('content')) + && (!languageIds.size || languageIds.has(item.lang.id)) + )) + if (query.get('all') && query.get('all') !== '0') { + const allQuery = new URLSearchParams({ + page: '1', + limit: String(Math.max(1, records.length)), + }) + return paginate(records, allQuery) + } + return paginate(records, query) + }, + }, + { + url: '/api/i18', + method: 'post', + response: ({ body }) => { + const language = state.languages.find(item => item.id === Number(body.lang)) + if (!language) { + return mockHttpResponse(404, { message: '语言不存在' }) + } + const record = { + id: nextId(state.localeRecords), + key: body.key, + content: body.content, + lang: language, + } + state.localeRecords.push(record) + writeFormattedLocale(record) + return record + }, + }, + { + url: '/api/i18/batch', + method: 'post', + response: ({ body }) => { + const ids = Array.isArray(body) ? body : body.ids ?? [] + const removed = state.localeRecords.filter(item => ids.includes(item.id) || ids.includes(String(item.id))) + removed.forEach(removeFormattedLocale) + state.localeRecords = state.localeRecords.filter(item => !removed.includes(item)) + return removed + }, + }, + { + url: '/api/i18/:id', + method: 'patch', + response: ({ body, params }) => { + const record = state.localeRecords.find(item => item.id === Number(params.id)) + if (!record) { + return mockHttpResponse(404, { message: '词条不存在' }) + } + const language = body.lang === undefined + ? record.lang + : state.languages.find(item => item.id === Number(body.lang)) + if (!language) { + return mockHttpResponse(404, { message: '语言不存在' }) + } + removeFormattedLocale(record) + Object.assign(record, { + content: body.content ?? record.content, + key: body.key ?? record.key, + lang: language, + }) + writeFormattedLocale(record) + return record + }, + }, + { + url: '/api/i18/:id', + method: 'delete', + response: ({ params }) => { + const index = state.localeRecords.findIndex(item => item.id === Number(params.id)) + if (index < 0) { + return mockHttpResponse(404, { message: '词条不存在' }) + } + const record = state.localeRecords.splice(index, 1)[0] + removeFormattedLocale(record) + return record + }, + }, + ] +} diff --git a/template/tinyvue/src/mock/board.test.ts b/template/tinyvue/src/mock/board.test.ts new file mode 100644 index 00000000..b5ff9537 --- /dev/null +++ b/template/tinyvue/src/mock/board.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test +import test from 'node:test' +import board from './board' +import { dispatchMockRequest } from './server' + +test('getrtrain returns collect cards with i18n fields for learn-traini', async () => { + const result = await dispatchMockRequest(board, { + method: 'get', + url: '/api/user/getrtrain', + }) + + assert.equal(result.statusCode, 200) + const options = (result.body as { data: { options: Array<{ + value: string + description: string + label1: string + label2: string + isNews?: boolean + }> } }).data.options + + assert.equal(options.length, 4) + assert.deepEqual(options[0], { + value: 'work.mock.collectValue1', + description: 'work.mock.collectDescription1', + label1: 'work.mock.collectHotLabel1', + label2: 'work.mock.collectLabel2', + }) + assert.equal(options[3].value, 'work.mock.collectValue4') + assert.equal(options[3].isNews, true) + for (const item of options) { + assert.ok(item.description) + assert.ok(item.label1) + assert.ok(item.label2) + } +}) diff --git a/template/tinyvue/src/mock/board.ts b/template/tinyvue/src/mock/board.ts index e136149c..082c41b7 100644 --- a/template/tinyvue/src/mock/board.ts +++ b/template/tinyvue/src/mock/board.ts @@ -38,16 +38,29 @@ const initData1 = Mock.mock({ const initData2 = Mock.mock({ options: [ { - value: '1', - label: 'work.mock.network', + value: 'work.mock.collectValue1', + description: 'work.mock.collectDescription1', + label1: 'work.mock.collectHotLabel1', + label2: 'work.mock.collectLabel2', }, { - value: '2', - label: 'work.mock.centralized', + value: 'work.mock.collectValue2', + description: 'work.mock.collectDescription2', + label1: 'work.mock.collectHotLabel1', + label2: 'work.mock.collectLabel3', }, { - value: '3', - label: 'work.mock.hardware', + value: 'work.mock.collectValue3', + description: 'work.mock.collectDescription3', + label1: 'work.mock.collectHotLabel1', + label2: 'work.mock.collectLabel4', + }, + { + value: 'work.mock.collectValue4', + description: 'work.mock.collectDescription4', + label1: 'work.mock.collectHotLabel1', + label2: 'work.mock.collectLabel5', + isNews: true, }, ], }) diff --git a/template/tinyvue/src/mock/index.ts b/template/tinyvue/src/mock/index.ts index c4c8eef7..aac58948 100644 --- a/template/tinyvue/src/mock/index.ts +++ b/template/tinyvue/src/mock/index.ts @@ -1,12 +1,20 @@ -import { createMockServer } from '@gaonengwww/mock-server' import froms from '../views/form/step/mock' +import application from './application' +import { createBackendMocks } from './backend' import board from './board' import list from './list' import profile from './profile' +import { startMockServer } from './server' import user from './user' -const mockData = [...list, ...froms, ...profile, ...board, ...user] as any +const mockData = [ + ...createBackendMocks(), + ...list, + ...froms, + ...profile, + ...board, + ...user, + ...application, +] as any -createMockServer({ - mocks: mockData, -}) +startMockServer(mockData) diff --git a/template/tinyvue/src/mock/server.test.ts b/template/tinyvue/src/mock/server.test.ts new file mode 100644 index 00000000..3c2a9544 --- /dev/null +++ b/template/tinyvue/src/mock/server.test.ts @@ -0,0 +1,24 @@ +import type { AddressInfo } from 'node:net' +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test +import test from 'node:test' +import { createBackendMocks } from './backend' +import { startMockServer } from './server' + +test('mock handlers are served through the real HTTP boundary', async (context) => { + const server = await startMockServer(createBackendMocks(), { port: 0 }) + context.after(() => server.close()) + const { address, port } = server.address() as AddressInfo + + const response = await fetch(`http://${address}:${port}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + email: 'admin@no-reply.com', + password: 'admin', + }), + }) + + assert.equal(response.status, 200) + assert.equal(typeof (await response.json()).accessToken, 'string') +}) diff --git a/template/tinyvue/src/mock/server.ts b/template/tinyvue/src/mock/server.ts new file mode 100644 index 00000000..776a5bbb --- /dev/null +++ b/template/tinyvue/src/mock/server.ts @@ -0,0 +1,141 @@ +import type { IncomingHttpHeaders } from 'node:http' +import { Buffer } from 'node:buffer' +import { createServer } from 'node:http' + +export interface MockRequest { + method: string + url: string + body?: unknown + headers?: Record | IncomingHttpHeaders +} + +export interface MockHandlerContext { + body: any + headers: Record | IncomingHttpHeaders + params: Record + query: URLSearchParams +} + +export interface MockMethod { + method?: string + url: string + response: (context: MockHandlerContext) => unknown | Promise +} + +export interface MockDispatchResult { + body: unknown + statusCode: number +} + +class MockHttpResponse { + constructor( + readonly statusCode: number, + readonly body: unknown, + ) {} +} + +export function mockHttpResponse(statusCode: number, body: unknown) { + return new MockHttpResponse(statusCode, body) +} + +function matchPath(pattern: string, pathname: string) { + const names: string[] = [] + const segments = pattern.split('/').filter(Boolean) + let source = '^' + + for (const segment of segments) { + if (segment.startsWith(':')) { + const optional = segment.endsWith('?') + const name = segment.slice(1, optional ? -1 : undefined) + names.push(name) + source += optional ? '(?:/([^/]+))?' : '/([^/]+)' + } + else { + source += `/${segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}` + } + } + + const match = pathname.match(new RegExp(`${source}/?$`)) + if (!match) { + return null + } + + return Object.fromEntries( + names.map((name, index) => [name, decodeURIComponent(match[index + 1] ?? '')]), + ) +} + +export async function dispatchMockRequest( + mocks: MockMethod[], + request: MockRequest, +): Promise { + const url = new URL(request.url, 'http://mock.local') + const method = request.method.toLowerCase() + + for (const mock of mocks) { + if ((mock.method ?? 'get').toLowerCase() !== method) { + continue + } + const params = matchPath(mock.url, url.pathname) + if (!params) { + continue + } + + const body = await mock.response({ + body: request.body, + headers: request.headers ?? {}, + params, + query: url.searchParams, + }) + if (body instanceof MockHttpResponse) { + return { body: body.body, statusCode: body.statusCode } + } + return { body, statusCode: 200 } + } + + return { body: { message: 'Mock route not found' }, statusCode: 404 } +} + +async function readBody(request: AsyncIterable) { + const chunks: Buffer[] = [] + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)) + } + if (!chunks.length) { + return undefined + } + const content = Buffer.concat(chunks).toString('utf8') + return content ? JSON.parse(content) : undefined +} + +export function startMockServer( + mocks: MockMethod[], + { hostname = '127.0.0.1', port = 8848 } = {}, +) { + const server = createServer(async (request, response) => { + try { + const result = await dispatchMockRequest(mocks, { + method: request.method ?? 'get', + url: request.url ?? '/', + body: await readBody(request), + headers: request.headers, + }) + response.writeHead(result.statusCode, { 'content-type': 'application/json' }) + response.end(JSON.stringify(result.body)) + } + catch (error) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + message: error instanceof Error ? error.message : 'Mock server error', + })) + } + }) + + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, hostname, () => { + server.off('error', reject) + resolve(server) + }) + }) +} diff --git a/template/tinyvue/src/mock/user.ts b/template/tinyvue/src/mock/user.ts index 5abf490d..6f610288 100644 --- a/template/tinyvue/src/mock/user.ts +++ b/template/tinyvue/src/mock/user.ts @@ -1,98 +1,9 @@ -import { isLogin } from '../utils/auth' -import { - failResponseWrap, - initData, - successResponseWrap, -} from '../utils/setup-mock' +import { initData, successResponseWrap } from '../utils/setup-mock' const positive = JSON.parse(JSON.stringify(initData.tableData)) const negative = JSON.parse(JSON.stringify(initData.tableData.reverse())) const initlist = JSON.parse(JSON.stringify(initData.chartData[0].list)) -const userInfo = JSON.parse(JSON.stringify(initData.userInfo)) export default [ - // 注册 - { - url: '/api/user/register', - method: 'post', - response: (params: { body: any }) => { - localStorage.setItem('registerUser', JSON.stringify(params.body)) - return successResponseWrap({ ...userInfo, role: 'admin' }) - }, - }, - - // 用户信息 - { - url: '/api/user/userInfo', - method: 'get', - response: () => { - if (isLogin()) { - const role = window.localStorage.getItem('userRole') || 'admin' - return successResponseWrap({ - ...userInfo, - role, - }) - } - return successResponseWrap(null) - }, - }, - - // 修改用户信息 - { - url: '/api/user/userInfo', - method: 'put', - response: () => { - if (isLogin()) { - const role = window.localStorage.getItem('userRole') || 'admin' - return successResponseWrap({ - ...userInfo, - role, - }) - } - return successResponseWrap(null) - }, - }, - - // 登录 - { - url: '/api/user/login', - method: 'post', - response: (params: { body: any }) => { - const registerUser = JSON.parse( - localStorage.getItem('registerUser') || '{}', - ) - const { username, password } = JSON.parse(JSON.stringify(params.body)) - if (!username) { - return failResponseWrap(null, '邮箱名不能为空', 'InvalidParameter') - } - if (!password) { - return failResponseWrap(null, '密码不能为空', 'InvalidParameter') - } - if ( - (username === 'admin@example.com' && password === 'admin') - || (username === registerUser.username - && password === registerUser.password) - ) { - window.localStorage.setItem('userRole', 'admin') - return successResponseWrap({ - token: '12345', - userInfo: { - ...userInfo, - }, - }) - } - return failResponseWrap(null, '账号或者密码错误', 'InvalidParameter') - }, - }, - - // 登出 - { - url: '/api/user/logout', - method: 'post', - response: () => { - return successResponseWrap(null) - }, - }, - // 用户中心数据 { url: '/api/user/data', diff --git a/template/tinyvue/src/views/board/work/components/learn-coach.vue b/template/tinyvue/src/views/board/work/components/learn-coach.vue index b1dd87b7..67d9e30a 100644 --- a/template/tinyvue/src/views/board/work/components/learn-coach.vue +++ b/template/tinyvue/src/views/board/work/components/learn-coach.vue @@ -1,199 +1,149 @@