feat: 添加字典管理功能,包括字典的增删改查接口和管理页面

- 新增字典删除接口 `/api/dictionary/delete`
- 新增字典详情接口 `/api/dictionary/detail`
- 新增字典列表接口 `/api/dictionary/list`
- 新增字典更新接口 `/api/dictionary/update`
- 新增字典服务 `dictionaryService.js`,实现字典的创建、更新、删除和查询功能
- 新增字典管理页面 `dictionary-manage.js`,支持字典的增删改查操作
- 新增管理主页 `index.js`,提供字典管理入口
- 新增示例页面 `page-b.js`,用于验证页面跳转
This commit is contained in:
2026-03-26 17:59:13 +08:00
parent 6490fc427f
commit 836e660842
27 changed files with 1797 additions and 112 deletions

View File

@@ -58,7 +58,7 @@ pocket-base/
- 登录接口:`POST /api/platform/login` - 登录接口:`POST /api/platform/login`
- 平台用户注册时会自动生成 GUID 并写入 `tbl_auth_users.openid` - 平台用户注册时会自动生成 GUID 并写入 `tbl_auth_users.openid`
- 同时写入 `users_idtype = ManagePlatform` - 同时写入 `users_idtype = ManagePlatform`
- 平台登录对前端暴露为 `users_phone + password` - 平台登录对前端暴露为 `login_account + password`,其中 `login_account` 支持邮箱或手机号
- 服务端内部仍使用 PocketBase 原生 password auth以确保返回原生 `token` - 服务端内部仍使用 PocketBase 原生 password auth以确保返回原生 `token`
### 通用认证能力 ### 通用认证能力
@@ -120,6 +120,7 @@ PocketBase JSVM 不会自动读取 `back-end/.env`。当前 Hook 运行配置来
-`back-end/.env` 中的这些值可以作为来源参考,但 **不会被 pb_hooks 自动读取** -`back-end/.env` 中的这些值可以作为来源参考,但 **不会被 pb_hooks 自动读取**
- 如果不方便改 PocketBase 进程环境,可在服务器创建:`pb_hooks/bai_api_pb_hooks/bai_api_shared/config/runtime.js` - 如果不方便改 PocketBase 进程环境,可在服务器创建:`pb_hooks/bai_api_pb_hooks/bai_api_shared/config/runtime.js`
- `runtime.js` 的内容可直接参考 `runtime.example.js` - `runtime.js` 的内容可直接参考 `runtime.example.js`
- 若 hooks 内部需要回源调用 PocketBase REST`auth-with-password``POCKETBASE_API_URL` 不应使用外部 HTTPS 域名,建议使用 PocketBase 进程/容器内可达地址,例如 `http://127.0.0.1:8080`,以避免 Nginx 443 回环失败。
- 当前 Hook 代码已不依赖自定义 JWT因此 `JWT_SECRET``JWT_EXPIRES_IN` 不是运行必需项。 - 当前 Hook 代码已不依赖自定义 JWT因此 `JWT_SECRET``JWT_EXPIRES_IN` 不是运行必需项。
## 额外要求 ## 额外要求

View File

@@ -23,5 +23,10 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/users-count.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/system/refresh-token.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/login.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/login.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/register.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/platform/register.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/login.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`) require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`)

View File

@@ -1,3 +1,4 @@
require(`${__hooks}/bai_web_pb_hooks/pages/index.js`) require(`${__hooks}/bai_web_pb_hooks/pages/index.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/page-a.js`) require(`${__hooks}/bai_web_pb_hooks/pages/page-a.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/page-b.js`) require(`${__hooks}/bai_web_pb_hooks/pages/page-b.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)

View File

@@ -0,0 +1,29 @@
routerAdd('POST', '/api/dictionary/create', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateDictionaryMutationBody(e, false)
const data = dictionaryService.createDictionary(payload)
return success(e, '新增字典成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('新增字典失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '新增字典失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -0,0 +1,29 @@
routerAdd('POST', '/api/dictionary/delete', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateDictionaryDeleteBody(e)
const data = dictionaryService.deleteDictionary(payload.dict_name)
return success(e, '删除字典成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('删除字典失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '删除字典失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -0,0 +1,28 @@
routerAdd('POST', '/api/dictionary/detail', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateDictionaryDetailBody(e)
const data = dictionaryService.getDictionaryByName(payload.dict_name)
return success(e, '查询字典详情成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('查询字典详情失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '查询字典详情失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -0,0 +1,30 @@
routerAdd('POST', '/api/dictionary/list', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateDictionaryListBody(e)
const data = dictionaryService.listDictionaries(payload.keyword)
return success(e, '查询字典列表成功', {
items: data,
})
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('查询字典列表失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '查询字典列表失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -0,0 +1,29 @@
routerAdd('POST', '/api/dictionary/update', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const dictionaryService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/dictionaryService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateDictionaryMutationBody(e, true)
const data = dictionaryService.updateDictionary(payload)
return success(e, '修改字典成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('修改字典失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '修改字典失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -2,6 +2,7 @@ routerAdd('POST', '/api/platform/login', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try { try {
guards.requireJson(e) guards.requireJson(e)
@@ -10,8 +11,7 @@ routerAdd('POST', '/api/platform/login', function (e) {
const payload = guards.validatePlatformLoginBody(e) const payload = guards.validatePlatformLoginBody(e)
const data = userService.authenticatePlatformUser(payload) const data = userService.authenticatePlatformUser(payload)
$apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) return successWithToken(e, '登录成功', data.meta.data, userService.ensureAuthToken(data.token))
return
} catch (err) { } catch (err) {
const status = const status =
(err && typeof err.statusCode === 'number' && err.statusCode) (err && typeof err.statusCode === 'number' && err.statusCode)

View File

@@ -2,6 +2,7 @@ routerAdd('POST', '/api/platform/register', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try { try {
guards.requireJson(e) guards.requireJson(e)
@@ -9,9 +10,9 @@ routerAdd('POST', '/api/platform/register', function (e) {
const payload = guards.validatePlatformRegisterBody(e) const payload = guards.validatePlatformRegisterBody(e)
const data = userService.registerPlatformUser(payload) const data = userService.registerPlatformUser(payload)
const token = userService.issueAuthToken(data.authRecord)
$apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) return successWithToken(e, '注册成功', data.meta.data, token)
return
} catch (err) { } catch (err) {
const status = const status =
(err && typeof err.statusCode === 'number' && err.statusCode) (err && typeof err.statusCode === 'number' && err.statusCode)

View File

@@ -1,8 +1,10 @@
routerAdd('POST', '/api/system/health', function (e) { routerAdd('POST', '/api/system/health', function (e) {
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`) const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
return success(e, '服务运行正常', { return success(e, '服务运行正常', {
status: 'healthy', status: 'healthy',
version: env.appVersion,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}) })
}) })

View File

