From 273fc062c191eadde3f7ca6fc97a3a72431d29a8 Mon Sep 17 00:00:00 2001 From: hexqi Date: Fri, 31 Jul 2026 23:21:51 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=20TinyVue=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=8B=AC=E7=AB=8B=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- template/tinyvue/config/vite.config.dev.ts | 18 +- template/tinyvue/dev.env | 2 +- template/tinyvue/package.json | 9 +- template/tinyvue/src/mock/backend-data.ts | 141 ++++ template/tinyvue/src/mock/backend.test.ts | 301 +++++++++ template/tinyvue/src/mock/backend.ts | 628 ++++++++++++++++++ template/tinyvue/src/mock/index.ts | 16 +- template/tinyvue/src/mock/server.test.ts | 24 + template/tinyvue/src/mock/server.ts | 141 ++++ template/tinyvue/src/mock/user.ts | 91 +-- .../src/views/login/components/login-info.vue | 22 +- tests/e2e/mobile/navbar.spec.ts | 12 +- 12 files changed, 1273 insertions(+), 132 deletions(-) create mode 100644 template/tinyvue/src/mock/backend-data.ts create mode 100644 template/tinyvue/src/mock/backend.test.ts create mode 100644 template/tinyvue/src/mock/backend.ts create mode 100644 template/tinyvue/src/mock/server.test.ts create mode 100644 template/tinyvue/src/mock/server.ts diff --git a/template/tinyvue/config/vite.config.dev.ts b/template/tinyvue/config/vite.config.dev.ts index 9e47f0db..ef160836 100644 --- a/template/tinyvue/config/vite.config.dev.ts +++ b/template/tinyvue/config/vite.config.dev.ts @@ -11,23 +11,23 @@ configDotenv({ // 加载环境变量(development 模式会读取 .env.development 和 .env) const env = loadEnv('development', process.cwd()) +const useMock = env.VITE_USE_MOCK === 'true' +const apiTarget = useMock ? env.VITE_MOCK_HOST : env.VITE_SERVER_HOST const proxyConfig = { [env.VITE_BASE_API]: { - target: env.VITE_SERVER_HOST, + target: apiTarget, 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, + target: apiTarget, changeOrigin: true, rewrite: (path: string) => { - return path.replace(new RegExp(`${env.VITE_MOCK_SERVER_HOST}`), '/mock') + return path.replace( + new RegExp(`^${env.VITE_MOCK_SERVER_HOST}`), + useMock ? '' : `${env.VITE_BASE_API}/mock`, + ) }, }, } @@ -35,7 +35,7 @@ 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..dfdd1883 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", "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/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/index.ts b/template/tinyvue/src/mock/index.ts index c4c8eef7..ccf98206 100644 --- a/template/tinyvue/src/mock/index.ts +++ b/template/tinyvue/src/mock/index.ts @@ -1,12 +1,18 @@ -import { createMockServer } from '@gaonengwww/mock-server' import froms from '../views/form/step/mock' +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, +] 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/login/components/login-info.vue b/template/tinyvue/src/views/login/components/login-info.vue index 92f73d7b..c47a2d23 100644 --- a/template/tinyvue/src/views/login/components/login-info.vue +++ b/template/tinyvue/src/views/login/components/login-info.vue @@ -14,7 +14,6 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import useLoading from '@/hooks/loading' import { useUserStore } from '@/store' -import { setToken } from '@/utils/auth' const router = useRouter() const { t } = useI18n() @@ -42,7 +41,7 @@ const rules = computed(() => { }) const loginInfo = reactive({ - username: 'admin', + username: 'admin@no-reply.com', password: 'admin', rememberPassword: true, }) @@ -58,20 +57,6 @@ function handleSubmit() { if (!valid) { return } - if (!import.meta.env.VITE_USE_MOCK) { - window.localStorage.setItem('userRole', 'admin') - setToken('12345') - - const { redirect, ...othersQuery } = router.currentRoute.value.query - router.push({ - name: (redirect as string) || 'Home', - query: { - ...othersQuery, - }, - }) - setLoading(false) - return - } setLoading(true) try { @@ -84,9 +69,8 @@ function handleSubmit() { status: 'success', }) - const { redirect, ...othersQuery } = router.currentRoute.value.query ?? { redirect: 'Home' } - router.replace({ name: redirect?.toString() ?? 'Home' }) - router.push({ + const { redirect, ...othersQuery } = router.currentRoute.value.query + await router.replace({ name: (redirect as string) || 'Home', query: { ...othersQuery, diff --git a/tests/e2e/mobile/navbar.spec.ts b/tests/e2e/mobile/navbar.spec.ts index 95393bff..94d9b6c8 100644 --- a/tests/e2e/mobile/navbar.spec.ts +++ b/tests/e2e/mobile/navbar.spec.ts @@ -1,10 +1,12 @@ import { test, expect } from '@playwright/test'; -test('测试移动端右上角折叠导航栏展开与折叠', async ({ page }) => { - await page.goto('http://localhost:3031/vue-pro/login'); - await page.getByRole('button', { name: '登录' }).click(); - await expect(page.locator('.menu-toggle')).toBeVisible(); +test('测试移动端右上角折叠导航栏展开与折叠', async ({ page }) => { + await page.goto('http://localhost:3031/vue-pro/login'); + await page.locator('input').first().fill('admin@no-reply.com'); + await page.getByRole('button', { name: '登录' }).click(); + await expect(page).not.toHaveURL(/\/login/); + await expect(page.locator('.menu-toggle')).toBeVisible(); await page.locator('.menu-toggle').click(); await expect(page.locator('.right-side.open')).toBeVisible(); -}); \ No newline at end of file +}); From db901ad8c5f7a5a72fc8e715e465a63855e35ef6 Mon Sep 17 00:00:00 2001 From: wuyiping0628 <1106773985@qq.com> Date: Tue, 8 Sep 2026 00:57:39 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E5=90=AF=E5=8A=A8=E5=90=8E?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=8A=A5=E9=94=99=E4=BF=AE=E5=A4=8D=E5=92=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 +- template/nestJs/locales.json | 32 +- .../src/main/resources/locales.json | 30 ++ template/tinyvue/README.md | 20 +- template/tinyvue/src/locales.json | 146 +++++++- template/tinyvue/src/mock/application.test.ts | 49 +++ template/tinyvue/src/mock/application.ts | 129 +++++++ template/tinyvue/src/mock/board.test.ts | 36 ++ template/tinyvue/src/mock/board.ts | 25 +- template/tinyvue/src/mock/index.ts | 2 + .../board/work/components/learn-coach.vue | 314 ++++++++---------- 11 files changed, 620 insertions(+), 198 deletions(-) create mode 100644 template/tinyvue/src/mock/application.test.ts create mode 100644 template/tinyvue/src/mock/application.ts create mode 100644 template/tinyvue/src/mock/board.test.ts 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/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/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/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..ce071946 --- /dev/null +++ b/template/tinyvue/src/mock/application.test.ts @@ -0,0 +1,49 @@ +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.ok(body.data[0].name) + 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=Furion&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, /Furion/) +}) diff --git a/template/tinyvue/src/mock/application.ts b/template/tinyvue/src/mock/application.ts new file mode 100644 index 00000000..2fe4795a --- /dev/null +++ b/template/tinyvue/src/mock/application.ts @@ -0,0 +1,129 @@ +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: 'Tiny Design 设计体系', + description: '华为云产品和服务的规范体系,包括交互视觉设计、业务流程、国际化、术语词条。', + tag: [{ type: '', value: '机会点定义' }, { type: 'danger', value: '交互设计' }], + classify: 'design', + icon: 'card-list-application-default.png', + }, + { + name: 'Tiny DesignLink 设计流水线工具', + description: '设计+协同+资源管理,一个工具就够了,在线原型设计、设计过程融入DevOps流程。', + tag: [{ type: 'error', 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', + icon: 'card-list-application-default.png', + }, + { + name: 'TinyPlus3.0 开发工具', + description: 'TinyPlus3.0 是基于Angular + Typescript的Web前端云业务组件库。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-tiny-plus.png', + }, + { + name: 'Tiny Stage 工程工具 ', + description: '一个跨平台的前端工程化cli工具,为开发提供一系列开发套件和工程插件', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'Tiny Flow 接口编排工具 ', + description: '端到端的API编排解决方案,通过可视化编程的方式快速生成、发布、调试的API编排。', + tag: [{ type: 'success', value: '开发' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'Tiny Gate 门禁系统', + description: '门禁系统,通过卡点方式集成到伏羲流水线,在服务发布时生成预览页面。', + tag: [{ type: 'info', value: '测试验证' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'Console Framework 控制台框架', + description: '华为云各服务快速构建管理控制台的平台。', + tag: [{ type: 'success', value: '开发' }, { type: 'info', value: '测试验证' }, { type: 'warning', value: '上线' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'Nodejs Framework Nodejs应用', + description: '基于egg的定制化web服务框架,让你快速上手Nodejs做BFF意见微服务。', + tag: [{ type: 'success', value: '开发' }, { type: 'info', value: '测试验证' }], + classify: 'dev', + icon: 'card-list-console-framework.png', + }, + { + name: 'Furion 前端体验监控', + description: '提供端到端前端用户体验度量,让产品用户体验可度量、可监控、可优化。', + tag: [{ type: '', value: '机会点定义' }], + classify: 'dev', + icon: 'card-list-furion.png', + }, + { + name: 'Tiny Mock API 管理', + description: '功能强大的API管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。', + tag: [{ type: 'success', value: '开发' }, { type: 'warning', 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/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 ccf98206..aac58948 100644 --- a/template/tinyvue/src/mock/index.ts +++ b/template/tinyvue/src/mock/index.ts @@ -1,4 +1,5 @@ import froms from '../views/form/step/mock' +import application from './application' import { createBackendMocks } from './backend' import board from './board' import list from './list' @@ -13,6 +14,7 @@ const mockData = [ ...profile, ...board, ...user, + ...application, ] as any startMockServer(mockData) 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 @@ + + + +
+ + + \ No newline at end of file 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/tests/playwright-webserver.test.mjs b/tests/playwright-webserver.test.mjs new file mode 100644 index 00000000..616c2e6e --- /dev/null +++ b/tests/playwright-webserver.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolvePlaywrightWebServerCommand } from '../playwright-webserver.mjs' + +test('local runs keep the mock frontend unless the command is overridden', () => { + assert.equal(resolvePlaywrightWebServerCommand({}), 'pnpm start') +}) + +test('CI defaults to pnpm dev:full so Docker backend is actually exercised', () => { + assert.equal(resolvePlaywrightWebServerCommand({ CI: 'true' }), 'pnpm dev:full') +}) + +test('PLAYWRIGHT_WEB_SERVER_COMMAND overrides both local and CI defaults', () => { + assert.equal( + resolvePlaywrightWebServerCommand({ + PLAYWRIGHT_WEB_SERVER_COMMAND: 'pnpm dev:full', + }), + 'pnpm dev:full', + ) + assert.equal( + resolvePlaywrightWebServerCommand({ + CI: 'true', + PLAYWRIGHT_WEB_SERVER_COMMAND: 'pnpm start', + }), + 'pnpm start', + ) +}) From 7662573a34a140bd636f7b274ba0ad0678e31b55 Mon Sep 17 00:00:00 2001 From: wuyiping0628 <1106773985@qq.com> Date: Wed, 9 Sep 2026 20:11:49 -0700 Subject: [PATCH 7/8] =?UTF-8?q?feat=EF=BC=9AapplicationData=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=88=90opentiny=E4=BA=A7=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- template/nestJs/src/application/init/data.ts | 85 ++++++++++--------- template/tinyvue/src/mock/application.test.ts | 26 +++--- template/tinyvue/src/mock/application.ts | 83 +++++++++--------- 3 files changed, 107 insertions(+), 87 deletions(-) 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/tinyvue/src/mock/application.test.ts b/template/tinyvue/src/mock/application.test.ts index ce071946..27ec9ea5 100644 --- a/template/tinyvue/src/mock/application.test.ts +++ b/template/tinyvue/src/mock/application.test.ts @@ -11,20 +11,24 @@ test('GET /api/application returns paginated card-list data', async () => { }) 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 } + 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.ok(body.data[0].name) + assert.equal(body.data[0].name, 'TinyVue 组件库') + assert.equal(body.data[1].name, 'TinyEngine 低代码引擎') assert.ok(Array.isArray(body.data[0].tag)) }) @@ -40,10 +44,10 @@ test('GET /api/application filters by classify and keywords', async () => { const search = await dispatchMockRequest(application, { method: 'get', - url: '/api/application?page=1&limit=10&keywords=Furion&classify=all', + 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, /Furion/) + assert.match(searchBody.data[0].name, /TinyRobot/) }) diff --git a/template/tinyvue/src/mock/application.ts b/template/tinyvue/src/mock/application.ts index 2fe4795a..6a6970ce 100644 --- a/template/tinyvue/src/mock/application.ts +++ b/template/tinyvue/src/mock/application.ts @@ -16,79 +16,86 @@ interface ApplicationItem { const applications: ApplicationItem[] = [ { - name: 'Tiny Design 设计体系', - description: '华为云产品和服务的规范体系,包括交互视觉设计、业务流程、国际化、术语词条。', - tag: [{ type: '', value: '机会点定义' }, { type: 'danger', value: '交互设计' }], - classify: 'design', + 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: 'Tiny DesignLink 设计流水线工具', - description: '设计+协同+资源管理,一个工具就够了,在线原型设计、设计过程融入DevOps流程。', - tag: [{ type: 'error', value: '交互设计' }, { type: 'warning', value: '视觉设计' }], + 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: '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 开发工具', - description: 'TinyPlus3.0 是基于Angular + Typescript的Web前端云业务组件库。', + name: 'TinyCLI 命令行工具', + description: '灵活可扩展的 OpenTiny 前端命令行工具,提供工程化套件与插件。', tag: [{ type: 'success', value: '开发' }], classify: 'dev', - icon: 'card-list-tiny-plus.png', + icon: 'card-list-console-framework.png', }, { - name: 'Tiny Stage 工程工具 ', - description: '一个跨平台的前端工程化cli工具,为开发提供一系列开发套件和工程插件', - tag: [{ type: 'success', value: '开发' }], + name: 'TinyPro 中后台模板', + description: '基于 TinyVue 的前后端分离后台管理系统,支持在线配置菜单、路由、国际化,开箱即用。', + tag: [{ type: 'success', value: '开发' }, { type: 'warning', value: '上线' }], classify: 'dev', icon: 'card-list-console-framework.png', }, { - name: 'Tiny Flow 接口编排工具 ', - description: '端到端的API编排解决方案,通过可视化编程的方式快速生成、发布、调试的API编排。', + name: 'TinyEditor 富文本编辑器', + description: '基于 Quill 2.0 的富文本编辑器,扩展丰富模块与格式,功能强大、开箱即用。', tag: [{ type: 'success', value: '开发' }], classify: 'dev', icon: 'card-list-console-framework.png', }, { - name: 'Tiny Gate 门禁系统', - description: '门禁系统,通过卡点方式集成到伏羲流水线,在服务发布时生成预览页面。', - tag: [{ type: 'info', value: '测试验证' }], + name: 'TinyRobot AI 对话组件库', + description: '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应用', - description: '基于egg的定制化web服务框架,让你快速上手Nodejs做BFF意见微服务。', - tag: [{ type: 'success', value: '开发' }, { type: 'info', value: '测试验证' }], + name: 'TinyCharts 可视化图表库', + description: '前端可视化图表库,提供 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 前端体验监控', - description: '提供端到端前端用户体验度量,让产品用户体验可度量、可监控、可优化。', - tag: [{ type: '', value: '机会点定义' }], + name: 'WebMCP SDK', + description: '前端 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 管理', - description: '功能强大的API管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。', - tag: [{ type: 'success', value: '开发' }, { type: 'warning', value: '视觉设计' }], + name: 'GenUI SDK', + description: '面向 Vue、Angular 的 Generative UI SDK,帮助快速生成交互界面。', + tag: [{ type: 'success', value: '开发' }], classify: 'dev', icon: 'card-list-application-default.png', }, From e88562039f678a4fc7b1ba4864ab739344138673 Mon Sep 17 00:00:00 2001 From: wuyiping0628 <1106773985@qq.com> Date: Wed, 9 Sep 2026 20:41:55 -0700 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20.=E6=9B=B4=E6=94=B9gitignore?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + playwright-report/index.html | 76 ------------------------------------ 2 files changed, 1 insertion(+), 76 deletions(-) delete mode 100644 playwright-report/index.html 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/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 159632b3..00000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file