From 438f2bacbdfb8e8aaed2f29abfc6dbcf593012dd Mon Sep 17 00:00:00 2001 From: wangrunpu <2095588299@qq.com> Date: Mon, 1 Dec 2025 15:29:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8Dbackend-mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend-mock/.env | 3 + apps/backend-mock/README.md | 15 + apps/backend-mock/api/auth/codes.ts | 16 + apps/backend-mock/api/auth/login.post.ts | 42 ++ apps/backend-mock/api/auth/logout.post.ts | 17 + apps/backend-mock/api/auth/refresh.post.ts | 35 ++ apps/backend-mock/api/demo/bigint.ts | 32 ++ apps/backend-mock/api/menu/all.ts | 15 + apps/backend-mock/api/status.ts | 8 + apps/backend-mock/api/system/dept/.post.ts | 16 + .../api/system/dept/[id].delete.ts | 16 + apps/backend-mock/api/system/dept/[id].put.ts | 16 + apps/backend-mock/api/system/dept/list.ts | 62 +++ apps/backend-mock/api/system/menu/list.ts | 13 + .../api/system/menu/name-exists.ts | 29 ++ .../api/system/menu/path-exists.ts | 29 ++ apps/backend-mock/api/system/role/list.ts | 84 ++++ apps/backend-mock/api/table/list.ts | 117 ++++++ apps/backend-mock/api/test.get.ts | 3 + apps/backend-mock/api/test.post.ts | 3 + apps/backend-mock/api/upload.ts | 14 + apps/backend-mock/api/user/info.ts | 11 + apps/backend-mock/error.ts | 7 + apps/backend-mock/middleware/1.api.ts | 20 + apps/backend-mock/nitro.config.ts | 20 + apps/backend-mock/package.json | 21 + apps/backend-mock/routes/[...].ts | 15 + apps/backend-mock/tsconfig.build.json | 4 + apps/backend-mock/tsconfig.json | 3 + apps/backend-mock/utils/cookie-utils.ts | 28 ++ apps/backend-mock/utils/jwt-utils.ts | 77 ++++ apps/backend-mock/utils/mock-data.ts | 390 ++++++++++++++++++ apps/backend-mock/utils/response.ts | 70 ++++ turbo.json | 4 + vben-admin.code-workspace | 4 + 35 files changed, 1259 insertions(+) create mode 100644 apps/backend-mock/.env create mode 100644 apps/backend-mock/README.md create mode 100644 apps/backend-mock/api/auth/codes.ts create mode 100644 apps/backend-mock/api/auth/login.post.ts create mode 100644 apps/backend-mock/api/auth/logout.post.ts create mode 100644 apps/backend-mock/api/auth/refresh.post.ts create mode 100644 apps/backend-mock/api/demo/bigint.ts create mode 100644 apps/backend-mock/api/menu/all.ts create mode 100644 apps/backend-mock/api/status.ts create mode 100644 apps/backend-mock/api/system/dept/.post.ts create mode 100644 apps/backend-mock/api/system/dept/[id].delete.ts create mode 100644 apps/backend-mock/api/system/dept/[id].put.ts create mode 100644 apps/backend-mock/api/system/dept/list.ts create mode 100644 apps/backend-mock/api/system/menu/list.ts create mode 100644 apps/backend-mock/api/system/menu/name-exists.ts create mode 100644 apps/backend-mock/api/system/menu/path-exists.ts create mode 100644 apps/backend-mock/api/system/role/list.ts create mode 100644 apps/backend-mock/api/table/list.ts create mode 100644 apps/backend-mock/api/test.get.ts create mode 100644 apps/backend-mock/api/test.post.ts create mode 100644 apps/backend-mock/api/upload.ts create mode 100644 apps/backend-mock/api/user/info.ts create mode 100644 apps/backend-mock/error.ts create mode 100644 apps/backend-mock/middleware/1.api.ts create mode 100644 apps/backend-mock/nitro.config.ts create mode 100644 apps/backend-mock/package.json create mode 100644 apps/backend-mock/routes/[...].ts create mode 100644 apps/backend-mock/tsconfig.build.json create mode 100644 apps/backend-mock/tsconfig.json create mode 100644 apps/backend-mock/utils/cookie-utils.ts create mode 100644 apps/backend-mock/utils/jwt-utils.ts create mode 100644 apps/backend-mock/utils/mock-data.ts create mode 100644 apps/backend-mock/utils/response.ts diff --git a/apps/backend-mock/.env b/apps/backend-mock/.env new file mode 100644 index 00000000..b20c4a65 --- /dev/null +++ b/apps/backend-mock/.env @@ -0,0 +1,3 @@ +PORT=5320 +ACCESS_TOKEN_SECRET=access_token_secret +REFRESH_TOKEN_SECRET=refresh_token_secret diff --git a/apps/backend-mock/README.md b/apps/backend-mock/README.md new file mode 100644 index 00000000..401bda76 --- /dev/null +++ b/apps/backend-mock/README.md @@ -0,0 +1,15 @@ +# @vben/backend-mock + +## Description + +Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供 mock 集成,可自行部署服务或者对接真实数据,由于 `mock.js` 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。该服务不需要手动启动,已经集成在 vite 插件内,随应用一起启用。 + +## Running the app + +```bash +# development +$ pnpm run start + +# production mode +$ pnpm run build +``` diff --git a/apps/backend-mock/api/auth/codes.ts b/apps/backend-mock/api/auth/codes.ts new file mode 100644 index 00000000..e610b338 --- /dev/null +++ b/apps/backend-mock/api/auth/codes.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_CODES } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const codes = + MOCK_CODES.find((item) => item.username === userinfo.username)?.codes ?? []; + + return useResponseSuccess(codes); +}); diff --git a/apps/backend-mock/api/auth/login.post.ts b/apps/backend-mock/api/auth/login.post.ts new file mode 100644 index 00000000..e23942c4 --- /dev/null +++ b/apps/backend-mock/api/auth/login.post.ts @@ -0,0 +1,42 @@ +import { defineEventHandler, readBody, setResponseStatus } from 'h3'; +import { + clearRefreshTokenCookie, + setRefreshTokenCookie, +} from '~/utils/cookie-utils'; +import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils'; +import { MOCK_USERS } from '~/utils/mock-data'; +import { + forbiddenResponse, + useResponseError, + useResponseSuccess, +} from '~/utils/response'; + +export default defineEventHandler(async (event) => { + const { password, username } = await readBody(event); + if (!password || !username) { + setResponseStatus(event, 400); + return useResponseError( + 'BadRequestException', + 'Username and password are required', + ); + } + + const findUser = MOCK_USERS.find( + (item) => item.username === username && item.password === password, + ); + + if (!findUser) { + clearRefreshTokenCookie(event); + return forbiddenResponse(event, 'Username or password is incorrect.'); + } + + const accessToken = generateAccessToken(findUser); + const refreshToken = generateRefreshToken(findUser); + + setRefreshTokenCookie(event, refreshToken); + + return useResponseSuccess({ + ...findUser, + accessToken, + }); +}); diff --git a/apps/backend-mock/api/auth/logout.post.ts b/apps/backend-mock/api/auth/logout.post.ts new file mode 100644 index 00000000..74c8d315 --- /dev/null +++ b/apps/backend-mock/api/auth/logout.post.ts @@ -0,0 +1,17 @@ +import { defineEventHandler } from 'h3'; +import { + clearRefreshTokenCookie, + getRefreshTokenFromCookie, +} from '~/utils/cookie-utils'; +import { useResponseSuccess } from '~/utils/response'; + +export default defineEventHandler(async (event) => { + const refreshToken = getRefreshTokenFromCookie(event); + if (!refreshToken) { + return useResponseSuccess(''); + } + + clearRefreshTokenCookie(event); + + return useResponseSuccess(''); +}); diff --git a/apps/backend-mock/api/auth/refresh.post.ts b/apps/backend-mock/api/auth/refresh.post.ts new file mode 100644 index 00000000..7d8d3a51 --- /dev/null +++ b/apps/backend-mock/api/auth/refresh.post.ts @@ -0,0 +1,35 @@ +import { defineEventHandler } from 'h3'; +import { + clearRefreshTokenCookie, + getRefreshTokenFromCookie, + setRefreshTokenCookie, +} from '~/utils/cookie-utils'; +import { generateAccessToken, verifyRefreshToken } from '~/utils/jwt-utils'; +import { MOCK_USERS } from '~/utils/mock-data'; +import { forbiddenResponse } from '~/utils/response'; + +export default defineEventHandler(async (event) => { + const refreshToken = getRefreshTokenFromCookie(event); + if (!refreshToken) { + return forbiddenResponse(event); + } + + clearRefreshTokenCookie(event); + + const userinfo = verifyRefreshToken(refreshToken); + if (!userinfo) { + return forbiddenResponse(event); + } + + const findUser = MOCK_USERS.find( + (item) => item.username === userinfo.username, + ); + if (!findUser) { + return forbiddenResponse(event); + } + const accessToken = generateAccessToken(findUser); + + setRefreshTokenCookie(event, refreshToken); + + return accessToken; +}); diff --git a/apps/backend-mock/api/demo/bigint.ts b/apps/backend-mock/api/demo/bigint.ts new file mode 100644 index 00000000..00d6c28c --- /dev/null +++ b/apps/backend-mock/api/demo/bigint.ts @@ -0,0 +1,32 @@ +import { eventHandler, setHeader } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const data = ` + { + "code": 0, + "message": "success", + "data": [ + { + "id": 123456789012345678901234567890123456789012345678901234567890, + "name": "John Doe", + "age": 30, + "email": "john-doe@demo.com" + }, + { + "id": 987654321098765432109876543210987654321098765432109876543210, + "name": "Jane Smith", + "age": 25, + "email": "jane@demo.com" + } + ] + } + `; + setHeader(event, 'Content-Type', 'application/json'); + return data; +}); diff --git a/apps/backend-mock/api/menu/all.ts b/apps/backend-mock/api/menu/all.ts new file mode 100644 index 00000000..7923f7ca --- /dev/null +++ b/apps/backend-mock/api/menu/all.ts @@ -0,0 +1,15 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENUS } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const menus = + MOCK_MENUS.find((item) => item.username === userinfo.username)?.menus ?? []; + return useResponseSuccess(menus); +}); diff --git a/apps/backend-mock/api/status.ts b/apps/backend-mock/api/status.ts new file mode 100644 index 00000000..43782095 --- /dev/null +++ b/apps/backend-mock/api/status.ts @@ -0,0 +1,8 @@ +import { eventHandler, getQuery, setResponseStatus } from 'h3'; +import { useResponseError } from '~/utils/response'; + +export default eventHandler((event) => { + const { status } = getQuery(event); + setResponseStatus(event, Number(status)); + return useResponseError(`${status}`); +}); diff --git a/apps/backend-mock/api/system/dept/.post.ts b/apps/backend-mock/api/system/dept/.post.ts new file mode 100644 index 00000000..9a4896af --- /dev/null +++ b/apps/backend-mock/api/system/dept/.post.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(600); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/[id].delete.ts b/apps/backend-mock/api/system/dept/[id].delete.ts new file mode 100644 index 00000000..eac0f584 --- /dev/null +++ b/apps/backend-mock/api/system/dept/[id].delete.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(1000); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/[id].put.ts b/apps/backend-mock/api/system/dept/[id].put.ts new file mode 100644 index 00000000..6805e139 --- /dev/null +++ b/apps/backend-mock/api/system/dept/[id].put.ts @@ -0,0 +1,16 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + useResponseSuccess, +} from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + await sleep(2000); + return useResponseSuccess(null); +}); diff --git a/apps/backend-mock/api/system/dept/list.ts b/apps/backend-mock/api/system/dept/list.ts new file mode 100644 index 00000000..a649a0d2 --- /dev/null +++ b/apps/backend-mock/api/system/dept/list.ts @@ -0,0 +1,62 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + pid: 0, + name: faker.commerce.department(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2021-01-01', to: '2022-12-31' }), + ), + remark: faker.lorem.sentence(), + }; + if (faker.datatype.boolean()) { + dataItem.children = Array.from( + { length: faker.number.int({ min: 1, max: 5 }) }, + () => ({ + id: faker.string.uuid(), + pid: dataItem.id, + name: faker.commerce.department(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2023-01-01', to: '2023-12-31' }), + ), + remark: faker.lorem.sentence(), + }), + ); + } + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(10); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const listData = structuredClone(mockData); + + return useResponseSuccess(listData); +}); diff --git a/apps/backend-mock/api/system/menu/list.ts b/apps/backend-mock/api/system/menu/list.ts new file mode 100644 index 00000000..ce96bb14 --- /dev/null +++ b/apps/backend-mock/api/system/menu/list.ts @@ -0,0 +1,13 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + return useResponseSuccess(MOCK_MENU_LIST); +}); diff --git a/apps/backend-mock/api/system/menu/name-exists.ts b/apps/backend-mock/api/system/menu/name-exists.ts new file mode 100644 index 00000000..7d5551b3 --- /dev/null +++ b/apps/backend-mock/api/system/menu/name-exists.ts @@ -0,0 +1,29 @@ +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const namesMap: Record = {}; + +function getNames(menus: any[]) { + menus.forEach((menu) => { + namesMap[menu.name] = String(menu.id); + if (menu.children) { + getNames(menu.children); + } + }); +} +getNames(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, name } = getQuery(event); + + return (name as string) in namesMap && + (!id || namesMap[name as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/api/system/menu/path-exists.ts b/apps/backend-mock/api/system/menu/path-exists.ts new file mode 100644 index 00000000..f3c3be99 --- /dev/null +++ b/apps/backend-mock/api/system/menu/path-exists.ts @@ -0,0 +1,29 @@ +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +const pathMap: Record = { '/': 0 }; + +function getPaths(menus: any[]) { + menus.forEach((menu) => { + pathMap[menu.path] = String(menu.id); + if (menu.children) { + getPaths(menu.children); + } + }); +} +getPaths(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, path } = getQuery(event); + + return (path as string) in pathMap && + (!id || pathMap[path as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/api/system/role/list.ts b/apps/backend-mock/api/system/role/list.ts new file mode 100644 index 00000000..bad29a51 --- /dev/null +++ b/apps/backend-mock/api/system/role/list.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +const menuIds = getMenuIds(MOCK_MENU_LIST); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + name: faker.commerce.product(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2022-01-01', to: '2025-01-01' }), + ), + permissions: faker.helpers.arrayElements(menuIds), + remark: faker.lorem.sentence(), + }; + + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(100); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const { + page = 1, + pageSize = 20, + name, + id, + remark, + startTime, + endTime, + status, + } = getQuery(event); + let listData = structuredClone(mockData); + if (name) { + listData = listData.filter((item) => + item.name.toLowerCase().includes(String(name).toLowerCase()), + ); + } + if (id) { + listData = listData.filter((item) => + item.id.toLowerCase().includes(String(id).toLowerCase()), + ); + } + if (remark) { + listData = listData.filter((item) => + item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()), + ); + } + if (startTime) { + listData = listData.filter((item) => item.createTime >= startTime); + } + if (endTime) { + listData = listData.filter((item) => item.createTime <= endTime); + } + if (['0', '1'].includes(status as string)) { + listData = listData.filter((item) => item.status === Number(status)); + } + return usePageResponseSuccess(page as string, pageSize as string, listData); +}); diff --git a/apps/backend-mock/api/table/list.ts b/apps/backend-mock/api/table/list.ts new file mode 100644 index 00000000..6664b583 --- /dev/null +++ b/apps/backend-mock/api/table/list.ts @@ -0,0 +1,117 @@ +import { faker } from '@faker-js/faker'; +import { eventHandler, getQuery } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { + sleep, + unAuthorizedResponse, + usePageResponseSuccess, +} from '~/utils/response'; + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem = { + id: faker.string.uuid(), + imageUrl: faker.image.avatar(), + imageUrl2: faker.image.avatar(), + open: faker.datatype.boolean(), + status: faker.helpers.arrayElement(['success', 'error', 'warning']), + productName: faker.commerce.productName(), + price: faker.commerce.price(), + currency: faker.finance.currencyCode(), + quantity: faker.number.int({ min: 1, max: 100 }), + available: faker.datatype.boolean(), + category: faker.commerce.department(), + releaseDate: faker.date.past(), + rating: faker.number.float({ min: 1, max: 5 }), + description: faker.commerce.productDescription(), + weight: faker.number.float({ min: 0.1, max: 10 }), + color: faker.color.human(), + inProduction: faker.datatype.boolean(), + tags: Array.from({ length: 3 }, () => faker.commerce.productAdjective()), + }; + + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(100); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + await sleep(600); + + const { page, pageSize, sortBy, sortOrder } = getQuery(event); + // 规范化分页参数,处理 string[] + const pageRaw = Array.isArray(page) ? page[0] : page; + const pageSizeRaw = Array.isArray(pageSize) ? pageSize[0] : pageSize; + const pageNumber = Math.max( + 1, + Number.parseInt(String(pageRaw ?? '1'), 10) || 1, + ); + const pageSizeNumber = Math.min( + 100, + Math.max(1, Number.parseInt(String(pageSizeRaw ?? '10'), 10) || 10), + ); + const listData = structuredClone(mockData); + + // 规范化 query 入参,兼容 string[] + const sortKeyRaw = Array.isArray(sortBy) ? sortBy[0] : sortBy; + const sortOrderRaw = Array.isArray(sortOrder) ? sortOrder[0] : sortOrder; + // 检查 sortBy 是否是 listData 元素的合法属性键 + if ( + typeof sortKeyRaw === 'string' && + listData[0] && + Object.prototype.hasOwnProperty.call(listData[0], sortKeyRaw) + ) { + // 定义数组元素的类型 + type ItemType = (typeof listData)[0]; + const sortKey = sortKeyRaw as keyof ItemType; // 将 sortBy 断言为合法键 + const isDesc = sortOrderRaw === 'desc'; + listData.sort((a, b) => { + const aValue = a[sortKey] as unknown; + const bValue = b[sortKey] as unknown; + + let result = 0; + + if (typeof aValue === 'number' && typeof bValue === 'number') { + result = aValue - bValue; + } else if (aValue instanceof Date && bValue instanceof Date) { + result = aValue.getTime() - bValue.getTime(); + } else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + if (aValue === bValue) { + result = 0; + } else { + result = aValue ? 1 : -1; + } + } else { + const aStr = String(aValue); + const bStr = String(bValue); + const aNum = Number(aStr); + const bNum = Number(bStr); + result = + Number.isFinite(aNum) && Number.isFinite(bNum) + ? aNum - bNum + : aStr.localeCompare(bStr, undefined, { + numeric: true, + sensitivity: 'base', + }); + } + + return isDesc ? -result : result; + }); + } + + return usePageResponseSuccess( + String(pageNumber), + String(pageSizeNumber), + listData, + ); +}); diff --git a/apps/backend-mock/api/test.get.ts b/apps/backend-mock/api/test.get.ts new file mode 100644 index 00000000..dc2ceef7 --- /dev/null +++ b/apps/backend-mock/api/test.get.ts @@ -0,0 +1,3 @@ +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(() => 'Test get handler'); diff --git a/apps/backend-mock/api/test.post.ts b/apps/backend-mock/api/test.post.ts new file mode 100644 index 00000000..0e9e337a --- /dev/null +++ b/apps/backend-mock/api/test.post.ts @@ -0,0 +1,3 @@ +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(() => 'Test post handler'); diff --git a/apps/backend-mock/api/upload.ts b/apps/backend-mock/api/upload.ts new file mode 100644 index 00000000..436b63cb --- /dev/null +++ b/apps/backend-mock/api/upload.ts @@ -0,0 +1,14 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + return useResponseSuccess({ + url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + }); + // return useResponseError("test") +}); diff --git a/apps/backend-mock/api/user/info.ts b/apps/backend-mock/api/user/info.ts new file mode 100644 index 00000000..138cb433 --- /dev/null +++ b/apps/backend-mock/api/user/info.ts @@ -0,0 +1,11 @@ +import { eventHandler } from 'h3'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler((event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + return useResponseSuccess(userinfo); +}); diff --git a/apps/backend-mock/error.ts b/apps/backend-mock/error.ts new file mode 100644 index 00000000..e20beac4 --- /dev/null +++ b/apps/backend-mock/error.ts @@ -0,0 +1,7 @@ +import type { NitroErrorHandler } from 'nitropack'; + +const errorHandler: NitroErrorHandler = function (error, event) { + event.node.res.end(`[Error Handler] ${error.stack}`); +}; + +export default errorHandler; diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend-mock/middleware/1.api.ts new file mode 100644 index 00000000..339cda4d --- /dev/null +++ b/apps/backend-mock/middleware/1.api.ts @@ -0,0 +1,20 @@ +import { defineEventHandler } from 'h3'; +import { forbiddenResponse, sleep } from '~/utils/response'; + +export default defineEventHandler(async (event) => { + event.node.res.setHeader( + 'Access-Control-Allow-Origin', + event.headers.get('Origin') ?? '*', + ); + if (event.method === 'OPTIONS') { + event.node.res.statusCode = 204; + event.node.res.statusMessage = 'No Content.'; + return 'OK'; + } else if ( + ['DELETE', 'PATCH', 'POST', 'PUT'].includes(event.method) && + event.path.startsWith('/api/system/') + ) { + await sleep(Math.floor(Math.random() * 2000)); + return forbiddenResponse(event, '演示环境,禁止修改'); + } +}); diff --git a/apps/backend-mock/nitro.config.ts b/apps/backend-mock/nitro.config.ts new file mode 100644 index 00000000..c0fc13e2 --- /dev/null +++ b/apps/backend-mock/nitro.config.ts @@ -0,0 +1,20 @@ +import errorHandler from './error'; + +process.env.COMPATIBILITY_DATE = new Date().toISOString(); +export default defineNitroConfig({ + devErrorHandler: errorHandler, + errorHandler: '~/error', + routeRules: { + '/api/**': { + cors: true, + headers: { + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': + 'Accept, Authorization, Content-Length, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With', + 'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': '*', + }, + }, + }, +}); diff --git a/apps/backend-mock/package.json b/apps/backend-mock/package.json new file mode 100644 index 00000000..cc0b8d53 --- /dev/null +++ b/apps/backend-mock/package.json @@ -0,0 +1,21 @@ +{ + "name": "@vben/backend-mock", + "version": "0.0.1", + "description": "", + "private": true, + "license": "MIT", + "author": "", + "scripts": { + "build": "nitro build", + "start": "nitro dev" + }, + "dependencies": { + "@faker-js/faker": "catalog:", + "jsonwebtoken": "catalog:", + "nitropack": "catalog:" + }, + "devDependencies": { + "@types/jsonwebtoken": "catalog:", + "h3": "catalog:" + } +} diff --git a/apps/backend-mock/routes/[...].ts b/apps/backend-mock/routes/[...].ts new file mode 100644 index 00000000..5a22563d --- /dev/null +++ b/apps/backend-mock/routes/[...].ts @@ -0,0 +1,15 @@ +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(() => { + return ` +

