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 +});