@@ -2,6 +2,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try { try {
guards.requireJson(e) guards.requireJson(e)
@@ -14,13 +15,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) {
const data = userService.refreshAuthToken(openid) const data = userService.refreshAuthToken(openid)
const token = userService.issueAuthToken(data.authRecord) const token = userService.issueAuthToken(data.authRecord)
return e.json(200, { return successWithToken(e, '刷新成功', {}, token)
code: 200,
msg: '刷新成功',
data: {
token: token,
},
})
} }
if (!payload.users_wx_code) { if (!payload.users_wx_code) {
@@ -34,13 +29,7 @@ routerAdd('POST', '/api/system/refresh-token', function (e) {
const authData = userService.authenticateWechatUser(payload) const authData = userService.authenticateWechatUser(payload)
const token = userService.issueAuthToken(authData.authRecord) const token = userService.issueAuthToken(authData.authRecord)
return e.json(200, { return successWithToken(e, '刷新成功', {}, token)
code: 200,
msg: '刷新成功',
data: {
token: token,
},
})
} catch (err) { } catch (err) {
const status = const status =
(err && typeof err.statusCode === 'number' && err.statusCode) (err && typeof err.statusCode === 'number' && err.statusCode)

View File

@@ -2,6 +2,7 @@ routerAdd('POST', '/api/wechat/login', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`) const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`) const userService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/userService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { successWithToken } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try { try {
guards.requireJson(e) guards.requireJson(e)
@@ -9,9 +10,9 @@ routerAdd('POST', '/api/wechat/login', function (e) {
const payload = guards.validateLoginBody(e) const payload = guards.validateLoginBody(e)
const data = userService.authenticateWechatUser(payload) const data = userService.authenticateWechatUser(payload)
const token = userService.issueAuthToken(data.authRecord)
$apis.recordAuthResponse(e, data.authRecord, data.authMethod, data.meta) return successWithToken(e, data.status === 'register_success' ? '注册成功' : '登录成功', data.meta.data, token)
return
} catch (err) { } catch (err) {
const status = const status =
(err && typeof err.statusCode === 'number' && err.statusCode) (err && typeof err.statusCode === 'number' && err.statusCode)

View File

@@ -20,6 +20,7 @@ function pick(key, fallback) {
module.exports = { module.exports = {
nodeEnv: pick('NODE_ENV', 'production'), nodeEnv: pick('NODE_ENV', 'production'),
apiPrefix: '/api', apiPrefix: '/api',
appVersion: pick('APP_VERSION', 'dev-local'),
wechatAppId: pick('WECHAT_APPID', ''), wechatAppId: pick('WECHAT_APPID', ''),
wechatSecret: pick('WECHAT_SECRET', ''), wechatSecret: pick('WECHAT_SECRET', ''),
pocketbaseApiUrl: pick('POCKETBASE_API_URL', ''), pocketbaseApiUrl: pick('POCKETBASE_API_URL', ''),

View File

@@ -1,7 +1,8 @@
module.exports = { module.exports = {
NODE_ENV: 'production', NODE_ENV: 'production',
APP_VERSION: 'temp-dev-local',
APP_BASE_URL: 'https://bai-api.blv-oa.com', APP_BASE_URL: 'https://bai-api.blv-oa.com',
POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', POCKETBASE_API_URL: 'http://127.0.0.1:8090',
POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo', POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NDEwMTc4NiwiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.67DCKhqRQYF3ClPU_9mBgON_9ZDEy-NzqTeS50rGGZo',
/* WECHAT_APPID: 'wx3bd7a7b19679da7a', /* WECHAT_APPID: 'wx3bd7a7b19679da7a',
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', */ WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', */

View File

@@ -1,7 +1,8 @@
module.exports = { module.exports = {
NODE_ENV: 'production', NODE_ENV: 'production',
APP_VERSION: '0.1.21',
APP_BASE_URL: 'https://bai-api.blv-oa.com', APP_BASE_URL: 'https://bai-api.blv-oa.com',
POCKETBASE_API_URL: 'https://bai-api.blv-oa.com/pb/', POCKETBASE_API_URL: 'http://127.0.0.1:8090',
POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo', POCKETBASE_AUTH_TOKEN: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTgwNTk2MzM4NywiaWQiOiJmcnl2dG1qNnlwcHlmMXAiLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.R34MTotuFBpevqbbUtvQ3GPa0Z1mpY8eQwZgDdmfhMo',
WECHAT_APPID: 'wx3bd7a7b19679da7a', WECHAT_APPID: 'wx3bd7a7b19679da7a',
WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1', WECHAT_SECRET: '57e40438c2a9151257b1927674db10e1',

View File

@@ -50,10 +50,15 @@ function validatePlatformRegisterBody(e) {
function validatePlatformLoginBody(e) { function validatePlatformLoginBody(e) {
const payload = parseBody(e) const payload = parseBody(e)
if (!payload.users_phone) throw createAppError(400, 'users_phone 为必填项') const loginAccount = payload.login_account || payload.users_phone || payload.email || ''
if (!loginAccount) throw createAppError(400, 'login_account 为必填项,支持邮箱或手机号')
if (!payload.password) throw createAppError(400, 'password 为必填项') if (!payload.password) throw createAppError(400, 'password 为必填项')
return payload return {
login_account: loginAccount,
password: payload.password,
}
} }
function validateSystemRefreshBody(e) { function validateSystemRefreshBody(e) {
@@ -74,6 +79,94 @@ function validateSystemRefreshBody(e) {
} }
} }
function requireManagePlatformUser(e) {
const authUser = requireAuthUser(e)
const idType = authUser.authRecord.getString('users_idtype')
if (idType !== 'ManagePlatform') {
throw createAppError(403, '仅平台管理用户可访问')
}
return authUser
}
function validateDictionaryListBody(e) {
const payload = parseBody(e)
return {
keyword: payload.keyword || '',
}
}
function validateDictionaryDetailBody(e) {
const payload = parseBody(e)
if (!payload.dict_name) {
throw createAppError(400, 'dict_name 为必填项')
}
return {
dict_name: payload.dict_name,
}
}
function normalizeDictionaryItem(item, index) {
const current = sanitizePayload(item || {})
if (!current.enum) {
throw createAppError(400, '第 ' + (index + 1) + ' 项 enum 为必填项')
}
if (!current.description) {
throw createAppError(400, '第 ' + (index + 1) + ' 项 description 为必填项')
}
const sortOrderNumber = Number(current.sortOrder)
if (!Number.isFinite(sortOrderNumber)) {
throw createAppError(400, '第 ' + (index + 1) + ' 项 sortOrder 必须为数字')
}
return {
enum: String(current.enum),
description: String(current.description),
sortOrder: sortOrderNumber,
}
}
function validateDictionaryMutationBody(e, isUpdate) {
const payload = parseBody(e)
if (!payload.dict_name) {
throw createAppError(400, 'dict_name 为必填项')
}
const items = Array.isArray(payload.items) ? payload.items : []
if (!items.length) {
throw createAppError(400, 'items 至少需要一项')
}
const normalizedItems = items.map(function (item, index) {
return normalizeDictionaryItem(item, index)
})
return {
dict_name: payload.dict_name,
dict_word_is_enabled: typeof payload.dict_word_is_enabled === 'boolean' ? payload.dict_word_is_enabled : true,
dict_word_parent_id: payload.dict_word_parent_id || '',
dict_word_remark: payload.dict_word_remark || '',
items: normalizedItems,
original_dict_name: isUpdate ? (payload.original_dict_name || payload.dict_name) : '',
}
}
function validateDictionaryDeleteBody(e) {
const payload = parseBody(e)
if (!payload.dict_name) {
throw createAppError(400, 'dict_name 为必填项')
}
return {
dict_name: payload.dict_name,
}
}
function requireAuthOpenid(e) { function requireAuthOpenid(e) {
if (!e.auth) { if (!e.auth) {
throw createAppError(401, '认证令牌无效或已过期') throw createAppError(401, '认证令牌无效或已过期')
@@ -138,6 +231,11 @@ module.exports = {
validatePlatformRegisterBody, validatePlatformRegisterBody,
validatePlatformLoginBody, validatePlatformLoginBody,
validateSystemRefreshBody, validateSystemRefreshBody,
requireManagePlatformUser,
validateDictionaryListBody,
validateDictionaryDetailBody,
validateDictionaryMutationBody,
validateDictionaryDeleteBody,
requireAuthOpenid, requireAuthOpenid,
requireAuthUser, requireAuthUser,
duplicateGuard, duplicateGuard,

View File

@@ -0,0 +1,200 @@
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
function buildSystemDictId() {
return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6)
}
function safeJsonParse(text, fallback) {
if (!text) return fallback
try {
return JSON.parse(text)
} catch (err) {
return fallback
}
}
function normalizeItemsFromRecord(record) {
const enums = safeJsonParse(record.getString('dict_word_enum'), [])
const descriptions = safeJsonParse(record.getString('dict_word_description'), [])
const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), [])
const maxLength = Math.max(enums.length, descriptions.length, sortOrders.length)
const items = []
for (let i = 0; i < maxLength; i += 1) {
if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof sortOrders[i] === 'undefined') {
continue
}
items.push({
enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]),
description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]),
sortOrder: Number(sortOrders[i] || 0),
})
}
return items
}
function exportDictionaryRecord(record) {
return {
pb_id: record.id,
system_dict_id: record.getString('system_dict_id'),
dict_name: record.getString('dict_name'),
dict_word_is_enabled: !!record.get('dict_word_is_enabled'),
dict_word_parent_id: record.getString('dict_word_parent_id'),
dict_word_remark: record.getString('dict_word_remark'),
items: normalizeItemsFromRecord(record),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
}
function findDictionaryByName(dictName) {
const records = $app.findRecordsByFilter('tbl_system_dict', 'dict_name = {:dictName}', '', 1, 0, {
dictName: dictName,
})
return records.length ? records[0] : null
}
function ensureDictionaryNameUnique(dictName, excludeId) {
const existing = findDictionaryByName(dictName)
if (existing && existing.id !== excludeId) {
throw createAppError(400, 'dict_name 已存在')
}
}
function fillDictionaryItems(record, items) {
const enums = []
const descriptions = []
const sortOrders = []
for (let i = 0; i < items.length; i += 1) {
enums.push(items[i].enum)
descriptions.push(items[i].description)
sortOrders.push(items[i].sortOrder)
}
record.set('dict_word_enum', JSON.stringify(enums))
record.set('dict_word_description', JSON.stringify(descriptions))
record.set('dict_word_sort_order', JSON.stringify(sortOrders))
}
function listDictionaries(keyword) {
const allRecords = $app.findRecordsByFilter('tbl_system_dict', '', 'dict_name', 500, 0)
const normalizedKeyword = String(keyword || '').toLowerCase()
const result = []
for (let i = 0; i < allRecords.length; i += 1) {
const item = exportDictionaryRecord(allRecords[i])
if (!normalizedKeyword || item.dict_name.toLowerCase().indexOf(normalizedKeyword) !== -1) {
result.push(item)
}
}
return result
}
function getDictionaryByName(dictName) {
const record = findDictionaryByName(dictName)
if (!record) {
throw createAppError(404, '未找到对应字典')
}
return exportDictionaryRecord(record)
}
function createDictionary(payload) {
ensureDictionaryNameUnique(payload.dict_name)
const collection = $app.findCollectionByNameOrId('tbl_system_dict')
const record = new Record(collection)
record.set('system_dict_id', buildSystemDictId())
record.set('dict_name', payload.dict_name)
record.set('dict_word_is_enabled', payload.dict_word_is_enabled)
record.set('dict_word_parent_id', payload.dict_word_parent_id)
record.set('dict_word_remark', payload.dict_word_remark)
fillDictionaryItems(record, payload.items)
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '创建字典失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('字典创建成功', {
dict_name: payload.dict_name,
system_dict_id: record.getString('system_dict_id'),
})
return exportDictionaryRecord(record)
}
function updateDictionary(payload) {
const record = findDictionaryByName(payload.original_dict_name)
if (!record) {
throw createAppError(404, '未找到待修改的字典')
}
ensureDictionaryNameUnique(payload.dict_name, record.id)
record.set('dict_name', payload.dict_name)
record.set('dict_word_is_enabled', payload.dict_word_is_enabled)
record.set('dict_word_parent_id', payload.dict_word_parent_id)
record.set('dict_word_remark', payload.dict_word_remark)
fillDictionaryItems(record, payload.items)
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '修改字典失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('字典修改成功', {
dict_name: payload.dict_name,
original_dict_name: payload.original_dict_name,
})
return exportDictionaryRecord(record)
}
function deleteDictionary(dictName) {
const record = findDictionaryByName(dictName)
if (!record) {
throw createAppError(404, '未找到待删除的字典')
}
try {
$app.delete(record)
} catch (err) {
throw createAppError(400, '删除字典失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('字典删除成功', {
dict_name: dictName,
system_dict_id: record.getString('system_dict_id'),
})
return {
dict_name: dictName,
}
}
module.exports = {
listDictionaries,
getDictionaryByName,
createDictionary,
updateDictionary,
deleteDictionary,
}

View File

@@ -1,6 +1,7 @@
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`) const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`) const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
const wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`) const wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`)
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const GUEST_USER_TYPE = '游客' const GUEST_USER_TYPE = '游客'
const REGISTERED_USER_TYPE = '注册用户' const REGISTERED_USER_TYPE = '注册用户'
@@ -64,6 +65,48 @@ function findUserByPhone(usersPhone) {
return records.length ? records[0] : null return records.length ? records[0] : null
} }
function findUserByEmail(email) {
const records = $app.findRecordsByFilter('tbl_auth_users', 'email = {:email}', '', 1, 0, {
email: email,
})
return records.length ? records[0] : null
}
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || ''))
}
function parseHttpJsonResponse(response) {
if (!response) {
throw createAppError(500, 'PocketBase 认证响应为空')
}
if (response.json && typeof response.json === 'object') {
return response.json
}
if (typeof response.body === 'string' && response.body) {
return JSON.parse(response.body)
}
if (response.body && typeof response.body === 'object') {
return response.body
}
if (typeof response.data === 'string' && response.data) {
return JSON.parse(response.data)
}
if (response.data && typeof response.data === 'object') {
return response.data
}
throw createAppError(500, 'PocketBase 认证响应体为空', {
statusCode: response.statusCode || 0,
responseKeys: Object.keys(response || {}),
})
}
function getCompanyByCompanyId(companyId) { function getCompanyByCompanyId(companyId) {
if (!companyId) return null if (!companyId) return null
const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, { const records = $app.findRecordsByFilter('tbl_company', 'company_id = {:companyId}', '', 1, 0, {
@@ -248,7 +291,7 @@ function registerPlatformUser(payload) {
record.set('users_promo_code', payload.users_promo_code || '') record.set('users_promo_code', payload.users_promo_code || '')
record.set('usergroups_id', payload.usergroups_id || '') record.set('usergroups_id', payload.usergroups_id || '')
record.set('users_auth_type', 0) record.set('users_auth_type', 0)
record.set('email', platformOpenid + '@manage.local') record.set('email', payload.email || (platformOpenid + '@manage.local'))
record.setPassword(payload.password) record.setPassword(payload.password)
record.set('passwordConfirm', payload.passwordConfirm) record.set('passwordConfirm', payload.passwordConfirm)
@@ -278,14 +321,18 @@ function registerPlatformUser(payload) {
} }
function authenticatePlatformUser(payload) { function authenticatePlatformUser(payload) {
return withUserLock('platform-login:' + payload.users_phone, function () { return withUserLock('platform-login:' + payload.login_account, function () {
const userRecord = findUserByPhone(payload.users_phone) const loginAccount = String(payload.login_account || '')
const userRecord = isEmail(loginAccount)
? findUserByEmail(loginAccount)
: findUserByPhone(loginAccount)
if (!userRecord) { if (!userRecord) {
throw createAppError(404, '平台用户不存在') throw createAppError(404, '平台用户不存在')
} }
if (userRecord.getString('users_idtype') !== MANAGE_PLATFORM_ID_TYPE) { if (userRecord.getString('users_idtype') !== MANAGE_PLATFORM_ID_TYPE) {
throw createAppError(400, '当前手机号对应的不是平台用户') throw createAppError(400, '当前登录账号对应的不是平台用户')
} }
const identity = userRecord.getString('email') const identity = userRecord.getString('email')
@@ -293,8 +340,32 @@ function authenticatePlatformUser(payload) {
throw createAppError(500, '平台用户缺少原生登录标识') throw createAppError(500, '平台用户缺少原生登录标识')
} }
const authData = $apis.recordAuthWithPassword('tbl_auth_users', identity, payload.password) const baseUrl = String(env.pocketbaseApiUrl || '').replace(/\/+$/, '')
const authRecord = authData.record || userRecord if (!baseUrl) {
throw createAppError(500, '缺少 POCKETBASE_API_URL 配置')
}
const authResponse = $http.send({
url: baseUrl + '/api/collections/tbl_auth_users/auth-with-password',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identity: identity,
password: payload.password,
}),
})
if (!authResponse || authResponse.statusCode < 200 || authResponse.statusCode >= 300) {
throw createAppError(400, '平台登录失败', {
statusCode: authResponse ? authResponse.statusCode : 0,
body: authResponse ? String(authResponse.body || '') : '',
})
}
const authData = parseHttpJsonResponse(authResponse)
const authRecord = userRecord
const user = enrichUser(authRecord) const user = enrichUser(authRecord)
@@ -302,6 +373,7 @@ function authenticatePlatformUser(payload) {
users_id: user.users_id, users_id: user.users_id,
openid: user.openid, openid: user.openid,
users_idtype: user.users_idtype, users_idtype: user.users_idtype,
login_account: loginAccount,
}) })
return { return {
@@ -310,6 +382,8 @@ function authenticatePlatformUser(payload) {
user: user, user: user,
authRecord: authRecord, authRecord: authRecord,
authMethod: '', authMethod: '',
token: issueAuthToken(authRecord),
pbRecord: authData.record || null,
meta: buildAuthMeta({ meta: buildAuthMeta({
status: 'login_success', status: 'login_success',
is_info_complete: isInfoComplete(authRecord), is_info_complete: isInfoComplete(authRecord),
@@ -409,9 +483,18 @@ function issueAuthToken(authRecord) {
return token return token
} }
function ensureAuthToken(token) {
if (!token) {
throw createAppError(500, '签发令牌失败:生成 token 为空')
}
return token
}
module.exports = { module.exports = {
authenticateWechatUser, authenticateWechatUser,
authenticatePlatformUser, authenticatePlatformUser,
ensureAuthToken,
updateWechatUserProfile, updateWechatUserProfile,
refreshAuthToken, refreshAuthToken,
issueAuthToken, issueAuthToken,

View File

@@ -6,6 +6,22 @@ function success(e, msg, data, code) {
}) })
} }
function successWithToken(e, msg, data, token, code) {
const statusCode = code || 200
const payload = {
code: statusCode,
msg: msg || '操作成功',
data: data || {},
}
if (token) {
payload.token = token
}
return e.json(statusCode, payload)
}
module.exports = { module.exports = {
success, success,
successWithToken,
} }

View File

@@ -0,0 +1,468 @@
routerAdd('GET', '/manage/dictionary-manage', function (e) {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>字典管理</title>
<script>
(function () {
var token = localStorage.getItem('pb_manage_token') || ''
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
if (!token || !isLoggedIn) {
window.location.replace('/pb/manage/login')
}
})()
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
.container { max-width: 1240px; margin: 0 auto; padding: 32px 20px 60px; }
.topbar, .panel, .modal-card { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
.topbar { border-radius: 24px; padding: 24px; margin-bottom: 24px; }
.topbar h1 { margin: 0 0 8px; font-size: 30px; }
.topbar p { margin: 0; color: #4b5563; line-height: 1.7; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; }
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-secondary { background: #e0e7ff; color: #1e3a8a; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
.panel { border-radius: 24px; padding: 24px; }
.toolbar { display: grid; grid-template-columns: 1.3fr 1fr 1fr auto auto; gap: 12px; margin-bottom: 18px; }
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px 12px; font-size: 14px; background: #fff; }
textarea { min-height: 88px; resize: vertical; }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 16px; }
thead { background: #eff6ff; }
th, td { padding: 14px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; vertical-align: top; }
th { font-size: 13px; color: #475569; }
tr:hover td { background: #f8fafc; }
.badge { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.badge-on { background: #dcfce7; color: #166534; }
.badge-off { background: #fee2e2; color: #991b1b; }
.muted { color: #64748b; font-size: 13px; }
.inline-input { min-width: 120px; }
.link { color: #2563eb; text-decoration: none; font-weight: 600; }
.status { margin: 14px 0 0; min-height: 24px; font-size: 14px; }
.status.success { color: #15803d; }
.status.error { color: #b91c1c; }
.auth-box { display: grid; grid-template-columns: 1fr auto; gap: 12px; margin-top: 18px; }
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.4); padding: 20px; }
.modal.show { display: flex; }
.modal-card { width: min(920px, 100%); border-radius: 24px; padding: 24px; max-height: 90vh; overflow: auto; }
.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.modal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.full { grid-column: 1 / -1; }
.item-table { margin-top: 16px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
.item-table table { border-radius: 0; }
.modal-actions { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.empty { text-align: center; padding: 32px 16px; color: #64748b; }
@media (max-width: 960px) {
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
td { display: flex; justify-content: space-between; gap: 12px; }
td::before { content: attr(data-label); font-weight: 700; color: #475569; }
}
</style>
</head>
<body>
<div class="container">
<section class="topbar">
<h1>字典管理</h1>
<p>页面仅允许 ManagePlatform 用户操作。当前请求会自动使用登录页保存到 localStorage 的 token。</p>
<div class="actions">
<a class="btn btn-light" href="/pb/manage">返回主页</a>
<a class="btn btn-light" href="/pb/manage/login">登录页</a>
<button class="btn btn-primary" id="createBtn" type="button">新增字典</button>
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
</div>
<div class="status" id="status"></div>
</section>
<section class="panel">
<div class="toolbar">
<input id="keywordInput" placeholder="按 dict_name 模糊搜索" />
<input id="detailInput" placeholder="查询指定 dict_name" />
<button class="btn btn-secondary" id="listBtn" type="button">查询全部</button>
<button class="btn btn-light" id="detailBtn" type="button">查询指定</button>
<button class="btn btn-light" id="resetBtn" type="button">重置</button>
</div>
<div style="overflow:auto;">
<table>
<thead>
<tr>
<th>dict_name</th>
<th>启用</th>
<th>备注</th>
<th>parent_id</th>
<th>枚举项</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="7" class="empty">暂无数据,请先查询。</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="modal" id="editorModal">
<div class="modal-card">
<div class="modal-header">
<div>
<h2 id="modalTitle" style="margin:0;">新增字典</h2>
<div class="muted">三个聚合字段将以 JSON 字符串形式保存,并在读取时自动还原为 items。</div>
</div>
<button class="btn btn-light" id="closeModalBtn" type="button">关闭</button>
</div>
<div class="modal-grid">
<div>
<label>dict_name</label>
<input id="dictNameInput" />
</div>
<div>
<label>dict_word_parent_id</label>
<input id="parentIdInput" />
</div>
<div>
<label>dict_word_is_enabled</label>
<select id="enabledInput">
<option value="true">启用</option>
<option value="false">禁用</option>
</select>
</div>
<div class="full">
<label>dict_word_remark</label>
<textarea id="remarkInput"></textarea>
</div>
</div>
<div class="item-table">
<table>
<thead>
<tr>
<th>枚举值</th>
<th>描述</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
<div class="modal-actions">
<div>
<button class="btn btn-secondary" id="addItemBtn" type="button">新增枚举项</button>
</div>
<div>
<button class="btn btn-light" id="cancelBtn" type="button">取消</button>
<button class="btn btn-primary" id="saveBtn" type="button">保存</button>
</div>
</div>
</div>
</div>
<script>
const API_BASE = '/pb/api'
const tokenKey = 'pb_manage_token'
const state = {
list: [],
mode: 'create',
editingOriginalName: '',
items: [],
}
const statusEl = document.getElementById('status')
const keywordInput = document.getElementById('keywordInput')
const detailInput = document.getElementById('detailInput')
const tableBody = document.getElementById('tableBody')
const editorModal = document.getElementById('editorModal')
const modalTitle = document.getElementById('modalTitle')
const dictNameInput = document.getElementById('dictNameInput')
const parentIdInput = document.getElementById('parentIdInput')
const enabledInput = document.getElementById('enabledInput')
const remarkInput = document.getElementById('remarkInput')
const itemsBody = document.getElementById('itemsBody')
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
}
function getToken() {
return localStorage.getItem(tokenKey) || ''
}
async function request(url, payload) {
const token = getToken()
if (!token) {
localStorage.removeItem('pb_manage_logged_in')
window.location.replace('/pb/manage/login')
throw new Error('登录状态已失效,请重新登录')
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify(payload || {}),
})
const data = await res.json()
if (!res.ok || data.code >= 400) {
if (res.status === 401 || res.status === 403 || data.code === 401 || data.code === 403) {
localStorage.removeItem('pb_manage_token')
localStorage.removeItem('pb_manage_logged_in')
localStorage.removeItem('pb_manage_login_account')
localStorage.removeItem('pb_manage_login_time')
window.location.replace('/pb/manage/login')
}
throw new Error(data.msg || '请求失败')
}
return data.data
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderItemsPreview(items) {
if (!items || !items.length) return '<span class="muted">无</span>'
return items.map(function (item) {
return '<div><strong>' + escapeHtml(item.enum) + '</strong> → ' + escapeHtml(item.description) + '(排序 ' + escapeHtml(item.sortOrder) + '</div>'
}).join('')
}
function renderTable(list) {
if (!list.length) {
tableBody.innerHTML = '<tr><td colspan="7" class="empty">暂无匹配数据。</td></tr>'
return
}
tableBody.innerHTML = list.map(function (item) {
const enabledClass = item.dict_word_is_enabled ? 'badge badge-on' : 'badge badge-off'
const enabledText = item.dict_word_is_enabled ? '启用' : '禁用'
return '<tr>'
+ '<td data-label="dict_name"><input class="inline-input" data-field="dict_name" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_name) + '" /></td>'
+ '<td data-label="启用"><select class="inline-input" data-field="dict_word_is_enabled" data-name="' + escapeHtml(item.dict_name) + '"><option value="true"' + (item.dict_word_is_enabled ? ' selected' : '') + '>启用</option><option value="false"' + (!item.dict_word_is_enabled ? ' selected' : '') + '>禁用</option></select><div class="' + enabledClass + '" style="margin-top:8px;">' + enabledText + '</div></td>'
+ '<td data-label="备注"><textarea class="inline-input" data-field="dict_word_remark" data-name="' + escapeHtml(item.dict_name) + '">' + escapeHtml(item.dict_word_remark) + '</textarea></td>'
+ '<td data-label="parent_id"><input class="inline-input" data-field="dict_word_parent_id" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_word_parent_id) + '" /></td>'
+ '<td data-label="枚举项">' + renderItemsPreview(item.items) + '</td>'
+ '<td data-label="创建时间"><span class="muted">' + escapeHtml(item.created) + '</span></td>'
+ '<td data-label="操作">'
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;">'
+ '<button class="btn btn-light" type="button" onclick="window.__editItems(\\'' + encodeURIComponent(item.dict_name) + '\\')">编辑枚举</button>'
+ '<button class="btn btn-secondary" type="button" onclick="window.__saveInline(\\'' + encodeURIComponent(item.dict_name) + '\\')">保存行</button>'
+ '<button class="btn btn-danger" type="button" onclick="window.__deleteRow(\\'' + encodeURIComponent(item.dict_name) + '\\')">删除</button>'
+ '</div>'
+ '</td>'
+ '</tr>'
}).join('')
}
function openModal(mode, record) {
state.mode = mode
state.editingOriginalName = record ? record.dict_name : ''
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
dictNameInput.value = record ? record.dict_name : ''
parentIdInput.value = record ? (record.dict_word_parent_id || '') : ''
enabledInput.value = record && !record.dict_word_is_enabled ? 'false' : 'true'
remarkInput.value = record ? (record.dict_word_remark || '') : ''
state.items = record && Array.isArray(record.items) && record.items.length
? record.items.map(function (item) { return { enum: item.enum, description: item.description, sortOrder: item.sortOrder } })
: [{ enum: '', description: '', sortOrder: 1 }]
renderItemsEditor()
editorModal.classList.add('show')
}
function closeModal() {
editorModal.classList.remove('show')
}
function renderItemsEditor() {
itemsBody.innerHTML = state.items.map(function (item, index) {
return '<tr>'
+ '<td><input data-item-field="enum" data-index="' + index + '" value="' + escapeHtml(item.enum) + '" /></td>'
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
+ '<td><input type="number" data-item-field="sortOrder" data-index="' + index + '" value="' + escapeHtml(item.sortOrder) + '" /></td>'
+ '<td><button class="btn btn-danger" type="button" onclick="window.__removeItem(' + index + ')">删除</button></td>'
+ '</tr>'
}).join('')
}
function collectItemsFromEditor() {
const rows = Array.from(itemsBody.querySelectorAll('tr'))
return rows.map(function (row, index) {
const enumInput = row.querySelector('[data-item-field="enum"]')
const descriptionInput = row.querySelector('[data-item-field="description"]')
const sortOrderInput = row.querySelector('[data-item-field="sortOrder"]')
return {
enum: enumInput.value.trim(),
description: descriptionInput.value.trim(),
sortOrder: Number(sortOrderInput.value || index + 1),
}
})
}
async function loadList() {
setStatus('正在查询字典列表...', '')
try {
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
state.list = data.items || []
renderTable(state.list)
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
} catch (err) {
setStatus(err.message || '查询失败', 'error')
}
}
async function loadDetail() {
const dictName = detailInput.value.trim()
if (!dictName) {
setStatus('请输入 dict_name', 'error')
return
}
setStatus('正在查询字典详情...', '')
try {
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
state.list = [data]
renderTable(state.list)
setStatus('查询详情成功。', 'success')
} catch (err) {
setStatus(err.message || '查询失败', 'error')
}
}
async function saveModalRecord() {
const items = collectItemsFromEditor()
const payload = {
dict_name: dictNameInput.value.trim(),
original_dict_name: state.editingOriginalName,
dict_word_parent_id: parentIdInput.value.trim(),
dict_word_is_enabled: enabledInput.value === 'true',
dict_word_remark: remarkInput.value.trim(),
items: items,
}
setStatus('正在保存字典...', '')
try {
await request(state.mode === 'create' ? API_BASE + '/dictionary/create' : API_BASE + '/dictionary/update', payload)
closeModal()
await loadList()
setStatus(state.mode === 'create' ? '新增成功。' : '修改成功。', 'success')
} catch (err) {
setStatus(err.message || '保存失败', 'error')
}
}
async function saveInline(dictName) {
const targetName = decodeURIComponent(dictName)
const row = Array.from(tableBody.querySelectorAll('[data-name]')).filter(function (el) {
return el.getAttribute('data-name') === targetName
})
const record = state.list.find(function (item) { return item.dict_name === targetName })
if (!record) {
setStatus('未找到对应记录', 'error')
return
}
const payload = {
original_dict_name: targetName,
dict_name: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_name' }) || {}).value || targetName,
dict_word_is_enabled: ((row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_is_enabled' }) || {}).value || 'true') === 'true',
dict_word_remark: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_remark' }) || {}).value || '',
dict_word_parent_id: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_parent_id' }) || {}).value || '',
items: record.items || [],
}
setStatus('正在保存行数据...', '')
try {
await request(API_BASE + '/dictionary/update', payload)
await loadList()
setStatus('行内保存成功。', 'success')
} catch (err) {
setStatus(err.message || '保存失败', 'error')
}
}
async function deleteRow(dictName) {
const targetName = decodeURIComponent(dictName)
if (!window.confirm('确认删除字典「' + targetName + '」吗?此操作不可恢复。')) {
return
}
setStatus('正在删除字典...', '')
try {
await request(API_BASE + '/dictionary/delete', { dict_name: targetName })
await loadList()
setStatus('删除成功。', 'success')
} catch (err) {
setStatus(err.message || '删除失败', 'error')
}
}
window.__editItems = function (dictName) {
const targetName = decodeURIComponent(dictName)
const record = state.list.find(function (item) { return item.dict_name === targetName })
if (!record) {
setStatus('未找到对应字典', 'error')
return
}
openModal('edit', record)
}
window.__saveInline = saveInline
window.__deleteRow = deleteRow
window.__removeItem = function (index) {
state.items.splice(index, 1)
if (!state.items.length) {
state.items.push({ enum: '', description: '', sortOrder: 1 })
}
renderItemsEditor()
}
document.getElementById('listBtn').addEventListener('click', loadList)
document.getElementById('detailBtn').addEventListener('click', loadDetail)
document.getElementById('resetBtn').addEventListener('click', function () {
keywordInput.value = ''
detailInput.value = ''
state.list = []
renderTable([])
setStatus('已重置查询条件。', 'success')
})
document.getElementById('createBtn').addEventListener('click', function () { openModal('create') })
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
document.getElementById('cancelBtn').addEventListener('click', closeModal)
document.getElementById('addItemBtn').addEventListener('click', function () {
state.items.push({ enum: '', description: '', sortOrder: state.items.length + 1 })
renderItemsEditor()
})
document.getElementById('saveBtn').addEventListener('click', saveModalRecord)
document.getElementById('logoutBtn').addEventListener('click', function () {
localStorage.removeItem('pb_manage_token')
localStorage.removeItem('pb_manage_logged_in')
localStorage.removeItem('pb_manage_login_account')
localStorage.removeItem('pb_manage_login_time')
window.location.replace('/pb/manage/login')
})
</script>
</body>
</html>`
return e.html(200, html)
})

View File

@@ -0,0 +1,71 @@
routerAdd('GET', '/manage', function (e) {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理主页</title>
<script>
(function () {
var token = localStorage.getItem('pb_manage_token') || ''
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
if (!token || !isLoggedIn) {
window.location.replace('/pb/manage/login')
}
})()
</script>
<style>
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
.wrap { max-width: 960px; margin: 0 auto; padding: 48px 20px; }
.hero { background: #ffffff; border-radius: 24px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 36px; border: 1px solid #e5e7eb; }
h1 { margin: 0 0 12px; font-size: 32px; }
p { color: #4b5563; line-height: 1.8; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-top: 28px; }
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 18px; padding: 22px; }
.card h2 { margin: 0 0 10px; font-size: 20px; }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 16px; border-radius: 12px; text-decoration: none; background: #2563eb; color: #fff; font-weight: 600; margin-top: 12px; }
.notice { margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #eff6ff; color: #1d4ed8; font-size: 14px; }
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<h1>管理主页</h1>
<p>该页面仅供已登录的 ManagePlatform 用户使用。你可以从这里跳转到各个管理子页面。</p>
<div class="notice">如未登录或 token 无效,系统会自动跳转到登录页。</div>
<div class="grid">
<article class="card">
<h2>字典管理</h2>
<p>维护 tbl_system_dict支持模糊查询、行内编辑、枚举项弹窗编辑、新增和删除。</p>
<a class="btn" href="/pb/manage/dictionary-manage">进入字典管理</a>
</article>
<article class="card">
<h2>登录页</h2>
<p>登录入口页。若已登录,访问该页会自动跳回管理主页。</p>
<a class="btn" href="/pb/manage/login">打开登录页</a>
</article>
<article class="card">
<h2>页面二</h2>
<p>第二个示例子页面,便于验证主页跳转与 hooks 页面导航。</p>
<a class="btn" href="/pb/manage/page-b">进入页面二</a>
</article>
</div>
<div style="margin-top:16px;">
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>
</div>
</section>
</main>
<script>
document.getElementById('logoutBtn').addEventListener('click', function () {
localStorage.removeItem('pb_manage_token')
localStorage.removeItem('pb_manage_logged_in')
localStorage.removeItem('pb_manage_login_account')
localStorage.removeItem('pb_manage_login_time')
window.location.replace('/pb/manage/login')
})
</script>
</body>
</html>`
return e.html(200, html)
})

View File

@@ -1,52 +1,158 @@
routerAdd('GET', '/web/page-a', function (e) { function renderLoginPage(e) {
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>页面一</title> <title>登录</title>
<script>
(function () {
var token = localStorage.getItem('pb_manage_token') || ''
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
if (token && isLoggedIn) {
window.location.replace('/pb/manage')
}
})()
</script>
<style> <style>
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f8fafc; background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%);
color: #1f2937; color: #1f2937;
} }
.wrap { .wrap {
max-width: 760px; max-width: 420px;
margin: 0 auto; margin: 0 auto;
padding: 48px 20px; padding: 72px 20px;
} }
.card { .card {
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid #dbe3f0;
border-radius: 18px; border-radius: 18px;
padding: 28px; padding: 28px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
} }
h1 { margin-top: 0; } h1 { margin-top: 0; }
p { line-height: 1.8; color: #4b5563; } p { line-height: 1.8; color: #4b5563; margin-bottom: 20px; }
.actions { margin-top: 20px; } .field { margin-bottom: 14px; }
a { .field label { display: block; margin-bottom: 8px; color: #334155; font-weight: 600; }
text-decoration: none; .field input {
color: #2563eb; width: 100%;
margin-right: 16px; border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 10px 12px;
font-size: 14px;
box-sizing: border-box;
} }
.btn {
width: 100%;
border: none;
border-radius: 12px;
padding: 11px 14px;
font-size: 15px;
font-weight: 700;
background: #2563eb;
color: #fff;
cursor: pointer;
}
.status { margin-top: 12px; min-height: 22px; font-size: 14px; }
.status.error { color: #b91c1c; }
.status.success { color: #15803d; }
</style> </style>
</head> </head>
<body> <body>
<main class="wrap"> <main class="wrap">
<section class="card"> <section class="card">
<h1>页面一</h1> <h1>后台登录</h1>
<p>这里是 page-a 页面。后续你可以把它扩展成介绍页、帮助页、数据展示页或简单表单页。</p> <p>请输入平台账号(邮箱或手机号)与密码,登录成功后会自动跳转到管理首页。</p>
<div class="actions"> <form id="loginForm">
<a href="/web">返回首页</a> <div class="field">
<a href="/web/page-b">跳转到页面二</a> <label for="account">登录账号</label>
<input id="account" name="account" placeholder="邮箱或手机号" required />
</div> </div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" placeholder="请输入密码" required />
</div>
<button class="btn" id="submitBtn" type="submit">登录</button>
</form>
<div id="status" class="status"></div>
</section> </section>
</main> </main>
<script>
(function () {
var API_BASE = '/pb/api'
var form = document.getElementById('loginForm')
var accountInput = document.getElementById('account')
var passwordInput = document.getElementById('password')
var statusEl = document.getElementById('status')
var submitBtn = document.getElementById('submitBtn')
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
}
form.addEventListener('submit', async function (event) {
event.preventDefault()
var loginAccount = accountInput.value.trim()
var password = passwordInput.value
if (!loginAccount || !password) {
setStatus('请填写账号和密码', 'error')
return
}
submitBtn.disabled = true
submitBtn.textContent = '登录中...'
setStatus('正在登录,请稍候...', '')
try {
var response = await fetch(API_BASE + '/platform/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
login_account: loginAccount,
password: password,
}),
})
var result = await response.json()
if (!response.ok || !result || result.code >= 400) {
throw new Error((result && result.msg) || '登录失败')
}
var token = result.token || (result.data && result.data.token) || ''
if (!token) {
throw new Error('登录成功但未获取到 token')
}
localStorage.setItem('pb_manage_token', token)
localStorage.setItem('pb_manage_logged_in', '1')
localStorage.setItem('pb_manage_login_account', loginAccount)
localStorage.setItem('pb_manage_login_time', new Date().toISOString())
setStatus('登录成功,正在跳转...', 'success')
window.location.replace('/pb/manage')
} catch (error) {
localStorage.removeItem('pb_manage_token')
localStorage.removeItem('pb_manage_logged_in')
setStatus(error.message || '登录失败', 'error')
} finally {
submitBtn.disabled = false
submitBtn.textContent = '登录'
}
})
})()
</script>
</body> </body>
</html>` </html>`
return e.html(200, html) return e.html(200, html)
}) }
routerAdd('GET', '/manage/login', renderLoginPage)
routerAdd('GET', '/manage/page-a', renderLoginPage)

View File

@@ -0,0 +1,62 @@
routerAdd('GET', '/manage/page-b', function (e) {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>页面二</title>
<script>
(function () {
var token = localStorage.getItem('pb_manage_token') || ''
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
if (!token || !isLoggedIn) {
window.location.replace('/pb/manage/login')
}
})()
</script>
<style>
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%);
color: #1f2937;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 48px 20px;
}
.card {
background: #fff;
border: 1px solid #dbe3f0;
border-radius: 18px;
padding: 28px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
}
h1 { margin-top: 0; }
p { line-height: 1.8; color: #4b5563; }
.actions { margin-top: 20px; display: flex; gap: 16px; flex-wrap: wrap; }
a {
text-decoration: none;
color: #2563eb;
font-weight: 600;
}
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>页面二</h1>
<p>这里是 page-b 页面。当前用于验证 PocketBase hooks 页面路由是否已经正确注册,并可从 manage 首页完成跳转。</p>
<div class="actions">
<a href="/pb/manage">返回首页</a>
<a href="/pb/manage/login">打开登录页</a>
<a href="/pb/manage/dictionary-manage">进入字典管理</a>
</div>
</section>
</main>
</body>
</html>`
return e.html(200, html)
})

View File

@@ -6,7 +6,7 @@
## 范围 ## 范围
本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略与运行规范。 本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。
--- ---
@@ -15,7 +15,7 @@
### 1. 认证体系 ### 1. 认证体系
- 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。 - 保持 PocketBase 原生 auth token 作为唯一正式认证令牌。
- 登录与刷新响应统一通过 `$apis.recordAuthResponse(...)` 返回 PocketBase 原生认证结果 - 登录与刷新响应统一为项目标准结构:`code``msg``data`,认证成功时额外返回顶层 `token`
- `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。 - `authMethod` 统一使用空字符串 `''`,避免触发不必要的 MFA / login alerts 校验。
### 2. Header 规则 ### 2. Header 规则
@@ -139,6 +139,11 @@
- `POST /api/platform/login` - `POST /api/platform/login`
- `POST /api/wechat/login` - `POST /api/wechat/login`
- `POST /api/wechat/profile` - `POST /api/wechat/profile`
- `POST /api/dictionary/list`
- `POST /api/dictionary/detail`
- `POST /api/dictionary/create`
- `POST /api/dictionary/update`
- `POST /api/dictionary/delete`
其中平台用户链路补充为: 其中平台用户链路补充为:
@@ -147,14 +152,15 @@
- body 必填:`users_name``users_phone``password``passwordConfirm``users_picture` - body 必填:`users_name``users_phone``password``passwordConfirm``users_picture`
- 自动生成 GUID 并写入统一身份字段 `openid` - 自动生成 GUID 并写入统一身份字段 `openid`
- 写入 `users_idtype = ManagePlatform` - 写入 `users_idtype = ManagePlatform`
- 成功时返回 PocketBase 原生 token + auth record + meta - 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
### `POST /api/platform/login` ### `POST /api/platform/login`
- body 必填:`users_phone``password` - body 必填:`login_account``password`
- 仅允许 `users_idtype = ManagePlatform` - 仅允许 `users_idtype = ManagePlatform`
- 前端使用手机号+密码提交 - 前端使用邮箱或手机号 + 密码提交
- 服务端内部仍通过 PocketBase 原生 password auth 返回原生 token - 服务端通过 PocketBase `auth-with-password` 校验身份,再由当前 hooks 进程签发正式 token
- 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
其中: 其中:
@@ -164,7 +170,7 @@
- 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段 - 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段
- 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录 - 若不存在 auth 用户则尝试创建 `tbl_auth_users` 记录
- 写入 `users_idtype = WeChat` - 写入 `users_idtype = WeChat`
- 成功时返回 PocketBase 原生 token + auth record + meta - 成功时返回统一结构:`code``msg``data`,并在顶层额外返回 `token`
### `POST /api/wechat/profile` ### `POST /api/wechat/profile`
@@ -179,25 +185,137 @@
- 若 token 仍有效:基于当前 auth record 续签 - 若 token 仍有效:基于当前 auth record 续签
- 若 token 已过期:回退到微信 code 重签流程 - 若 token 已过期:回退到微信 code 重签流程
- 若 token 已过期且未提供 `users_wx_code`,返回:`token已过期请上传users_wx_code` - 若 token 已过期且未提供 `users_wx_code`,返回:`token已过期请上传users_wx_code`
- 返回精简结构,仅返回新 token(不返回完整登录用户信息) - 返回统一结构:`code``msg``data`,并在顶层额外返回新 `token`
- 属于系统级通用认证能力,不限定为微信专属接口 - 属于系统级通用认证能力,不限定为微信专属接口
### 字典管理接口
新增 `dictionary` 分类接口,统一要求平台管理用户访问:
- `POST /api/dictionary/list`
- 支持按 `dict_name` 模糊搜索
- 返回字典全量信息,并将 `dict_word_enum``dict_word_description``dict_word_sort_order` 组装为 `items`
- `POST /api/dictionary/detail`
-`dict_name` 查询单条字典
- `POST /api/dictionary/create`
- 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一
- `POST /api/dictionary/update`
-`original_dict_name` / `dict_name` 更新字典
- `POST /api/dictionary/delete`
-`dict_name` 真删除字典
说明:
- `dict_word_enum``dict_word_description``dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]`
--- ---
## 七、当前已知边界 ## 七、页面与运维辅助能力新增
### 1. PocketBase 页面
新增页面:
- `/web`
- `/web/dictionary-manage`
页面能力:
- 首页支持跳转到子页面
- 字典管理页支持:
- Bearer Token 粘贴与本地保存
- `dict_name` 模糊搜索
- 指定字典查询
- 行内编辑基础字段
- 弹窗编辑枚举项
- 新增 / 删除字典
- 返回主页
### 2. 健康检查版本探针
`POST /api/system/health` 新增:
- `data.version`
用途:
- 通过修改 `APP_VERSION` 判断 hooks 是否已成功部署并生效
配置来源:
- 进程环境变量 `APP_VERSION`
-`runtime.js`
---
## 八、OpenAPI 与 Apifox 调试策略调整
### 1. 统一返回结构
所有对外接口统一返回:
- `code`
- `msg`
- `data`
认证成功类接口额外返回:
- `token`
不再返回以下顶层字段:
- `record`
- `meta`
### 2. 鉴权文档策略
OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authorization` 参数。
统一约定:
- 在 Apifox 环境中配置全局 Header`Authorization: Bearer {{token}}`
- 不再依赖文档中的 Bearer 组件自动注入
此举目的是:
- 避免接口页重复出现局部 `Authorization`
- 统一依赖环境变量完成鉴权注入
---
## 九、当前已知边界
1. `tbl_auth_users` 为 PocketBase `auth` 集合,因此仍受 PocketBase auth 内置规则影响。 1. `tbl_auth_users` 为 PocketBase `auth` 集合,因此仍受 PocketBase auth 内置规则影响。
2. 自定义字段“除 openid 外均可空”已在脚本层按目标放宽,但 auth 集合结构更新仍可能触发 PocketBase 服务端限制。 2. 自定义字段“除 openid 外均可空”已在脚本层按目标放宽,但 auth 集合结构更新仍可能触发 PocketBase 服务端限制。
3. 若线上仍返回 PocketBase 默认 400需要确保最新 hooks 已部署并重启生效。 3. 若线上仍返回 PocketBase 默认 400需要确保最新 hooks 已部署并重启生效。
4. 平台登录通过回源 PocketBase REST 完成密码校验,因此 `POCKETBASE_API_URL` 必须配置为 PocketBase 进程/容器内部可达地址,不应使用外部 HTTPS 域名。
5. Apifox 环境中需自行维护全局 Header`Authorization: Bearer {{token}}`,否则鉴权接口不会自动携带 token。
--- ---
## 、归档建议 ## 、归档建议
部署时至少同步以下文件: 部署时至少同步以下文件:
- `pocket-base/bai-api-main.pb.js` - `pocket-base/bai-api-main.pb.js`
- `pocket-base/bai-web-main.pb.js`
- `pocket-base/bai_api_pb_hooks/` - `pocket-base/bai_api_pb_hooks/`
- `script/pocketbase.newpb.js` - `pocket-base/bai_web_pb_hooks/`
- `pocket-base/spec/openapi.yaml`
- `script/pocketbase.js`
并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。 并在 PocketBase 环境中执行 schema 同步后重启服务,再进行接口验证。
建议归档后的发布核验顺序:
1. `POST /api/system/health`:确认 `data.version`
2. `POST /api/platform/login`:确认返回统一结构与顶层 `token`
3. `POST /api/dictionary/list`:确认鉴权与字典接口可用
---
## 十一、归档状态
- 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。
- 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。

View File

@@ -6,6 +6,7 @@ info:
当前 `tbl_auth_users.openid` 已被定义为全平台统一身份锚点: 当前 `tbl_auth_users.openid` 已被定义为全平台统一身份锚点:
- 微信用户:`openid = 微信 openid` - 微信用户:`openid = 微信 openid`
- 平台用户:`openid = 服务端生成的 GUID` - 平台用户:`openid = 服务端生成的 GUID`
请在 Apifox 环境中统一设置全局 Header`Authorization: Bearer {{token}}`。
version: 1.0.0 version: 1.0.0
servers: servers:
- url: https://bai-api.blv-oa.com/pb - url: https://bai-api.blv-oa.com/pb
@@ -19,12 +20,9 @@ tags:
description: 面向微信用户的认证接口;认证成功后仍统一使用全平台 `openid` 与 PocketBase 原生 token。 description: 面向微信用户的认证接口;认证成功后仍统一使用全平台 `openid` 与 PocketBase 原生 token。
- name: 平台认证 - name: 平台认证
description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。 description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。
- name: 字典管理
description: 面向 ManagePlatform 用户的系统字典维护接口。
components: components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: PocketBaseAuthToken
schemas: schemas:
ApiResponse: ApiResponse:
type: object type: object
@@ -45,6 +43,10 @@ components:
status: status:
type: string type: string
example: healthy example: healthy
version:
type: string
description: 当前已部署 hooks 版本号,用于确认发布是否生效
example: 2026.03.26-health-probe.1
timestamp: timestamp:
type: string type: string
format: date-time format: date-time
@@ -94,7 +96,7 @@ components:
users_id_number: users_id_number:
type: string type: string
users_status: users_status:
type: number type: string
users_rank_level: users_rank_level:
type: number type: number
users_auth_type: users_auth_type:
@@ -132,17 +134,8 @@ components:
PocketBaseAuthResponse: PocketBaseAuthResponse:
type: object type: object
description: | description: |
PocketBase 原生认证响应。 项目统一认证响应。
客户端可直接使用返回 `token` 与 PocketBase SDK 或当前 hooks 接口交互 所有对外接口统一返回 `code`、`msg`、`data`,认证成功时额外返回顶层 `token`
properties:
token:
type: string
description: PocketBase 原生 auth token
record:
type: object
description: PocketBase auth record 原始对象
meta:
type: object
properties: properties:
code: code:
type: integer type: integer
@@ -160,6 +153,36 @@ components:
type: boolean type: boolean
user: user:
$ref: '#/components/schemas/UserInfo' $ref: '#/components/schemas/UserInfo'
token:
type: string
description: PocketBase 原生 auth token仅认证类接口在成功时额外返回
example:
code: 200
msg: 登录成功
data:
status: login_success
is_info_complete: true
user:
pb_id: vtukf6agem2xbcv
users_id: U202603260001
users_idtype: ManagePlatform
users_name: momo
users_phone: '13509214696'
users_phone_masked: '135****4696'
users_status: ''
users_rank_level: 0
users_auth_type: 0
users_type: 注册用户
users_picture: ''
openid: app_momo
company_id: ''
users_parent_id: ''
users_promo_code: ''
usergroups_id: ''
company: null
created: ''
updated: ''
token: eyJhbGciOi...
WechatLoginRequest: WechatLoginRequest:
type: object type: object
required: [users_wx_code] required: [users_wx_code]
@@ -227,12 +250,13 @@ components:
type: string type: string
PlatformLoginRequest: PlatformLoginRequest:
type: object type: object
required: [users_phone, password] required: [login_account, password]
description: 平台用户登录请求体;前端使用手机号+密码提交,服务端内部转换为 PocketBase 原生 password auth。 description: 平台用户登录请求体;前端使用邮箱或手机号 + 密码提交,服务端内部转换为 PocketBase 原生 password auth。
properties: properties:
users_phone: login_account:
type: string type: string
example: 13800138000 description: 支持邮箱或手机号
example: admin@example.com
password: password:
type: string type: string
example: 12345678 example: 12345678
@@ -251,10 +275,89 @@ components:
example: 0a1b2c3d4e5f6g example: 0a1b2c3d4e5f6g
RefreshTokenData: RefreshTokenData:
type: object type: object
properties: {}
DictionaryItem:
type: object
required: [enum, description, sortOrder]
properties: properties:
token: enum:
type: string type: string
description: 新签发的 PocketBase 原生 auth token example: enabled
description:
type: string
example: 启用
sortOrder:
type: integer
example: 1
DictionaryRecord:
type: object
properties:
pb_id:
type: string
system_dict_id:
type: string
dict_name:
type: string
dict_word_is_enabled:
type: boolean
dict_word_parent_id:
type: string
dict_word_remark:
type: string
items:
type: array
items:
$ref: '#/components/schemas/DictionaryItem'
created:
type: string
updated:
type: string
DictionaryListRequest:
type: object
properties:
keyword:
type: string
description: 对 `dict_name` 的模糊搜索关键字
example: 状态
DictionaryDetailRequest:
type: object
required: [dict_name]
properties:
dict_name:
type: string
example: 用户状态
DictionaryMutationRequest:
type: object
required: [dict_name, items]
properties:
original_dict_name:
type: string
description: 更新时用于定位原始记录;新增时可不传
example: 用户状态
dict_name:
type: string
example: 用户状态
dict_word_is_enabled:
type: boolean
example: true
dict_word_parent_id:
type: string
example: ''
dict_word_remark:
type: string
example: 系统状态字典
items:
type: array
minItems: 1
items:
$ref: '#/components/schemas/DictionaryItem'
DictionaryDeleteRequest:
type: object
required: [dict_name]
properties:
dict_name:
type: string
example: 用户状态
paths: paths:
/api/system/test-helloworld: /api/system/test-helloworld:
post: post:
@@ -314,13 +417,6 @@ paths:
1) 若 `Authorization` 对应 token 仍有效:直接按当前 auth record 续签(不调用微信接口)。 1) 若 `Authorization` 对应 token 仍有效:直接按当前 auth record 续签(不调用微信接口)。
2) 若 token 已过期:仅在 body 提供 `users_wx_code` 时才走微信 code 重新签发。 2) 若 token 已过期:仅在 body 提供 `users_wx_code` 时才走微信 code 重新签发。
返回体仅包含新的 `token`,不返回完整登录用户信息。 返回体仅包含新的 `token`,不返回完整登录用户信息。
parameters:
- in: header
name: Authorization
required: false
schema:
type: string
description: 可选。建议传入旧 token`Bearer <token>`)以优先走有效 token 续签路径。
requestBody: requestBody:
required: false required: false
content: content:
@@ -337,8 +433,14 @@ paths:
- $ref: '#/components/schemas/ApiResponse' - $ref: '#/components/schemas/ApiResponse'
- type: object - type: object
properties: properties:
data: token:
$ref: '#/components/schemas/RefreshTokenData' type: string
description: 新签发的 PocketBase 原生 auth token
example:
code: 200
msg: 刷新成功
data: {}
token: eyJhbGciOi...
'400': '400':
description: 参数错误或微信侧身份换取失败 description: 参数错误或微信侧身份换取失败
'401': '401':
@@ -416,9 +518,9 @@ paths:
tags: [平台认证] tags: [平台认证]
summary: 平台用户登录 summary: 平台用户登录
description: | description: |
前端使用平台注册时保存的 `users_phone + password` 登录。 前端使用平台注册时保存的 `邮箱或手机号 + password` 登录。
仅允许 `users_idtype = ManagePlatform` 的用户通过该接口登录。 仅允许 `users_idtype = ManagePlatform` 的用户通过该接口登录。
服务端会先按手机号定位平台用户,再使用该用户的 PocketBase 原生 identity当前为 `email`)执行原生 password auth。 服务端会根据 `login_account` 自动判断邮箱或手机号,并定位平台用户,再使用该用户的 PocketBase 原生 identity当前为 `email`)执行原生 password auth。
登录成功后直接返回 PocketBase 原生 auth token。 登录成功后直接返回 PocketBase 原生 auth token。
requestBody: requestBody:
required: true required: true
@@ -426,6 +528,9 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PlatformLoginRequest' $ref: '#/components/schemas/PlatformLoginRequest'
example:
login_account: 13509214696
password: Momo123456
responses: responses:
'200': '200':
description: 登录成功 description: 登录成功
@@ -433,6 +538,33 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PocketBaseAuthResponse' $ref: '#/components/schemas/PocketBaseAuthResponse'
example:
code: 200
msg: 登录成功
data:
status: login_success
is_info_complete: false
user:
pb_id: vtukf6agem2xbcv
users_id: ''
users_idtype: ManagePlatform
users_name: momo
users_phone: '13509214696'
users_phone_masked: '135****4696'
users_status: ''
users_rank_level: 0
users_auth_type: 0
users_type: ''
users_picture: ''
openid: app_momo
company_id: ''
users_parent_id: ''
users_promo_code: ''
usergroups_id: ''
company: null
created: ''
updated: ''
token: eyJhbGciOi...
'400': '400':
description: 参数错误、密码错误或用户类型不匹配 description: 参数错误、密码错误或用户类型不匹配
'404': '404':
@@ -450,8 +582,6 @@ paths:
description: | description: |
基于当前 `Authorization` 对应 auth record 中的统一 `openid` 定位当前微信用户。 基于当前 `Authorization` 对应 auth record 中的统一 `openid` 定位当前微信用户。
当前接口仍用于微信资料完善场景。 当前接口仍用于微信资料完善场景。
security:
- bearerAuth: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -474,3 +604,187 @@ paths:
description: token 无效或当前 auth record 缺少统一身份字段 openid description: token 无效或当前 auth record 缺少统一身份字段 openid
'400': '400':
description: 参数错误、手机号已被注册或资料更新失败 description: 参数错误、手机号已被注册或资料更新失败
/api/dictionary/list:
post:
tags: [字典管理]
summary: 查询字典列表
description: |
仅允许 `ManagePlatform` 用户访问。
支持按 `dict_name` 模糊搜索,返回字典全量信息,并将三个聚合字段组装为 `items`。
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/DictionaryListRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/DictionaryRecord'
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
/api/dictionary/detail:
post:
tags: [字典管理]
summary: 查询指定字典
description: |
仅允许 `ManagePlatform` 用户访问。
按唯一键 `dict_name` 查询单条字典,并返回组装后的 `items`。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DictionaryDetailRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DictionaryRecord'
'400':
description: 参数错误
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到对应字典
'415':
description: 请求体必须为 application/json
/api/dictionary/create:
post:
tags: [字典管理]
summary: 新增字典
description: |
仅允许 `ManagePlatform` 用户访问。
`system_dict_id` 由服务端自动生成;`dict_name` 必须唯一;
`items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 三个字段。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DictionaryMutationRequest'
responses:
'200':
description: 新增成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DictionaryRecord'
'400':
description: 参数错误或 dict_name 已存在
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/dictionary/update:
post:
tags: [字典管理]
summary: 修改字典
description: |
仅允许 `ManagePlatform` 用户访问。
根据 `original_dict_name`(未传时回退为 `dict_name`)定位原记录并更新。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DictionaryMutationRequest'
responses:
'200':
description: 修改成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DictionaryRecord'
'400':
description: 参数错误或 dict_name 冲突
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到待修改字典
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/dictionary/delete:
post:
tags: [字典管理]
summary: 删除字典
description: |
仅允许 `ManagePlatform` 用户访问。
按 `dict_name` 真删除对应记录。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DictionaryDeleteRequest'
responses:
'200':
description: 删除成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
dict_name:
type: string
'400':
description: 参数错误或删除失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到待删除字典
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁

View File

@@ -15,16 +15,17 @@ const collections = [
type: 'base', type: 'base',
fields: [ fields: [
{ name: 'system_dict_id', type: 'text', required: true }, { name: 'system_dict_id', type: 'text', required: true },
{ name: 'dict_name', type: 'text' }, { name: 'dict_name', type: 'text', required: true },
{ name: 'dict_word_enum', type: 'text' }, { name: 'dict_word_enum', type: 'text' },
{ name: 'dict_word_description', type: 'text' }, { name: 'dict_word_description', type: 'text' },
{ name: 'dict_word_is_enabled', type: 'bool' }, { name: 'dict_word_is_enabled', type: 'bool' },
{ name: 'dict_word_sort_order', type: 'number' }, { name: 'dict_word_sort_order', type: 'text' },
{ name: 'dict_word_parent_id', type: 'text' }, { name: 'dict_word_parent_id', type: 'text' },
{ name: 'dict_word_remark', type: 'text' } { name: 'dict_word_remark', type: 'text' }
], ],
indexes: [ indexes: [
'CREATE UNIQUE INDEX idx_system_dict_id ON tbl_system_dict (system_dict_id)', 'CREATE UNIQUE INDEX idx_system_dict_id ON tbl_system_dict (system_dict_id)',
'CREATE UNIQUE INDEX idx_dict_name ON tbl_system_dict (dict_name)',
'CREATE INDEX idx_dict_word_parent_id ON tbl_system_dict (dict_word_parent_id)' 'CREATE INDEX idx_dict_word_parent_id ON tbl_system_dict (dict_word_parent_id)'
] ]
}, },