Hello Vben Admin

+

Mock service is starting

+ +`; +}); diff --git a/apps/backend-mock/tsconfig.build.json b/apps/backend-mock/tsconfig.build.json new file mode 100644 index 00000000..64f86c6b --- /dev/null +++ b/apps/backend-mock/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/backend-mock/tsconfig.json b/apps/backend-mock/tsconfig.json new file mode 100644 index 00000000..43008af1 --- /dev/null +++ b/apps/backend-mock/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nitro/types/tsconfig.json" +} diff --git a/apps/backend-mock/utils/cookie-utils.ts b/apps/backend-mock/utils/cookie-utils.ts new file mode 100644 index 00000000..187ce2f0 --- /dev/null +++ b/apps/backend-mock/utils/cookie-utils.ts @@ -0,0 +1,28 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +import { deleteCookie, getCookie, setCookie } from 'h3'; + +export function clearRefreshTokenCookie(event: H3Event) { + deleteCookie(event, 'jwt', { + httpOnly: true, + sameSite: 'none', + secure: true, + }); +} + +export function setRefreshTokenCookie( + event: H3Event, + refreshToken: string, +) { + setCookie(event, 'jwt', refreshToken, { + httpOnly: true, + maxAge: 24 * 60 * 60, // unit: seconds + sameSite: 'none', + secure: true, + }); +} + +export function getRefreshTokenFromCookie(event: H3Event) { + const refreshToken = getCookie(event, 'jwt'); + return refreshToken; +} diff --git a/apps/backend-mock/utils/jwt-utils.ts b/apps/backend-mock/utils/jwt-utils.ts new file mode 100644 index 00000000..71858307 --- /dev/null +++ b/apps/backend-mock/utils/jwt-utils.ts @@ -0,0 +1,77 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +import type { UserInfo } from './mock-data'; + +import { getHeader } from 'h3'; +import jwt from 'jsonwebtoken'; + +import { MOCK_USERS } from './mock-data'; + +// TODO: Replace with your own secret key +const ACCESS_TOKEN_SECRET = 'access_token_secret'; +const REFRESH_TOKEN_SECRET = 'refresh_token_secret'; + +export interface UserPayload extends UserInfo { + iat: number; + exp: number; +} + +export function generateAccessToken(user: UserInfo) { + return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '7d' }); +} + +export function generateRefreshToken(user: UserInfo) { + return jwt.sign(user, REFRESH_TOKEN_SECRET, { + expiresIn: '30d', + }); +} + +export function verifyAccessToken( + event: H3Event, +): null | Omit { + const authHeader = getHeader(event, 'Authorization'); + if (!authHeader?.startsWith('Bearer')) { + return null; + } + + const tokenParts = authHeader.split(' '); + if (tokenParts.length !== 2) { + return null; + } + const token = tokenParts[1] as string; + try { + const decoded = jwt.verify( + token, + ACCESS_TOKEN_SECRET, + ) as unknown as UserPayload; + + const username = decoded.username; + const user = MOCK_USERS.find((item) => item.username === username); + if (!user) { + return null; + } + const { password: _pwd, ...userinfo } = user; + return userinfo; + } catch { + return null; + } +} + +export function verifyRefreshToken( + token: string, +): null | Omit { + try { + const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload; + const username = decoded.username; + const user = MOCK_USERS.find( + (item) => item.username === username, + ) as UserInfo; + if (!user) { + return null; + } + const { password: _pwd, ...userinfo } = user; + return userinfo; + } catch { + return null; + } +} diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts new file mode 100644 index 00000000..192f30a0 --- /dev/null +++ b/apps/backend-mock/utils/mock-data.ts @@ -0,0 +1,390 @@ +export interface UserInfo { + id: number; + password: string; + realName: string; + roles: string[]; + username: string; + homePath?: string; +} + +export const MOCK_USERS: UserInfo[] = [ + { + id: 0, + password: '123456', + realName: 'Vben', + roles: ['super'], + username: 'vben', + }, + { + id: 1, + password: '123456', + realName: 'Admin', + roles: ['admin'], + username: 'admin', + homePath: '/workspace', + }, + { + id: 2, + password: '123456', + realName: 'Jack', + roles: ['user'], + username: 'jack', + homePath: '/analytics', + }, +]; + +export const MOCK_CODES = [ + // super + { + codes: ['AC_100100', 'AC_100110', 'AC_100120', 'AC_100010'], + username: 'vben', + }, + { + // admin + codes: ['AC_100010', 'AC_100020', 'AC_100030'], + username: 'admin', + }, + { + // user + codes: ['AC_1000001', 'AC_1000002'], + username: 'jack', + }, +]; + +const dashboardMenus = [ + { + meta: { + order: -1, + title: 'page.dashboard.title', + }, + name: 'Dashboard', + path: '/dashboard', + redirect: '/analytics', + children: [ + { + name: 'Analytics', + path: '/analytics', + component: '/dashboard/analytics/index', + meta: { + affixTab: true, + title: 'page.dashboard.analytics', + }, + }, + { + name: 'Workspace', + path: '/workspace', + component: '/dashboard/workspace/index', + meta: { + title: 'page.dashboard.workspace', + }, + }, + ], + }, +]; + +const createDemosMenus = (role: 'admin' | 'super' | 'user') => { + const roleWithMenus = { + admin: { + component: '/demos/access/admin-visible', + meta: { + icon: 'mdi:button-cursor', + title: 'demos.access.adminVisible', + }, + name: 'AccessAdminVisibleDemo', + path: '/demos/access/admin-visible', + }, + super: { + component: '/demos/access/super-visible', + meta: { + icon: 'mdi:button-cursor', + title: 'demos.access.superVisible', + }, + name: 'AccessSuperVisibleDemo', + path: '/demos/access/super-visible', + }, + user: { + component: '/demos/access/user-visible', + meta: { + icon: 'mdi:button-cursor', + title: 'demos.access.userVisible', + }, + name: 'AccessUserVisibleDemo', + path: '/demos/access/user-visible', + }, + }; + + return [ + { + meta: { + icon: 'ic:baseline-view-in-ar', + keepAlive: true, + order: 1000, + title: 'demos.title', + }, + name: 'Demos', + path: '/demos', + redirect: '/demos/access', + children: [ + { + name: 'AccessDemos', + path: '/demosaccess', + meta: { + icon: 'mdi:cloud-key-outline', + title: 'demos.access.backendPermissions', + }, + redirect: '/demos/access/page-control', + children: [ + { + name: 'AccessPageControlDemo', + path: '/demos/access/page-control', + component: '/demos/access/index', + meta: { + icon: 'mdi:page-previous-outline', + title: 'demos.access.pageAccess', + }, + }, + { + name: 'AccessButtonControlDemo', + path: '/demos/access/button-control', + component: '/demos/access/button-control', + meta: { + icon: 'mdi:button-cursor', + title: 'demos.access.buttonControl', + }, + }, + { + name: 'AccessMenuVisible403Demo', + path: '/demos/access/menu-visible-403', + component: '/demos/access/menu-visible-403', + meta: { + authority: ['no-body'], + icon: 'mdi:button-cursor', + menuVisibleWithForbidden: true, + title: 'demos.access.menuVisible403', + }, + }, + roleWithMenus[role], + ], + }, + ], + }, + ]; +}; + +export const MOCK_MENUS = [ + { + menus: [...dashboardMenus, ...createDemosMenus('super')], + username: 'vben', + }, + { + menus: [...dashboardMenus, ...createDemosMenus('admin')], + username: 'admin', + }, + { + menus: [...dashboardMenus, ...createDemosMenus('user')], + username: 'jack', + }, +]; + +export const MOCK_MENU_LIST = [ + { + id: 1, + name: 'Workspace', + status: 1, + type: 'menu', + icon: 'mdi:dashboard', + path: '/workspace', + component: '/dashboard/workspace/index', + meta: { + icon: 'carbon:workspace', + title: 'page.dashboard.workspace', + affixTab: true, + order: 0, + }, + }, + { + id: 2, + meta: { + icon: 'carbon:settings', + order: 9997, + title: 'system.title', + badge: 'new', + badgeType: 'normal', + badgeVariants: 'primary', + }, + status: 1, + type: 'catalog', + name: 'System', + path: '/system', + children: [ + { + id: 201, + pid: 2, + path: '/system/menu', + name: 'SystemMenu', + authCode: 'System:Menu:List', + status: 1, + type: 'menu', + meta: { + icon: 'carbon:menu', + title: 'system.menu.title', + }, + component: '/system/menu/list', + children: [ + { + id: 20_101, + pid: 201, + name: 'SystemMenuCreate', + status: 1, + type: 'button', + authCode: 'System:Menu:Create', + meta: { title: 'common.create' }, + }, + { + id: 20_102, + pid: 201, + name: 'SystemMenuEdit', + status: 1, + type: 'button', + authCode: 'System:Menu:Edit', + meta: { title: 'common.edit' }, + }, + { + id: 20_103, + pid: 201, + name: 'SystemMenuDelete', + status: 1, + type: 'button', + authCode: 'System:Menu:Delete', + meta: { title: 'common.delete' }, + }, + ], + }, + { + id: 202, + pid: 2, + path: '/system/dept', + name: 'SystemDept', + status: 1, + type: 'menu', + authCode: 'System:Dept:List', + meta: { + icon: 'carbon:container-services', + title: 'system.dept.title', + }, + component: '/system/dept/list', + children: [ + { + id: 20_401, + pid: 201, + name: 'SystemDeptCreate', + status: 1, + type: 'button', + authCode: 'System:Dept:Create', + meta: { title: 'common.create' }, + }, + { + id: 20_402, + pid: 201, + name: 'SystemDeptEdit', + status: 1, + type: 'button', + authCode: 'System:Dept:Edit', + meta: { title: 'common.edit' }, + }, + { + id: 20_403, + pid: 201, + name: 'SystemDeptDelete', + status: 1, + type: 'button', + authCode: 'System:Dept:Delete', + meta: { title: 'common.delete' }, + }, + ], + }, + ], + }, + { + id: 9, + meta: { + badgeType: 'dot', + order: 9998, + title: 'demos.vben.title', + icon: 'carbon:data-center', + }, + name: 'Project', + path: '/vben-admin', + type: 'catalog', + status: 1, + children: [ + { + id: 901, + pid: 9, + name: 'VbenDocument', + path: '/vben-admin/document', + component: 'IFrameView', + type: 'embedded', + status: 1, + meta: { + icon: 'carbon:book', + iframeSrc: 'https://doc.vben.pro', + title: 'demos.vben.document', + }, + }, + { + id: 902, + pid: 9, + name: 'VbenGithub', + path: '/vben-admin/github', + component: 'IFrameView', + type: 'link', + status: 1, + meta: { + icon: 'carbon:logo-github', + link: 'https://github.com/vbenjs/vue-vben-admin', + title: 'Github', + }, + }, + { + id: 903, + pid: 9, + name: 'VbenAntdv', + path: '/vben-admin/antdv', + component: 'IFrameView', + type: 'link', + status: 0, + meta: { + icon: 'carbon:hexagon-vertical-solid', + badgeType: 'dot', + link: 'https://ant.vben.pro', + title: 'demos.vben.antdv', + }, + }, + ], + }, + { + id: 10, + component: '_core/about/index', + type: 'menu', + status: 1, + meta: { + icon: 'lucide:copyright', + order: 9999, + title: 'demos.vben.about', + }, + name: 'About', + path: '/about', + }, +]; + +export function getMenuIds(menus: any[]) { + const ids: number[] = []; + menus.forEach((item) => { + ids.push(item.id); + if (item.children && item.children.length > 0) { + ids.push(...getMenuIds(item.children)); + } + }); + return ids; +} diff --git a/apps/backend-mock/utils/response.ts b/apps/backend-mock/utils/response.ts new file mode 100644 index 00000000..2d4242e9 --- /dev/null +++ b/apps/backend-mock/utils/response.ts @@ -0,0 +1,70 @@ +import type { EventHandlerRequest, H3Event } from 'h3'; + +import { setResponseStatus } from 'h3'; + +export function useResponseSuccess(data: T) { + return { + code: 0, + data, + error: null, + message: 'ok', + }; +} + +export function usePageResponseSuccess( + page: number | string, + pageSize: number | string, + list: T[], + { message = 'ok' } = {}, +) { + const pageData = pagination( + Number.parseInt(`${page}`), + Number.parseInt(`${pageSize}`), + list, + ); + + return { + ...useResponseSuccess({ + items: pageData, + total: list.length, + }), + message, + }; +} + +export function useResponseError(message: string, error: any = null) { + return { + code: -1, + data: null, + error, + message, + }; +} + +export function forbiddenResponse( + event: H3Event, + message = 'Forbidden Exception', +) { + setResponseStatus(event, 403); + return useResponseError(message, message); +} + +export function unAuthorizedResponse(event: H3Event) { + setResponseStatus(event, 401); + return useResponseError('Unauthorized Exception', 'Unauthorized Exception'); +} + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function pagination( + pageNo: number, + pageSize: number, + array: T[], +): T[] { + const offset = (pageNo - 1) * Number(pageSize); + return offset + Number(pageSize) >= array.length + ? array.slice(offset) + : array.slice(offset, offset + Number(pageSize)); +} diff --git a/turbo.json b/turbo.json index 427e0178..aece6e36 100644 --- a/turbo.json +++ b/turbo.json @@ -41,6 +41,10 @@ "dependsOn": ["^build"], "outputs": ["dist/**"] }, + "@vben/backend-mock#build": { + "dependsOn": ["^build"], + "outputs": [".nitro/**", ".output/**"] + }, "test:e2e": {}, "dev": { "dependsOn": [], diff --git a/vben-admin.code-workspace b/vben-admin.code-workspace index b1e7f809..ac73dea8 100644 --- a/vben-admin.code-workspace +++ b/vben-admin.code-workspace @@ -1,5 +1,9 @@ { "folders": [ + { + "name": "@vben/backend-mock", + "path": "apps/backend-mock", + }, { "name": "@vben/web-antd", "path": "apps/web-antd",