feat: 添加 PocketBase MiniApp 公司 API 文档和文件字段迁移脚本

- 新增 openapi-miniapp-company.yaml 文件,定义 tbl_company 的基础 CRUD 接口文档,包括查询、创建、更新和删除公司记录的详细描述和示例。
- 新增 pocketbase.file-fields-to-attachments.js 脚本,用于迁移 PocketBase 中的文件字段到文本字段,并处理 tbl_attachments 集合的公开规则。
This commit is contained in:
2026-03-28 15:13:04 +08:00
parent eaf282ea24
commit 51a90260e4
50 changed files with 4250 additions and 113 deletions

View File

@@ -156,6 +156,10 @@ PocketBase JSVM 不是 Node.js 运行时:
- `pocket-base/spec/openapi.yaml`
- `pocket-base/spec/changes.2026-03-23-pocketbase-hooks-auth-hardening.md`
- `openspec/specs/attachment-backed-media/spec.md`
- `openspec/specs/document-manage-console/spec.md`
- `openspec/specs/sdk-collection-permissions/spec.md`
- `openspec/changes/archive/2026-03-28-pocketbase-manage-media-and-sdk-permissions/`
本次变更重点包括:
@@ -167,6 +171,10 @@ PocketBase JSVM 不是 Node.js 运行时:
- `users_phone` 索引由唯一改为普通索引
- `tbl_auth_users` 以全平台统一 `openid` 为业务身份锚点
- auth 集合兼容占位 `email`、随机密码与 `passwordConfirm`
- 业务文件统一收敛到 `tbl_attachments`
- `tbl_document` 新增 `document_file`
- 文档管理页支持图片 / 视频 / 文件三类附件
- SDK 直连权限页支持按角色配置 collection CRUD 权限
## 与原项目关系

View File

@@ -38,5 +38,11 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document-history/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/context.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-save.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/role-delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/user-role-update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/collection-save.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/sdk-permission/manageplatform-sync.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`)

View File

@@ -2,3 +2,4 @@ require(`${__hooks}/bai_web_pb_hooks/pages/index.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/login.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/document-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/dictionary-manage.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/sdk-permission-manage.js`)

View File

@@ -25,4 +25,4 @@ routerAdd('POST', '/api/attachment/upload', function (e) {
data: (err && err.data) || {},
})
}
})
}, $apis.bodyLimit(536870912))

View File

@@ -0,0 +1,14 @@
routerAdd('POST', '/api/sdk-permission/collection-save', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateSdkPermissionCollectionSaveBody(e)
const data = permissionService.saveCollectionRules(payload)
return success(e, '保存集合权限成功', data)
})

View File

@@ -0,0 +1,13 @@
routerAdd('POST', '/api/sdk-permission/context', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateSdkPermissionContextBody(e)
const data = permissionService.getManagementContext(payload.keyword)
return success(e, '查询权限管理上下文成功', data)
})

View File

@@ -0,0 +1,13 @@
routerAdd('POST', '/api/sdk-permission/manageplatform-sync', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const data = permissionService.syncManagePlatformFullAccess()
return success(e, '已同步 ManagePlatform 业务全权限', data)
})

View File

@@ -0,0 +1,14 @@
routerAdd('POST', '/api/sdk-permission/role-delete', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateSdkPermissionRoleDeleteBody(e)
const data = permissionService.deleteRole(payload.role_id)
return success(e, '删除角色成功', data)
})

View File

@@ -0,0 +1,14 @@
routerAdd('POST', '/api/sdk-permission/role-save', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateSdkPermissionRoleBody(e)
const data = permissionService.saveRole(payload)
return success(e, '保存角色成功', data)
})

View File

@@ -0,0 +1,14 @@
routerAdd('POST', '/api/sdk-permission/user-role-update', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const permissionService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/sdkPermissionService.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
guards.requireJson(e)
guards.requireManagePlatformUser(e)
guards.duplicateGuard(e)
const payload = guards.validateSdkPermissionUserRoleBody(e)
const data = permissionService.updateUserRole(payload)
return success(e, '更新用户角色成功', data)
})

View File

@@ -28,7 +28,14 @@ function validateProfileBody(e) {
if (!payload.users_name) throw createAppError(400, 'users_name 为必填项')
if (!payload.users_phone_code) throw createAppError(400, 'users_phone_code 为必填项')
if (!payload.users_picture) throw createAppError(400, 'users_picture 为必填项')
return payload
return {
users_name: payload.users_name,
users_phone_code: payload.users_phone_code,
users_picture: payload.users_picture,
users_id_pic_a: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_a') ? payload.users_id_pic_a : undefined,
users_id_pic_b: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_b') ? payload.users_id_pic_b : undefined,
users_title_picture: Object.prototype.hasOwnProperty.call(payload, 'users_title_picture') ? payload.users_title_picture : undefined,
}
}
function validatePlatformRegisterBody(e) {
@@ -44,7 +51,24 @@ function validatePlatformRegisterBody(e) {
throw createAppError(400, 'password 与 passwordConfirm 不一致')
}
return payload
return {
users_name: payload.users_name,
users_phone: payload.users_phone,
password: payload.password,
passwordConfirm: payload.passwordConfirm,
users_picture: payload.users_picture,
users_id_number: payload.users_id_number || '',
users_level: payload.users_level || '',
users_type: payload.users_type || '',
company_id: payload.company_id || '',
users_parent_id: payload.users_parent_id || '',
users_promo_code: payload.users_promo_code || '',
usergroups_id: payload.usergroups_id || '',
email: payload.email || '',
users_id_pic_a: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_a') ? payload.users_id_pic_a : undefined,
users_id_pic_b: Object.prototype.hasOwnProperty.call(payload, 'users_id_pic_b') ? payload.users_id_pic_b : undefined,
users_title_picture: Object.prototype.hasOwnProperty.call(payload, 'users_title_picture') ? payload.users_title_picture : undefined,
}
}
function validatePlatformLoginBody(e) {
@@ -193,7 +217,21 @@ function validateAttachmentDeleteBody(e) {
}
function validateAttachmentUploadBody(e) {
const payload = sanitizePayload(e.requestInfo().body || {})
const request = e.request
if (request && typeof request.parseMultipartForm === 'function') {
// Explicit multipart parsing avoids the default Request.ParseForm 10MB cap path.
request.parseMultipartForm(64 * 1024 * 1024)
}
const payload = sanitizePayload({
attachments_filename: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_filename') : '',
attachments_filetype: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_filetype') : '',
attachments_size: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_size') : 0,
attachments_md5: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_md5') : '',
attachments_ocr: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_ocr') : '',
attachments_status: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_status') : '',
attachments_remark: request && typeof request.postFormValue === 'function' ? request.postFormValue('attachments_remark') : '',
})
return {
attachments_filename: payload.attachments_filename || '',
@@ -282,6 +320,7 @@ function validateDocumentMutationBody(e, isUpdate) {
document_content: payload.document_content || '',
document_image: normalizeAttachmentIdList(payload.document_image, 'document_image'),
document_video: normalizeAttachmentIdList(payload.document_video, 'document_video'),
document_file: normalizeAttachmentIdList(payload.document_file, 'document_file'),
document_relation_model: payload.document_relation_model || '',
document_keywords: payload.document_keywords || '',
document_share_count: typeof payload.document_share_count === 'undefined' ? '' : payload.document_share_count,
@@ -311,6 +350,92 @@ function validateDocumentHistoryListBody(e) {
}
}
function normalizeRuleConfig(input) {
const current = sanitizePayload(input || {})
const mode = String(current.mode || 'locked')
const rawExpression = String(current.rawExpression || '')
const roles = Array.isArray(current.roles)
? current.roles.map(function (item) {
return String(item || '').trim()
}).filter(function (item) {
return !!item
})
: []
return {
mode: mode,
includeManagePlatform: !!current.includeManagePlatform,
roles: roles,
rawExpression: rawExpression,
}
}
function validateSdkPermissionContextBody(e) {
const payload = parseBody(e)
return {
keyword: payload.keyword || '',
}
}
function validateSdkPermissionRoleBody(e) {
const payload = parseBody(e)
if (!payload.role_name) {
throw createAppError(400, 'role_name 为必填项')
}
return {
original_role_id: payload.original_role_id || '',
role_id: payload.role_id || '',
role_name: payload.role_name,
role_code: payload.role_code || '',
role_status: typeof payload.role_status === 'undefined' ? 1 : payload.role_status,
role_remark: payload.role_remark || '',
}
}
function validateSdkPermissionRoleDeleteBody(e) {
const payload = parseBody(e)
if (!payload.role_id) {
throw createAppError(400, 'role_id 为必填项')
}
return {
role_id: payload.role_id,
}
}
function validateSdkPermissionUserRoleBody(e) {
const payload = parseBody(e)
if (!payload.pb_id) {
throw createAppError(400, 'pb_id 为必填项')
}
return {
pb_id: payload.pb_id,
usergroups_id: payload.usergroups_id || '',
}
}
function validateSdkPermissionCollectionSaveBody(e) {
const payload = parseBody(e)
if (!payload.collection_name) {
throw createAppError(400, 'collection_name 为必填项')
}
const rules = payload.rules && typeof payload.rules === 'object' ? payload.rules : {}
return {
collection_name: payload.collection_name,
rules: {
list: normalizeRuleConfig(rules.list),
view: normalizeRuleConfig(rules.view),
create: normalizeRuleConfig(rules.create),
update: normalizeRuleConfig(rules.update),
delete: normalizeRuleConfig(rules.delete),
},
}
}
function requireAuthOpenid(e) {
if (!e.auth) {
throw createAppError(401, '认证令牌无效或已过期')
@@ -389,6 +514,11 @@ module.exports = {
validateDocumentMutationBody,
validateDocumentDeleteBody,
validateDocumentHistoryListBody,
validateSdkPermissionContextBody,
validateSdkPermissionRoleBody,
validateSdkPermissionRoleDeleteBody,
validateSdkPermissionUserRoleBody,
validateSdkPermissionCollectionSaveBody,
requireAuthOpenid,
requireAuthUser,
duplicateGuard,

View File

@@ -26,7 +26,7 @@ function buildFileUrl(collectionName, recordId, filename, download) {
function normalizeDateValue(value) {
const text = String(value || '').replace(/^\s+|\s+$/g, '')
if (!text) return ''
if (!text) return null
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return text + ' 00:00:00.000Z'
@@ -198,8 +198,10 @@ function exportDocumentRecord(record) {
const documentStatus = ensureDocumentStatus(record)
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
const fileAttachmentList = resolveAttachmentList(record.getString('document_file'))
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null
const firstFileAttachment = fileAttachmentList.attachments.length ? fileAttachmentList.attachments[0] : null
return {
pb_id: record.id,
@@ -223,6 +225,12 @@ function exportDocumentRecord(record) {
document_video_attachments: videoAttachmentList.attachments,
document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '',
document_video_attachment: firstVideoAttachment,
document_file: fileAttachmentList.ids.join('|'),
document_file_ids: fileAttachmentList.ids,
document_file_urls: fileAttachmentList.urls,
document_file_attachments: fileAttachmentList.attachments,
document_file_url: firstFileAttachment ? firstFileAttachment.attachments_url : '',
document_file_attachment: firstFileAttachment,
document_owner: record.getString('document_owner'),
document_relation_model: record.getString('document_relation_model'),
document_keywords: record.getString('document_keywords'),
@@ -265,6 +273,37 @@ function exportHistoryRecord(record) {
}
}
function extractErrorDetails(err, fallbackMessage) {
return {
statusCode: (err && err.statusCode) || (err && err.status) || 400,
message: (err && err.message) || fallbackMessage || '操作失败',
data: (err && err.data) || {},
}
}
function runInTransactionSafely(actionName, transactionHandler) {
let result = null
let capturedFailure = null
try {
$app.runInTransaction(function (txApp) {
try {
result = transactionHandler(txApp)
} catch (err) {
capturedFailure = extractErrorDetails(err, actionName + '失败')
throw new Error(capturedFailure.message)
}
})
} catch (err) {
if (capturedFailure) {
throw createAppError(capturedFailure.statusCode, capturedFailure.message, capturedFailure.data)
}
throw err
}
return result
}
function createHistoryRecord(txApp, payload) {
const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history')
const record = new Record(collection)
@@ -368,7 +407,9 @@ function deleteAttachment(attachmentId) {
const imageIds = parseAttachmentIdList(current.getString('document_image'))
const videoIds = parseAttachmentIdList(current.getString('document_video'))
if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1) {
const fileIds = parseAttachmentIdList(current.getString('document_file'))
if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1 || fileIds.indexOf(attachmentId) !== -1) {
throw createAppError(400, '附件已被文档引用,无法删除')
}
}
@@ -433,6 +474,7 @@ function getDocumentDetail(documentId) {
function createDocument(userOpenid, payload) {
ensureAttachmentIdsExist(payload.document_image, 'document_image')
ensureAttachmentIdsExist(payload.document_video, 'document_video')
ensureAttachmentIdsExist(payload.document_file, 'document_file')
const targetDocumentId = payload.document_id || buildBusinessId('DOC')
const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
@@ -440,7 +482,7 @@ function createDocument(userOpenid, payload) {
throw createAppError(400, 'document_id 已存在')
}
return $app.runInTransaction(function (txApp) {
return runInTransactionSafely('创建文档', function (txApp) {
const collection = txApp.findCollectionByNameOrId('tbl_document')
const record = new Record(collection)
const effectDateValue = normalizeDateValue(payload.document_effect_date)
@@ -457,12 +499,13 @@ function createDocument(userOpenid, payload) {
record.set('document_content', payload.document_content || '')
record.set('document_image', serializeAttachmentIdList(payload.document_image))
record.set('document_video', serializeAttachmentIdList(payload.document_video))
record.set('document_file', serializeAttachmentIdList(payload.document_file))
record.set('document_owner', userOpenid || '')
record.set('document_relation_model', payload.document_relation_model || '')
record.set('document_keywords', payload.document_keywords || '')
record.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
record.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
record.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
record.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
record.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
record.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
record.set('document_status', documentStatus)
record.set('document_embedding_status', payload.document_embedding_status || '')
record.set('document_embedding_error', payload.document_embedding_error || '')
@@ -473,7 +516,14 @@ function createDocument(userOpenid, payload) {
record.set('document_hotel_type', payload.document_hotel_type || '')
record.set('document_remark', payload.document_remark || '')
txApp.save(record)
try {
txApp.save(record)
} catch (err) {
throw createAppError(400, '保存文档失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
createHistoryRecord(txApp, {
documentId: record.getString('document_id'),
@@ -495,8 +545,9 @@ function updateDocument(userOpenid, payload) {
ensureAttachmentIdsExist(payload.document_image, 'document_image')
ensureAttachmentIdsExist(payload.document_video, 'document_video')
ensureAttachmentIdsExist(payload.document_file, 'document_file')
return $app.runInTransaction(function (txApp) {
return runInTransactionSafely('更新文档', function (txApp) {
const target = txApp.findRecordById('tbl_document', record.id)
const effectDateValue = normalizeDateValue(payload.document_effect_date)
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
@@ -511,11 +562,12 @@ function updateDocument(userOpenid, payload) {
target.set('document_content', payload.document_content || '')
target.set('document_image', serializeAttachmentIdList(payload.document_image))
target.set('document_video', serializeAttachmentIdList(payload.document_video))
target.set('document_file', serializeAttachmentIdList(payload.document_file))
target.set('document_relation_model', payload.document_relation_model || '')
target.set('document_keywords', payload.document_keywords || '')
target.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
target.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
target.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
target.set('document_share_count', normalizeNumberValue(payload.document_share_count, 'document_share_count'))
target.set('document_download_count', normalizeNumberValue(payload.document_download_count, 'document_download_count'))
target.set('document_favorite_count', normalizeNumberValue(payload.document_favorite_count, 'document_favorite_count'))
target.set('document_status', documentStatus)
target.set('document_embedding_status', payload.document_embedding_status || '')
target.set('document_embedding_error', payload.document_embedding_error || '')
@@ -526,7 +578,14 @@ function updateDocument(userOpenid, payload) {
target.set('document_hotel_type', payload.document_hotel_type || '')
target.set('document_remark', payload.document_remark || '')
txApp.save(target)
try {
txApp.save(target)
} catch (err) {
throw createAppError(400, '保存文档失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
createHistoryRecord(txApp, {
documentId: target.getString('document_id'),
@@ -546,7 +605,7 @@ function deleteDocument(userOpenid, documentId) {
throw createAppError(404, '未找到待删除的文档')
}
return $app.runInTransaction(function (txApp) {
return runInTransactionSafely('删除文档', function (txApp) {
createHistoryRecord(txApp, {
documentId: documentId,
operationType: 'delete',

View File

@@ -0,0 +1,560 @@
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
const ROLE_COLLECTION = 'tbl_auth_roles'
const USER_COLLECTION = 'tbl_auth_users'
const MANAGE_PLATFORM_EXPR = '@request.auth.users_idtype = "ManagePlatform"'
const AUTHENTICATED_EXPR = '@request.auth.id != ""'
const RULE_KEYS = ['listRule', 'viewRule', 'createRule', 'updateRule', 'deleteRule']
function parseJsonResponse(response, actionName) {
if (!response) {
throw createAppError(500, actionName + ' 失败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
}
return {}
}
function getPocketBaseApiBaseUrl() {
const base = String(env.pocketbaseApiUrl || '').replace(/\/+$/, '')
if (!base) {
throw createAppError(500, '缺少 POCKETBASE_API_URL 配置,无法同步 PocketBase collection rules')
}
return base
}
function getPocketBaseAuthToken() {
const token = String(env.pocketbaseAuthToken || '').trim()
if (!token) {
throw createAppError(500, '缺少 POCKETBASE_AUTH_TOKEN 配置,无法同步 PocketBase collection rules')
}
return token
}
function requestPocketBase(method, path, body, actionName) {
const baseUrl = getPocketBaseApiBaseUrl()
const token = getPocketBaseAuthToken()
const headers = {
Authorization: 'Bearer ' + token,
}
if (body) {
headers['Content-Type'] = 'application/json'
}
const response = $http.send({
url: baseUrl + path,
method: method,
headers: headers,
body: body ? JSON.stringify(body) : '',
})
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
throw createAppError(500, actionName + ' 失败', {
statusCode: response ? response.statusCode : 0,
body: response ? String(response.body || '') : '',
})
}
return parseJsonResponse(response, actionName)
}
function buildRoleId() {
return 'ROLE-' + new Date().getTime() + '-' + $security.randomString(6)
}
function normalizeText(value) {
return String(value || '').replace(/^\s+|\s+$/g, '')
}
function uniqueList(values) {
const result = []
for (let i = 0; i < (values || []).length; i += 1) {
const current = normalizeText(values[i])
if (current && result.indexOf(current) === -1) {
result.push(current)
}
}
return result
}
function listRoles() {
const records = $app.findRecordsByFilter(ROLE_COLLECTION, '', 'role_name', 500, 0)
return records.map(function (record) {
return {
pb_id: record.id,
role_id: record.getString('role_id'),
role_name: record.getString('role_name'),
role_code: record.getString('role_code'),
role_status: record.get('role_status'),
role_remark: record.getString('role_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
})
}
function buildRoleMap(roles) {
const map = {}
for (let i = 0; i < (roles || []).length; i += 1) {
const role = roles[i]
if (role && role.role_id) {
map[role.role_id] = role
}
}
return map
}
function findRoleRecordByRoleId(roleId) {
const value = normalizeText(roleId)
if (!value) return null
const records = $app.findRecordsByFilter(ROLE_COLLECTION, 'role_id = {:roleId}', '', 1, 0, {
roleId: value,
})
return records.length ? records[0] : null
}
function saveRole(payload) {
const roleId = normalizeText(payload.role_id) || buildRoleId()
const originalRoleId = normalizeText(payload.original_role_id || roleId)
const roleName = normalizeText(payload.role_name)
if (!roleName) {
throw createAppError(400, 'role_name 为必填项')
}
let record = findRoleRecordByRoleId(originalRoleId)
if (!record) {
const collection = $app.findCollectionByNameOrId(ROLE_COLLECTION)
record = new Record(collection)
record.set('role_id', roleId)
}
const sameIdRecord = findRoleRecordByRoleId(roleId)
if (sameIdRecord && sameIdRecord.id !== record.id) {
throw createAppError(400, 'role_id 已存在')
}
record.set('role_id', roleId)
record.set('role_name', roleName)
record.set('role_code', normalizeText(payload.role_code))
record.set('role_status', Number(payload.role_status || 1))
record.set('role_remark', normalizeText(payload.role_remark))
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '保存角色失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
return {
role: {
pb_id: record.id,
role_id: record.getString('role_id'),
role_name: record.getString('role_name'),
role_code: record.getString('role_code'),
role_status: record.get('role_status'),
role_remark: record.getString('role_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
},
}
}
function buildRuleParts(config) {
const parts = []
if (config.includeManagePlatform) {
parts.push(MANAGE_PLATFORM_EXPR)
}
if (config.mode === 'authenticated') {
parts.push(AUTHENTICATED_EXPR)
}
if (config.mode === 'roleBased') {
const roles = uniqueList(config.roles || [])
for (let i = 0; i < roles.length; i += 1) {
parts.push('@request.auth.usergroups_id = "' + roles[i].replace(/"/g, '\\"') + '"')
}
}
return uniqueList(parts)
}
function buildRuleExpression(config) {
const mode = normalizeText(config.mode)
if (mode === 'public') {
return ''
}
if (mode === 'custom') {
const raw = normalizeText(config.rawExpression)
if (!raw) {
return config.includeManagePlatform ? MANAGE_PLATFORM_EXPR : null
}
if (config.includeManagePlatform && raw.indexOf(MANAGE_PLATFORM_EXPR) === -1) {
return '(' + raw + ') || ' + MANAGE_PLATFORM_EXPR
}
return raw
}
if (mode === 'locked') {
return null
}
const parts = buildRuleParts(config)
return parts.length ? parts.join(' || ') : null
}
function parseRuleExpression(ruleValue) {
if (typeof ruleValue === 'undefined' || ruleValue === null) {
return {
mode: 'locked',
includeManagePlatform: false,
roles: [],
rawExpression: '',
}
}
const expression = String(ruleValue)
if (expression === '') {
return {
mode: 'public',
includeManagePlatform: false,
roles: [],
rawExpression: '',
}
}
const includeManagePlatform = expression.indexOf(MANAGE_PLATFORM_EXPR) !== -1
const includeAuthenticated = expression.indexOf(AUTHENTICATED_EXPR) !== -1
const roles = []
const roleRegex = /@request\.auth\.usergroups_id\s*=\s*"([^"]+)"/g
let match = roleRegex.exec(expression)
while (match) {
roles.push(match[1])
match = roleRegex.exec(expression)
}
const cleaned = expression
.replace(new RegExp(MANAGE_PLATFORM_EXPR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '')
.replace(new RegExp(AUTHENTICATED_EXPR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '')
.replace(roleRegex, '')
.replace(/\s*\|\|\s*/g, '')
.replace(/^\s+|\s+$/g, '')
if (cleaned) {
return {
mode: 'custom',
includeManagePlatform: includeManagePlatform,
roles: uniqueList(roles),
rawExpression: expression,
}
}
if (includeAuthenticated && !roles.length) {
return {
mode: 'authenticated',
includeManagePlatform: includeManagePlatform,
roles: [],
rawExpression: '',
}
}
return {
mode: 'roleBased',
includeManagePlatform: includeManagePlatform,
roles: uniqueList(roles),
rawExpression: '',
}
}
function listManageableCollections() {
const data = requestPocketBase('GET', '/api/collections?page=1&perPage=200&sort=name', null, '查询集合列表')
const items = Array.isArray(data.items) ? data.items : []
return items
.filter(function (item) {
return !item.system && item.type !== 'view' && String(item.name || '').indexOf('_') !== 0
})
.map(function (item) {
return {
id: item.id,
name: item.name,
type: item.type,
listRule: item.listRule,
viewRule: item.viewRule,
createRule: item.createRule,
updateRule: item.updateRule,
deleteRule: item.deleteRule,
parsedRules: {
list: parseRuleExpression(item.listRule),
view: parseRuleExpression(item.viewRule),
create: parseRuleExpression(item.createRule),
update: parseRuleExpression(item.updateRule),
delete: parseRuleExpression(item.deleteRule),
},
}
})
}
function saveCollectionRules(payload) {
const collectionName = normalizeText(payload.collection_name)
if (!collectionName) {
throw createAppError(400, 'collection_name 为必填项')
}
const collections = listManageableCollections()
const target = collections.find(function (item) {
return item.name === collectionName
})
if (!target) {
throw createAppError(404, '未找到可管理的集合:' + collectionName)
}
const rulesPayload = {
listRule: buildRuleExpression(payload.rules && payload.rules.list ? payload.rules.list : { mode: 'locked' }),
viewRule: buildRuleExpression(payload.rules && payload.rules.view ? payload.rules.view : { mode: 'locked' }),
createRule: buildRuleExpression(payload.rules && payload.rules.create ? payload.rules.create : { mode: 'locked' }),
updateRule: buildRuleExpression(payload.rules && payload.rules.update ? payload.rules.update : { mode: 'locked' }),
deleteRule: buildRuleExpression(payload.rules && payload.rules.delete ? payload.rules.delete : { mode: 'locked' }),
}
requestPocketBase('PATCH', '/api/collections/' + encodeURIComponent(target.id), rulesPayload, '保存集合权限')
return {
collection: listManageableCollections().find(function (item) {
return item.name === collectionName
}) || null,
}
}
function listUsers(keyword, roleMap) {
const search = normalizeText(keyword).toLowerCase()
const records = $app.findRecordsByFilter(USER_COLLECTION, '', '', 500, 0)
const items = records.map(function (record) {
const usergroupsId = record.getString('usergroups_id')
const role = roleMap && roleMap[usergroupsId] ? roleMap[usergroupsId] : null
return {
pb_id: record.id,
users_id: record.getString('users_id'),
users_name: record.getString('users_name'),
users_phone: record.getString('users_phone'),
openid: record.getString('openid'),
users_idtype: record.getString('users_idtype'),
users_type: record.getString('users_type'),
users_status: record.get('users_status'),
users_rank_level: record.get('users_rank_level'),
usergroups_id: usergroupsId,
role_name: role ? role.role_name : '',
created: String(record.created || ''),
updated: String(record.updated || ''),
}
})
if (!search) {
return items
}
return items.filter(function (item) {
return String(item.users_name || '').toLowerCase().indexOf(search) !== -1
|| String(item.users_phone || '').toLowerCase().indexOf(search) !== -1
|| String(item.openid || '').toLowerCase().indexOf(search) !== -1
|| String(item.role_name || '').toLowerCase().indexOf(search) !== -1
})
}
function updateUserRole(payload) {
const userId = normalizeText(payload.pb_id)
if (!userId) {
throw createAppError(400, 'pb_id 为必填项')
}
const collection = $app.findCollectionByNameOrId(USER_COLLECTION)
const record = $app.findRecordById(collection, userId)
if (!record) {
throw createAppError(404, '未找到对应用户')
}
const roleId = normalizeText(payload.usergroups_id)
if (roleId && !findRoleRecordByRoleId(roleId)) {
throw createAppError(400, '指定的 role_id 不存在')
}
record.set('usergroups_id', roleId)
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '保存用户角色失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
return {
user: {
pb_id: record.id,
users_id: record.getString('users_id'),
users_name: record.getString('users_name'),
users_phone: record.getString('users_phone'),
openid: record.getString('openid'),
users_idtype: record.getString('users_idtype'),
users_type: record.getString('users_type'),
users_status: record.get('users_status'),
users_rank_level: record.get('users_rank_level'),
usergroups_id: record.getString('usergroups_id'),
created: String(record.created || ''),
updated: String(record.updated || ''),
},
}
}
function deleteRole(roleId) {
const value = normalizeText(roleId)
if (!value) {
throw createAppError(400, 'role_id 为必填项')
}
const record = findRoleRecordByRoleId(value)
if (!record) {
throw createAppError(404, '未找到对应角色')
}
const users = $app.findRecordsByFilter(USER_COLLECTION, 'usergroups_id = {:roleId}', '', 500, 0, {
roleId: value,
})
for (let i = 0; i < users.length; i += 1) {
users[i].set('usergroups_id', '')
$app.save(users[i])
}
const collections = listManageableCollections()
for (let i = 0; i < collections.length; i += 1) {
const item = collections[i]
let changed = false
const nextRules = {}
;['list', 'view', 'create', 'update', 'delete'].forEach(function (operation) {
const config = item.parsedRules[operation]
if (!config || config.mode === 'custom') {
nextRules[operation] = config
return
}
const nextRoles = uniqueList((config.roles || []).filter(function (currentRoleId) {
return currentRoleId !== value
}))
if (nextRoles.length !== (config.roles || []).length) {
changed = true
}
nextRules[operation] = {
mode: config.mode,
includeManagePlatform: !!config.includeManagePlatform,
roles: nextRoles,
rawExpression: config.rawExpression || '',
}
})
if (changed) {
saveCollectionRules({
collection_name: item.name,
rules: nextRules,
})
}
}
$app.delete(record)
return {
deleted_role_id: value,
}
}
function syncManagePlatformFullAccess() {
const collections = listManageableCollections()
for (let i = 0; i < collections.length; i += 1) {
const item = collections[i]
const nextRules = {}
;['list', 'view', 'create', 'update', 'delete'].forEach(function (operation) {
const config = item.parsedRules[operation]
if (config && config.mode === 'custom') {
nextRules[operation] = {
mode: 'custom',
includeManagePlatform: true,
roles: config.roles || [],
rawExpression: config.rawExpression || '',
}
return
}
nextRules[operation] = {
mode: config ? config.mode : 'roleBased',
includeManagePlatform: true,
roles: config ? (config.roles || []) : [],
rawExpression: config ? (config.rawExpression || '') : '',
}
if (nextRules[operation].mode === 'locked' && !nextRules[operation].roles.length) {
nextRules[operation].mode = 'roleBased'
}
})
saveCollectionRules({
collection_name: item.name,
rules: nextRules,
})
}
return {
count: collections.length,
}
}
function getManagementContext(keyword) {
const roles = listRoles()
const roleMap = buildRoleMap(roles)
return {
roles: roles,
users: listUsers(keyword, roleMap),
collections: listManageableCollections(),
note: '先创建角色,再在“用户授权”里把用户绑定到角色,最后切到下方“当前配置角色”为该角色逐表勾选 CRUD 权限。ManagePlatform 仍是业务管理员,不会变成 PocketBase 原生 _superusers。',
}
}
module.exports = {
getManagementContext,
listRoles,
saveRole,
deleteRole,
listUsers,
updateUserRole,
listManageableCollections,
saveCollectionRules,
syncManagePlatformFullAccess,
}

View File

@@ -1,6 +1,7 @@
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 wechatService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/wechatService.js`)
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const GUEST_USER_TYPE = '游客'
@@ -124,10 +125,68 @@ function exportCompany(companyRecord) {
return companyRecord.publicExport()
}
function resolveUserAttachment(attachmentId) {
const value = String(attachmentId || '')
if (!value) {
return {
id: '',
url: '',
attachment: null,
}
}
try {
const attachment = documentService.getAttachmentDetail(value)
return {
id: value,
url: attachment ? attachment.attachments_url : '',
attachment: attachment,
}
} catch (_error) {
return {
id: value,
url: '',
attachment: null,
}
}
}
function ensureAttachmentIdExists(attachmentId, fieldName) {
const value = String(attachmentId || '')
if (!value) return ''
try {
documentService.getAttachmentDetail(value)
} catch (_error) {
throw createAppError(400, fieldName + ' 对应的附件不存在:' + value)
}
return value
}
function applyUserAttachmentFields(record, payload) {
if (!payload) return
record.set('users_picture', ensureAttachmentIdExists(payload.users_picture || '', 'users_picture'))
if (typeof payload.users_id_pic_a !== 'undefined') {
record.set('users_id_pic_a', ensureAttachmentIdExists(payload.users_id_pic_a || '', 'users_id_pic_a'))
}
if (typeof payload.users_id_pic_b !== 'undefined') {
record.set('users_id_pic_b', ensureAttachmentIdExists(payload.users_id_pic_b || '', 'users_id_pic_b'))
}
if (typeof payload.users_title_picture !== 'undefined') {
record.set('users_title_picture', ensureAttachmentIdExists(payload.users_title_picture || '', 'users_title_picture'))
}
}
function enrichUser(userRecord) {
const companyId = userRecord.getString('company_id')
const companyRecord = getCompanyByCompanyId(companyId)
const openid = userRecord.getString('openid')
const userPicture = resolveUserAttachment(userRecord.getString('users_picture'))
const userIdPicA = resolveUserAttachment(userRecord.getString('users_id_pic_a'))
const userIdPicB = resolveUserAttachment(userRecord.getString('users_id_pic_b'))
const userTitlePicture = resolveUserAttachment(userRecord.getString('users_title_picture'))
return {
pb_id: userRecord.id,
@@ -143,7 +202,18 @@ function enrichUser(userRecord) {
users_phone: userRecord.getString('users_phone'),
users_phone_masked: maskPhone(userRecord.getString('users_phone')),
users_level: userRecord.getString('users_level'),
users_picture: userRecord.getString('users_picture'),
users_picture: userPicture.id,
users_picture_url: userPicture.url,
users_picture_attachment: userPicture.attachment,
users_id_pic_a: userIdPicA.id,
users_id_pic_a_url: userIdPicA.url,
users_id_pic_a_attachment: userIdPicA.attachment,
users_id_pic_b: userIdPicB.id,
users_id_pic_b_url: userIdPicB.url,
users_id_pic_b_attachment: userIdPicB.attachment,
users_title_picture: userTitlePicture.id,
users_title_picture_url: userTitlePicture.url,
users_title_picture_attachment: userTitlePicture.attachment,
openid: openid,
company_id: companyId || '',
users_parent_id: userRecord.getString('users_parent_id'),
@@ -293,7 +363,6 @@ function registerPlatformUser(payload) {
record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE)
record.set('users_name', payload.users_name)
record.set('users_phone', payload.users_phone)
record.set('users_picture', payload.users_picture)
record.set('users_id_number', payload.users_id_number || '')
record.set('users_level', payload.users_level || '')
record.set('users_type', payload.users_type || REGISTERED_USER_TYPE)
@@ -305,6 +374,7 @@ function registerPlatformUser(payload) {
record.set('email', payload.email || (platformOpenid + '@manage.local'))
record.setPassword(payload.password)
record.set('passwordConfirm', payload.passwordConfirm)
applyUserAttachmentFields(record, payload)
saveAuthUserRecord(record)
@@ -432,7 +502,7 @@ function updateWechatUserProfile(usersWxOpenid, payload) {
currentUser.set('users_name', payload.users_name)
currentUser.set('users_phone', usersPhone)
currentUser.set('users_picture', payload.users_picture)
applyUserAttachmentFields(currentUser, payload)
if (shouldPromote) {
currentUser.set('users_type', REGISTERED_USER_TYPE)
}

View File

@@ -1,5 +1,39 @@
function normalizeErrorData(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return {}
}
const result = {}
const keys = Object.keys(data)
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i]
const value = data[key]
if (typeof value === 'undefined') {
continue
}
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
result[key] = value
continue
}
try {
result[key] = JSON.stringify(value)
} catch (_err) {
result[key] = String(value)
}
}
return result
}
function createAppError(statusCode, message, data) {
return new ApiError(statusCode || 500, message || '服务器内部错误', data || {})
const error = new ApiError(statusCode || 500, message || '服务器内部错误')
error.statusCode = statusCode || 500
error.status = statusCode || 500
error.data = normalizeErrorData(data)
return error
}
module.exports = {

View File

@@ -58,6 +58,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
.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; }
.thumb { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; }
.thumb.previewable { cursor: zoom-in; }
.thumb-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.thumb-meta { font-size: 12px; color: #64748b; word-break: break-all; }
.enum-preview-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
@@ -67,6 +68,16 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
.image-upload-row input[type="file"] { width: 100%; min-width: 0; }
.icon-btn { min-width: 36px; padding: 6px 10px; line-height: 1; font-size: 18px; }
.drop-tip { color: #64748b; font-size: 12px; margin-top: 6px; }
.image-viewer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.82); z-index: 9999; }
.image-viewer.show { display: flex; }
.image-viewer img { max-width: min(92vw, 1600px); max-height: 88vh; border-radius: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.38); background: #fff; }
.image-viewer-close { position: absolute; top: 18px; right: 18px; min-width: 44px; min-height: 44px; border-radius: 999px; border: none; background: rgba(255,255,255,0.92); color: #0f172a; font-size: 24px; cursor: pointer; }
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
.loading-mask.show { display: flex; }
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: dictSpin 0.9s linear infinite; }
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
@keyframes dictSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 960px) {
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
table, thead, tbody, th, td, tr { display: block; }
@@ -169,6 +180,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
</div>
</div>
<div class="image-viewer" id="imageViewer">
<button class="image-viewer-close" id="closeImageViewerBtn" type="button">×</button>
<img id="imageViewerImg" src="" alt="预览原图" />
</div>
<div class="loading-mask" id="loadingMask">
<div class="loading-card">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
</div>
</div>
<script>
const API_BASE = '/pb/api'
const tokenKey = 'pb_manage_token'
@@ -193,12 +216,43 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
const enabledInput = document.getElementById('enabledInput')
const remarkInput = document.getElementById('remarkInput')
const itemsBody = document.getElementById('itemsBody')
const imageViewer = document.getElementById('imageViewer')
const imageViewerImg = document.getElementById('imageViewerImg')
const loadingMask = document.getElementById('loadingMask')
const loadingText = document.getElementById('loadingText')
const loadingState = { count: 0 }
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
}
function showLoading(message) {
loadingState.count += 1
if (loadingText) {
loadingText.textContent = message || '处理中,请稍候...'
}
if (loadingMask) {
loadingMask.classList.add('show')
}
}
function hideLoading() {
loadingState.count = Math.max(0, loadingState.count - 1)
if (loadingState.count === 0 && loadingMask) {
loadingMask.classList.remove('show')
if (loadingText) {
loadingText.textContent = '处理中,请稍候...'
}
}
}
function escapeJsString(value) {
return String(value || '')
.replace(/\\\\/g, '\\\\\\\\')
.replace(/'/g, "\\'")
}
function getToken() {
return localStorage.getItem(tokenKey) || ''
}
@@ -235,6 +289,36 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
return data.data
}
async function parseJsonSafe(res) {
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
const rawText = await res.text()
const isJson = contentType.indexOf('application/json') !== -1
if (!rawText) {
return {
json: null,
text: '',
isJson: false,
}
}
if (isJson) {
try {
return {
json: JSON.parse(rawText),
text: rawText,
isJson: true,
}
} catch (_error) {}
}
return {
json: null,
text: rawText,
isJson: false,
}
}
async function uploadAttachment(file, label) {
const token = getToken()
if (!token) {
@@ -259,8 +343,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
body: form,
})
const data = await res.json()
const parsed = await parseJsonSafe(res)
const data = parsed.json
if (!res.ok || !data || data.code >= 400) {
if (res.status === 413) {
throw new Error('上传图片失败:文件已超过当前网关允许的请求体大小,或线上服务仍在运行旧版 hooks。')
}
if (!parsed.isJson && parsed.text) {
throw new Error('上传图片失败:服务端返回了非 JSON 响应,通常表示网关或反向代理提前拦截了上传请求。')
}
throw new Error((data && data.msg) || '上传图片失败')
}
@@ -333,9 +424,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
function renderEnumPreviewItem(item) {
const desc = escapeHtml(item && item.description ? item.description : '(无描述)')
const imageHtml = item && item.imageUrl
? '<img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" />'
? '<img class="thumb previewable" src="' + escapeHtml(item.imageUrl) + '" alt="" onclick="window.__previewImage(\\'' + escapeJsString(item.imageUrl) + '\\')" />'
: '<span class="muted">无图</span>'
return '<div class="enum-preview-item"><span>' + desc + '</span>' + imageHtml + '</div>'
return '<div class="enum-preview-item">' + imageHtml + '<span>' + desc + '</span></div>'
}
function renderItemsPreview(items, previewKey, isExpanded) {
@@ -443,7 +534,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
function renderItemsEditor() {
itemsBody.innerHTML = state.items.map(function (item, index) {
const imageCell = item.imageUrl
? '<div class="thumb-row"><img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" /><div class="thumb-meta">' + escapeHtml(item.image || '') + '</div></div>'
? '<div class="thumb-row"><img class="thumb previewable" src="' + escapeHtml(item.imageUrl) + '" alt="" onclick="window.__previewImage(\\'' + escapeJsString(item.imageUrl) + '\\')" /><div class="thumb-meta">' + escapeHtml(item.image || '') + '</div></div>'
: '<div class="muted">未上传图片</div>'
return '<tr>'
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
@@ -472,6 +563,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
setStatus('正在上传字典项图片...', '')
showLoading('正在上传字典项图片,请稍候...')
try {
const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1))
state.items[index].image = attachment.attachments_id
@@ -481,6 +573,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('字典项图片上传成功。', 'success')
} catch (err) {
setStatus(err.message || '字典项图片上传失败', 'error')
} finally {
hideLoading()
}
}
@@ -515,6 +609,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
async function loadList() {
setStatus('正在查询字典列表...', '')
showLoading('正在查询字典列表...')
try {
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
state.list = data.items || []
@@ -523,6 +618,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
} catch (err) {
setStatus(err.message || '查询失败', 'error')
} finally {
hideLoading()
}
}
@@ -534,6 +631,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
setStatus('正在查询字典详情...', '')
showLoading('正在查询字典详情...')
try {
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
state.list = [data]
@@ -542,6 +640,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus('查询详情成功。', 'success')
} catch (err) {
setStatus(err.message || '查询失败', 'error')
} finally {
hideLoading()
}
}
@@ -559,6 +659,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
setStatus('正在保存字典...', '')
showLoading('正在保存字典,请稍候...')
try {
await request(state.mode === 'create' ? API_BASE + '/dictionary/create' : API_BASE + '/dictionary/update', payload)
closeModal()
@@ -566,6 +667,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
setStatus(state.mode === 'create' ? '新增成功。' : '修改成功。', 'success')
} catch (err) {
setStatus(err.message || '保存失败', 'error')
} finally {
hideLoading()
}
}
@@ -591,12 +694,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
setStatus('正在保存行数据...', '')
showLoading('正在保存行数据,请稍候...')
try {
await request(API_BASE + '/dictionary/update', payload)
await loadList()
setStatus('行内保存成功。', 'success')
} catch (err) {
setStatus(err.message || '保存失败', 'error')
} finally {
hideLoading()
}
}
@@ -607,12 +713,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
setStatus('正在删除字典...', '')
showLoading('正在删除字典,请稍候...')
try {
await request(API_BASE + '/dictionary/delete', { dict_name: targetName })
await loadList()
setStatus('删除成功。', 'success')
} catch (err) {
setStatus(err.message || '删除失败', 'error')
} finally {
hideLoading()
}
}
@@ -632,6 +741,13 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey
renderTable(state.list)
}
window.__previewImage = function (url) {
if (!url) {
return
}
imageViewerImg.src = url
imageViewer.classList.add('show')
}
window.__uploadItemImage = uploadItemImage
window.__allowItemDrop = function (event) {
event.preventDefault()
@@ -675,6 +791,22 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
document.getElementById('createBtn').addEventListener('click', function () { openModal('create') })
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
document.getElementById('cancelBtn').addEventListener('click', closeModal)
document.getElementById('closeImageViewerBtn').addEventListener('click', function () {
imageViewer.classList.remove('show')
imageViewerImg.src = ''
})
imageViewer.addEventListener('click', function (event) {
if (event.target === imageViewer) {
imageViewer.classList.remove('show')
imageViewerImg.src = ''
}
})
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && imageViewer.classList.contains('show')) {
imageViewer.classList.remove('show')
imageViewerImg.src = ''
}
})
document.getElementById('addItemBtn').addEventListener('click', function () {
syncItemsStateFromEditor()
const used = new Set(state.items.map(function (item) { return String(item.enum || '') }))

View File

@@ -30,6 +30,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.status { margin-top: 14px; min-height: 24px; font-size: 14px; }
.status.success { color: #15803d; }
.status.error { color: #b91c1c; }
.editor-panel { display: none; }
.editor-panel.show { display: block; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.full { grid-column: 1 / -1; }
label { display: block; margin-bottom: 8px; color: #334155; font-weight: 600; }
@@ -46,7 +48,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.doc-links a, .file-card a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; }
.btn-warning { background: #f59e0b; color: #fff; }
.editor-banner { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 13px; font-weight: 700; }
.file-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
.file-group { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
.file-box { border: 1px solid #dbe3f0; border-radius: 18px; padding: 16px; background: #f8fbff; }
.option-box { border: 1px solid #dbe3f0; border-radius: 16px; background: #f8fbff; padding: 14px; }
.option-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
@@ -68,6 +70,18 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.file-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.file-card-title { font-weight: 700; word-break: break-all; }
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
.thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; cursor: zoom-in; }
.thumb-strip { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.image-viewer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.82); z-index: 9999; }
.image-viewer.show { display: flex; }
.image-viewer img { max-width: min(92vw, 1600px); max-height: 88vh; border-radius: 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.38); background: #fff; }
.image-viewer-close { position: absolute; top: 18px; right: 18px; min-width: 44px; min-height: 44px; border-radius: 999px; border: none; background: rgba(255,255,255,0.92); color: #0f172a; font-size: 24px; cursor: pointer; }
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
.loading-mask.show { display: flex; }
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: docSpin 0.9s linear infinite; }
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
@keyframes docSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 960px) {
.grid, .file-group { grid-template-columns: 1fr; }
.option-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -92,7 +106,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<div class="status" id="status"></div>
</section>
<section class="panel">
<section class="panel editor-panel" id="editorPanel">
<div class="toolbar" style="justify-content:space-between;align-items:center;">
<div>
<h2 id="formTitle">新增文档</h2>
@@ -213,12 +227,23 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<div class="file-list" id="videoCurrentList"></div>
<div class="file-list" id="videoPendingList"></div>
</div>
<div class="file-box">
<h3>文件附件</h3>
<label for="documentFile">新增文件</label>
<div class="dropzone" id="documentFileDropzone">
<div class="dropzone-title">拖拽文件到这里,或点击选择文件</div>
<input id="documentFile" type="file" multiple />
</div>
<div class="file-list" id="documentFileCurrentList"></div>
<div class="file-list" id="documentFilePendingList"></div>
</div>
</div>
<div class="form-actions" style="margin-top:16px;">
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
<button class="btn btn-warning" id="cancelEditBtn" type="button">取消编辑</button>
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
</div>
<div class="status" id="editorStatus"></div>
</section>
<section class="panel">
@@ -242,10 +267,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
</section>
</div>
<div class="image-viewer" id="imageViewer">
<button class="image-viewer-close" id="closeImageViewerBtn" type="button">×</button>
<img id="imageViewerImg" src="" alt="预览原图" />
</div>
<div class="loading-mask" id="loadingMask">
<div class="loading-card">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
</div>
</div>
<script>
const tokenKey = 'pb_manage_token'
const API_BASE = '/pb/api'
const statusEl = document.getElementById('status')
const editorStatusEl = document.getElementById('editorStatus')
const editorPanelEl = document.getElementById('editorPanel')
const tableBody = document.getElementById('tableBody')
const formTitleEl = document.getElementById('formTitle')
const editorModeEl = document.getElementById('editorMode')
@@ -253,8 +292,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const imagePendingListEl = document.getElementById('imagePendingList')
const videoCurrentListEl = document.getElementById('videoCurrentList')
const videoPendingListEl = document.getElementById('videoPendingList')
const documentFileCurrentListEl = document.getElementById('documentFileCurrentList')
const documentFilePendingListEl = document.getElementById('documentFilePendingList')
const imageDropzoneEl = document.getElementById('imageDropzone')
const videoDropzoneEl = document.getElementById('videoDropzone')
const documentFileDropzoneEl = document.getElementById('documentFileDropzone')
const documentTypeSourceEl = document.getElementById('documentTypeSource')
const documentTypeOptionsEl = document.getElementById('documentTypeOptions')
const documentTypeTagsEl = document.getElementById('documentTypeTags')
@@ -267,6 +309,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags')
const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions')
const hotelTypeTagsEl = document.getElementById('hotelTypeTags')
const imageViewerEl = document.getElementById('imageViewer')
const imageViewerImgEl = document.getElementById('imageViewerImg')
const loadingMaskEl = document.getElementById('loadingMask')
const loadingTextEl = document.getElementById('loadingText')
const loadingState = { count: 0 }
const fields = {
documentTitle: document.getElementById('documentTitle'),
documentStatus: document.getElementById('documentStatus'),
@@ -281,6 +328,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
documentRemark: document.getElementById('documentRemark'),
imageFile: document.getElementById('imageFile'),
videoFile: document.getElementById('videoFile'),
documentFile: document.getElementById('documentFile'),
}
const dictionaryFieldConfig = {
documentKeywords: {
@@ -306,13 +354,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
const state = {
list: [],
mode: 'create',
mode: 'idle',
editingId: '',
editingSource: null,
currentImageAttachments: [],
currentVideoAttachments: [],
currentFileAttachments: [],
pendingImageFiles: [],
pendingVideoFiles: [],
pendingDocumentFiles: [],
removedAttachmentIds: [],
dictionaries: [],
dictionariesByName: {},
@@ -330,6 +380,43 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
if (editorStatusEl) {
editorStatusEl.textContent = message || ''
editorStatusEl.className = 'status' + (type ? ' ' + type : '')
}
}
function setEditorVisible(visible) {
if (!editorPanelEl) {
return
}
editorPanelEl.classList.toggle('show', !!visible)
}
function showLoading(message) {
loadingState.count += 1
if (loadingTextEl) {
loadingTextEl.textContent = message || '处理中,请稍候...'
}
if (loadingMaskEl) {
loadingMaskEl.classList.add('show')
}
}
function hideLoading() {
loadingState.count = Math.max(0, loadingState.count - 1)
if (loadingState.count === 0 && loadingMaskEl) {
loadingMaskEl.classList.remove('show')
if (loadingTextEl) {
loadingTextEl.textContent = '处理中,请稍候...'
}
}
}
function escapeJsString(value) {
return String(value || '')
.replace(/\\\\/g, '\\\\\\\\')
.replace(/'/g, "\\'")
}
function getToken() {
@@ -549,29 +636,40 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
async function loadDictionaries() {
const data = await requestJson('/dictionary/list', {})
state.dictionaries = Array.isArray(data.items) ? data.items : []
state.dictionariesByName = {}
state.dictionariesById = {}
showLoading('正在加载字典选项...')
try {
const data = await requestJson('/dictionary/list', {})
state.dictionaries = Array.isArray(data.items) ? data.items : []
state.dictionariesByName = {}
state.dictionariesById = {}
for (let i = 0; i < state.dictionaries.length; i += 1) {
const item = state.dictionaries[i]
state.dictionariesByName[item.dict_name] = item
state.dictionariesById[item.system_dict_id] = item
for (let i = 0; i < state.dictionaries.length; i += 1) {
const item = state.dictionaries[i]
state.dictionariesByName[item.dict_name] = item
state.dictionariesById[item.system_dict_id] = item
}
renderDictionarySelectors()
} finally {
hideLoading()
}
renderDictionarySelectors()
}
function updateEditorMode() {
setEditorVisible(state.mode === 'create' || state.mode === 'edit')
if (state.mode === 'edit') {
formTitleEl.textContent = '编辑文档'
editorModeEl.textContent = '当前模式:编辑 ' + state.editingId
document.getElementById('submitBtn').textContent = '保存文档修改'
} else {
} else if (state.mode === 'create') {
formTitleEl.textContent = '新增文档'
editorModeEl.textContent = '当前模式:新建'
document.getElementById('submitBtn').textContent = '上传附件并创建文档'
} else {
formTitleEl.textContent = '新增文档'
editorModeEl.textContent = '当前模式:未打开编辑区'
document.getElementById('submitBtn').textContent = '上传附件并创建文档'
}
}
@@ -598,7 +696,12 @@ routerAdd('GET', '/manage/document-manage', function (e) {
localStorage.removeItem('pb_manage_logged_in')
window.location.replace('/pb/manage/login')
}
throw new Error((data && data.msg) || '请求失败')
const details = data && data.data ? data.data : {}
const detailMessage = details.originalMessage || details.body || ''
const finalMessage = [(data && data.msg) || '请求失败', detailMessage].filter(function (item, index, arr) {
return item && arr.indexOf(item) === index
}).join('')
throw new Error(finalMessage || '请求失败')
}
return data.data
@@ -661,7 +764,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const data = parsed.json
if (!res.ok || !data || data.code >= 400) {
if (res.status === 413) {
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小。当前附件字段已放宽到约 4GB但线上反向代理也需要同步放开到相应体积。')
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小,或线上服务仍在运行旧版 hooks。')
}
if (!parsed.isJson && parsed.text) {
@@ -693,6 +796,26 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.replace(/'/g, '&#39;')
}
function getAttachmentPreviewUrl(item, pending) {
if (!item) {
return ''
}
if (pending) {
return item.previewUrl || ''
}
return item.attachments_url || ''
}
function renderImageThumb(url, title) {
if (!url) {
return ''
}
return '<img class="thumb" src="' + escapeHtml(url) + '" alt="' + escapeHtml(title || '') + '" onclick="window.__previewImage(\\'' + escapeJsString(url) + '\\')" />'
}
function renderAttachmentCards(container, items, category, pending) {
if (!items.length) {
container.innerHTML = '<div class="muted">' + (pending ? '暂无待上传附件。' : '暂无已绑定附件。') + '</div>'
@@ -709,11 +832,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const linkHtml = pending || !item.attachments_url
? ''
: '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>'
const previewHtml = category === 'image'
? renderImageThumb(getAttachmentPreviewUrl(item, pending), title)
: ''
const actionLabel = pending ? '移除待上传' : '从文档移除'
const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment'
return '<div class="file-card">'
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>'
+ previewHtml
+ '<div class="file-meta">' + meta + '</div>'
+ '<div class="file-actions">'
+ linkHtml
@@ -726,25 +853,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function renderAttachmentEditors() {
renderAttachmentCards(imageCurrentListEl, state.currentImageAttachments, 'image', false)
renderAttachmentCards(videoCurrentListEl, state.currentVideoAttachments, 'video', false)
renderAttachmentCards(documentFileCurrentListEl, state.currentFileAttachments, 'file', false)
renderAttachmentCards(imagePendingListEl, state.pendingImageFiles, 'image', true)
renderAttachmentCards(videoPendingListEl, state.pendingVideoFiles, 'video', true)
renderAttachmentCards(documentFilePendingListEl, state.pendingDocumentFiles, 'file', true)
}
function renderLinks(item) {
const links = []
const imageThumbs = []
const imageUrls = Array.isArray(item.document_image_urls) ? item.document_image_urls : (item.document_image_url ? [item.document_image_url] : [])
const videoUrls = Array.isArray(item.document_video_urls) ? item.document_video_urls : (item.document_video_url ? [item.document_video_url] : [])
const fileUrls = Array.isArray(item.document_file_urls) ? item.document_file_urls : (item.document_file_url ? [item.document_file_url] : [])
for (let i = 0; i < imageUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(imageUrls[i]) + '" target="_blank" rel="noreferrer">图片流' + (i + 1) + '</a>')
imageThumbs.push(renderImageThumb(imageUrls[i], '图片流' + (i + 1)))
}
for (let i = 0; i < videoUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(videoUrls[i]) + '" target="_blank" rel="noreferrer">视频流' + (i + 1) + '</a>')
}
for (let i = 0; i < fileUrls.length; i += 1) {
links.push('<a href="' + escapeHtml(fileUrls[i]) + '" target="_blank" rel="noreferrer">文件流' + (i + 1) + '</a>')
}
if (!links.length) {
return '<span class="muted">无</span>'
}
return '<div class="doc-links">' + links.join('') + '</div>'
+ (imageThumbs.length ? '<div class="thumb-strip">' + imageThumbs.join('') + '</div>' : '')
}
function renderTable() {
@@ -768,17 +904,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
function appendPendingFiles(category, fileList) {
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
const target = category === 'image'
? state.pendingImageFiles
: (category === 'video' ? state.pendingVideoFiles : state.pendingDocumentFiles)
const files = Array.from(fileList || [])
for (let i = 0; i < files.length; i += 1) {
target.push({
key: Date.now() + '-' + Math.random().toString(36).slice(2),
file: files[i],
previewUrl: category === 'image' && files[i] ? URL.createObjectURL(files[i]) : '',
})
}
renderAttachmentEditors()
}
function revokePendingPreview(item) {
if (item && item.previewUrl) {
try {
URL.revokeObjectURL(item.previewUrl)
} catch (_error) {}
}
}
function clearPendingList(list) {
for (let i = 0; i < list.length; i += 1) {
revokePendingPreview(list[i])
}
}
function bindDropzone(dropzoneEl, inputEl, category) {
if (!dropzoneEl || !inputEl) {
return
@@ -818,15 +971,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
function removePendingAttachment(category, index) {
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
const target = category === 'image'
? state.pendingImageFiles
: (category === 'video' ? state.pendingVideoFiles : state.pendingDocumentFiles)
if (index >= 0 && index < target.length) {
revokePendingPreview(target[index])
target.splice(index, 1)
}
renderAttachmentEditors()
}
function removeCurrentAttachment(category, index) {
const target = category === 'image' ? state.currentImageAttachments : state.currentVideoAttachments
const target = category === 'image'
? state.currentImageAttachments
: (category === 'video' ? state.currentVideoAttachments : state.currentFileAttachments)
if (index < 0 || index >= target.length) {
return
}
@@ -840,6 +998,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
async function loadDocuments() {
setStatus('正在加载文档列表...', '')
showLoading('正在加载文档列表...')
try {
const data = await requestJson('/document/list', {})
state.list = data.items || []
@@ -847,6 +1006,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
} catch (err) {
setStatus(err.message || '加载列表失败', 'error')
} finally {
hideLoading()
}
}
@@ -864,6 +1025,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentRemark.value = ''
fields.imageFile.value = ''
fields.videoFile.value = ''
fields.documentFile.value = ''
state.selections.documentTypeSource = ''
state.selections.documentTypeValues = []
state.selections.documentKeywords = []
@@ -875,13 +1037,37 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
function enterCreateMode() {
clearPendingList(state.pendingImageFiles)
clearPendingList(state.pendingVideoFiles)
clearPendingList(state.pendingDocumentFiles)
state.mode = 'create'
state.editingId = ''
state.editingSource = null
state.currentImageAttachments = []
state.currentVideoAttachments = []
state.currentFileAttachments = []
state.pendingImageFiles = []
state.pendingVideoFiles = []
state.pendingDocumentFiles = []
state.removedAttachmentIds = []
resetForm()
updateEditorMode()
renderAttachmentEditors()
}
function enterIdleMode() {
clearPendingList(state.pendingImageFiles)
clearPendingList(state.pendingVideoFiles)
clearPendingList(state.pendingDocumentFiles)
state.mode = 'idle'
state.editingId = ''
state.editingSource = null
state.currentImageAttachments = []
state.currentVideoAttachments = []
state.currentFileAttachments = []
state.pendingImageFiles = []
state.pendingVideoFiles = []
state.pendingDocumentFiles = []
state.removedAttachmentIds = []
resetForm()
updateEditorMode()
@@ -902,6 +1088,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentRemark.value = item.document_remark || ''
fields.imageFile.value = ''
fields.videoFile.value = ''
fields.documentFile.value = ''
const documentTypeParts = splitPipeValue(item.document_type)
const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : ''
@@ -936,8 +1123,10 @@ routerAdd('GET', '/manage/document-manage', function (e) {
state.editingSource = target
state.currentImageAttachments = normalizeAttachmentList(target.document_image_attachments, target.document_image_ids, target.document_image_urls)
state.currentVideoAttachments = normalizeAttachmentList(target.document_video_attachments, target.document_video_ids, target.document_video_urls)
state.currentFileAttachments = normalizeAttachmentList(target.document_file_attachments, target.document_file_ids, target.document_file_urls)
state.pendingImageFiles = []
state.pendingVideoFiles = []
state.pendingDocumentFiles = []
state.removedAttachmentIds = []
fillFormFromItem(target)
@@ -947,7 +1136,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function buildMutationPayload(imageAttachments, videoAttachments) {
function buildMutationPayload(imageAttachments, videoAttachments, fileAttachments) {
const source = state.editingSource || {}
return {
document_id: state.mode === 'edit' ? state.editingId : '',
@@ -962,6 +1151,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
document_content: fields.documentContent.value.trim(),
document_image: imageAttachments.map(function (item) { return item.attachments_id }),
document_video: videoAttachments.map(function (item) { return item.attachments_id }),
document_file: fileAttachments.map(function (item) { return item.attachments_id }),
document_relation_model: fields.relationModel.value.trim(),
document_keywords: joinPipeValue(state.selections.documentKeywords),
document_share_count: typeof source.document_share_count === 'undefined' || source.document_share_count === null ? '' : source.document_share_count,
@@ -1011,24 +1201,29 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
setStatus(state.mode === 'edit' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '')
showLoading(state.mode === 'edit' ? '正在保存文档修改,请稍候...' : '正在上传附件并创建文档,请稍候...')
const uploadedAttachments = []
try {
const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image')
const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video')
const newFileAttachments = await uploadPendingFiles(state.pendingDocumentFiles, 'file')
uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments)
uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments)
uploadedAttachments.push.apply(uploadedAttachments, newFileAttachments)
const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments)
const finalVideoAttachments = state.currentVideoAttachments.concat(newVideoAttachments)
const payload = buildMutationPayload(finalImageAttachments, finalVideoAttachments)
const finalFileAttachments = state.currentFileAttachments.concat(newFileAttachments)
const payload = buildMutationPayload(finalImageAttachments, finalVideoAttachments, finalFileAttachments)
if (state.mode === 'edit') {
await requestJson('/document/update', payload)
const updated = await requestJson('/document/update', payload)
const deleteFailed = await deleteRemovedAttachments()
await loadDocuments()
enterCreateMode()
state.editingId = (updated && updated.document_id) || state.editingId
enterEditMode(state.editingId)
if (deleteFailed.length) {
setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(''), 'error')
return
@@ -1037,15 +1232,19 @@ routerAdd('GET', '/manage/document-manage', function (e) {
return
}
await requestJson('/document/create', payload)
const created = await requestJson('/document/create', payload)
await loadDocuments()
enterCreateMode()
if (created && created.document_id) {
enterEditMode(created.document_id)
}
setStatus('文档创建成功。', 'success')
} catch (err) {
if (uploadedAttachments.length) {
await cleanupUploadedAttachments(uploadedAttachments)
}
setStatus(err.message || (state.mode === 'edit' ? '修改文档失败' : '创建文档失败'), 'error')
} finally {
hideLoading()
}
}
@@ -1056,6 +1255,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
setStatus('正在删除文档...', '')
showLoading('正在删除文档,请稍候...')
try {
await requestJson('/document/delete', { document_id: target })
if (state.mode === 'edit' && state.editingId === target) {
@@ -1065,6 +1265,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
setStatus('文档删除成功。', 'success')
} catch (err) {
setStatus(err.message || '删除文档失败', 'error')
} finally {
hideLoading()
}
}
@@ -1072,6 +1274,13 @@ routerAdd('GET', '/manage/document-manage', function (e) {
window.__editDocument = function (documentId) {
enterEditMode(decodeURIComponent(documentId))
}
window.__previewImage = function (url) {
if (!url) {
return
}
imageViewerImgEl.src = url
imageViewerEl.classList.add('show')
}
window.__removePendingAttachment = removePendingAttachment
window.__removeCurrentAttachment = removeCurrentAttachment
@@ -1115,8 +1324,24 @@ routerAdd('GET', '/manage/document-manage', function (e) {
})
document.getElementById('submitBtn').addEventListener('click', submitDocument)
document.getElementById('cancelEditBtn').addEventListener('click', function () {
enterCreateMode()
setStatus('已取消编辑。', 'success')
enterIdleMode()
setStatus('已关闭编辑。', 'success')
})
document.getElementById('closeImageViewerBtn').addEventListener('click', function () {
imageViewerEl.classList.remove('show')
imageViewerImgEl.src = ''
})
imageViewerEl.addEventListener('click', function (event) {
if (event.target === imageViewerEl) {
imageViewerEl.classList.remove('show')
imageViewerImgEl.src = ''
}
})
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && imageViewerEl.classList.contains('show')) {
imageViewerEl.classList.remove('show')
imageViewerImgEl.src = ''
}
})
document.getElementById('resetBtn').addEventListener('click', function () {
if (state.mode === 'edit' && state.editingSource) {
@@ -1148,10 +1373,15 @@ routerAdd('GET', '/manage/document-manage', function (e) {
appendPendingFiles('video', event.target.files)
fields.videoFile.value = ''
})
fields.documentFile.addEventListener('change', function (event) {
appendPendingFiles('file', event.target.files)
fields.documentFile.value = ''
})
bindDropzone(imageDropzoneEl, fields.imageFile, 'image')
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
bindDropzone(documentFileDropzoneEl, fields.documentFile, 'file')
enterCreateMode()
enterIdleMode()
;(async function initPage() {
try {
await loadDictionaries()

View File

@@ -39,6 +39,10 @@ routerAdd('GET', '/manage', function (e) {
<h2>文档管理</h2>
<a class="btn" href="/pb/manage/document-manage">进入文档管理</a>
</article>
<article class="card">
<h2>SDK 权限管理</h2>
<a class="btn" href="/pb/manage/sdk-permission-manage">进入权限管理</a>
</article>
</div>
<div class="actions">
<button id="logoutBtn" class="btn" type="button" style="background:#dc2626;">退出登录</button>

View File

@@ -0,0 +1,717 @@
routerAdd('GET', '/manage/sdk-permission-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>SDK 权限管理</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, #eef6ff 0%, #f8fafc 100%); color: #0f172a; }
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px 40px; }
.panel { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); border-radius: 20px; padding: 18px; }
.panel + .panel { margin-top: 14px; }
h1, h2, h3 { margin-top: 0; }
p { color: #475569; line-height: 1.7; }
.actions, .toolbar, .row-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 700; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-warning { background: #f59e0b; color: #fff; }
.btn-success { background: #16a34a; color: #fff; }
.status { margin-top: 12px; min-height: 24px; font-size: 14px; }
.status.success { color: #15803d; }
.status.error { color: #b91c1c; }
.note { padding: 14px 16px; border-radius: 16px; background: #eff6ff; color: #1d4ed8; font-size: 14px; line-height: 1.7; }
table { width: 100%; border-collapse: collapse; }
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; }
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 9px 11px; font-size: 14px; background: #fff; }
textarea { min-height: 80px; resize: vertical; }
.empty { text-align: center; padding: 24px 16px; color: #64748b; }
.muted { color: #64748b; font-size: 12px; }
.grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.full { grid-column: 1 / -1; }
.rule-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
.rule-card { border: 1px solid #dbe3f0; border-radius: 16px; padding: 12px; background: #f8fbff; }
.rule-card h4 { margin: 0 0 10px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.rule-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; color: #475569; font-size: 12px; }
.rule-meta input[type="checkbox"] { width: auto; margin: 0; }
.rule-toggle { display: inline-flex; align-items: center; gap: 6px; color: #0f172a; font-size: 13px; font-weight: 600; }
.rule-toggle input[type="checkbox"] { width: auto; margin: 0; }
.split { display: grid; grid-template-columns: 1.15fr 1fr; gap: 14px; }
.table-wrap { overflow: auto; }
.collection-table { table-layout: fixed; }
.collection-col { width: 264px; }
.rule-col { width: calc(100% - 264px); }
.collection-meta { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.loading-mask { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.42); backdrop-filter: blur(4px); z-index: 9998; }
.loading-mask.show { display: flex; }
.loading-card { min-width: min(92vw, 360px); padding: 24px 22px; border-radius: 20px; background: rgba(255,255,255,0.98); box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22); border: 1px solid #dbe3f0; text-align: center; }
.loading-spinner { width: 44px; height: 44px; margin: 0 auto 14px; border-radius: 999px; border: 4px solid #dbeafe; border-top-color: #2563eb; animation: sdkSpin 0.9s linear infinite; }
.loading-text { color: #0f172a; font-size: 15px; font-weight: 700; }
@keyframes sdkSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 1100px) {
.split, .rule-grid, .grid { grid-template-columns: 1fr; }
.collection-col, .rule-col { width: auto; }
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; flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<main class="wrap">
<section class="panel">
<h1>SDK 权限管理</h1>
<p>这里管理的是 <code>tbl_auth_users</code> 用户通过 PocketBase SDK 直连数据库时的业务权限。<strong>ManagePlatform</strong> 会被视为你的业务管理员,但不会自动变成 PocketBase 原生 <code>_superusers</code>。</p>
<div class="actions">
<a class="btn btn-light" href="/pb/manage">返回主页</a>
<button class="btn btn-light" id="refreshBtn" type="button">刷新数据</button>
<button class="btn btn-success" id="syncManageBtn" type="button">同步 ManagePlatform 全权限</button>
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
</div>
<div class="status" id="status"></div>
</section>
<section class="panel">
<div class="note" id="noteBox">加载中...</div>
</section>
<section class="panel">
<h2>角色管理</h2>
<div class="grid">
<input id="newRoleName" placeholder="角色名称" />
<input id="newRoleCode" placeholder="角色编码,可为空" />
<input id="newRoleStatus" type="number" placeholder="状态默认1" value="1" />
<button class="btn btn-primary" id="createRoleBtn" type="button">新增角色</button>
<div class="muted">角色 ID 由系统自动生成,页面不显示。</div>
<textarea id="newRoleRemark" class="full" placeholder="备注"></textarea>
</div>
<div class="table-wrap" style="margin-top:16px;">
<table>
<thead>
<tr>
<th>名称</th>
<th>编码</th>
<th>状态</th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody id="roleTableBody">
<tr><td colspan="5" class="empty">暂无角色。</td></tr>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>用户授权</h2>
<div class="toolbar">
<input id="userKeywordInput" placeholder="按姓名、手机号、openid、角色搜索" />
<button class="btn btn-light" id="searchUserBtn" type="button">查询用户</button>
</div>
<div class="table-wrap" style="margin-top:16px;">
<table>
<thead>
<tr>
<th>用户</th>
<th>身份类型</th>
<th>当前角色</th>
<th>授权</th>
<th>操作</th>
</tr>
</thead>
<tbody id="userTableBody">
<tr><td colspan="5" class="empty">暂无用户。</td></tr>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Collection 直连权限</h2>
<div class="toolbar">
<select id="permissionRoleSelect"></select>
<div class="muted">这里是按“当前配置角色”逐表分配 CRUD 权限。下面依次对应“列表、详情、新增、修改、删除”五种权限,勾选后会立即保存。公开访问或自定义规则的操作不会显示授权勾选框。</div>
</div>
<div class="table-wrap" style="margin-top:16px;">
<table class="collection-table">
<thead>
<tr>
<th class="collection-col">集合</th>
<th class="rule-col">当前角色权限</th>
</tr>
</thead>
<tbody id="collectionTableBody">
<tr><td colspan="2" class="empty">暂无集合。</td></tr>
</tbody>
</table>
</div>
</section>
</main>
<div class="loading-mask" id="loadingMask">
<div class="loading-card">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">处理中,请稍候...</div>
</div>
</div>
<script>
const API_BASE = '/pb/api/sdk-permission'
const tokenKey = 'pb_manage_token'
const statusEl = document.getElementById('status')
const noteBox = document.getElementById('noteBox')
const roleTableBody = document.getElementById('roleTableBody')
const userTableBody = document.getElementById('userTableBody')
const collectionTableBody = document.getElementById('collectionTableBody')
const permissionRoleSelect = document.getElementById('permissionRoleSelect')
const loadingMask = document.getElementById('loadingMask')
const loadingText = document.getElementById('loadingText')
const loadingState = { count: 0 }
const state = {
roles: [],
users: [],
collections: [],
userKeyword: '',
selectedPermissionRoleId: '',
}
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
}
function showLoading(message) {
loadingState.count += 1
loadingText.textContent = message || '处理中,请稍候...'
loadingMask.classList.add('show')
}
function hideLoading() {
loadingState.count = Math.max(0, loadingState.count - 1)
if (!loadingState.count) {
loadingMask.classList.remove('show')
loadingText.textContent = '处理中,请稍候...'
}
}
function getToken() {
return localStorage.getItem(tokenKey) || ''
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function getRoleById(roleId) {
const target = String(roleId || '')
return state.roles.find(function (role) {
return role.role_id === target
}) || null
}
function getRoleName(roleId) {
const role = getRoleById(roleId)
return role ? role.role_name : ''
}
function syncSelectedPermissionRole() {
const exists = state.roles.some(function (role) {
return role.role_id === state.selectedPermissionRoleId
})
if (!exists) {
state.selectedPermissionRoleId = state.roles.length ? state.roles[0].role_id : ''
}
}
async function requestJson(path, 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(API_BASE + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify(payload || {}),
})
const data = await res.json()
if (!res.ok || !data || 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 && data.msg) || '请求失败')
}
return data.data
}
function roleOptionsHtml(selectedRoleId) {
const current = String(selectedRoleId || '')
return ['<option value="">未分配</option>'].concat(state.roles.map(function (role) {
return '<option value="' + escapeHtml(role.role_id) + '"' + (current === role.role_id ? ' selected' : '') + '>' + escapeHtml(role.role_name) + '</option>'
})).join('')
}
function renderRoles() {
if (!state.roles.length) {
roleTableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无角色。</td></tr>'
return
}
roleTableBody.innerHTML = state.roles.map(function (role) {
return '<tr data-role-id="' + escapeHtml(role.role_id) + '">'
+ '<td><input data-role-field="role_name" value="' + escapeHtml(role.role_name) + '" /></td>'
+ '<td><input data-role-field="role_code" value="' + escapeHtml(role.role_code) + '" /></td>'
+ '<td><input data-role-field="role_status" type="number" value="' + escapeHtml(role.role_status) + '" /></td>'
+ '<td><textarea data-role-field="role_remark">' + escapeHtml(role.role_remark) + '</textarea></td>'
+ '<td><div class="row-actions"><button class="btn btn-light" type="button" onclick="window.__saveRoleRow(\\'' + encodeURIComponent(role.role_id) + '\\')">保存</button><button class="btn btn-danger" type="button" onclick="window.__deleteRoleRow(\\'' + encodeURIComponent(role.role_id) + '\\')">删除</button></div></td>'
+ '</tr>'
}).join('')
}
function renderUsers() {
if (!state.users.length) {
userTableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无匹配用户。</td></tr>'
return
}
userTableBody.innerHTML = state.users.map(function (user) {
const name = user.users_name || '未命名用户'
const phone = user.users_phone || '无手机号'
const roleName = user.role_name || getRoleName(user.usergroups_id) || '未分配'
return '<tr data-user-id="' + escapeHtml(user.pb_id) + '">'
+ '<td><div><strong>' + escapeHtml(name) + '</strong></div><div class="muted">' + escapeHtml(phone) + '</div><div class="muted">' + escapeHtml(user.openid) + '</div></td>'
+ '<td><div>' + escapeHtml(user.users_idtype || '') + '</div><div class="muted">' + escapeHtml(user.users_type || '') + '</div></td>'
+ '<td>' + escapeHtml(roleName) + '</td>'
+ '<td><select data-user-role-select="1">' + roleOptionsHtml(user.usergroups_id) + '</select></td>'
+ '<td><button class="btn btn-light" type="button" onclick="window.__saveUserRole(\\'' + escapeHtml(user.pb_id) + '\\')">保存角色</button></td>'
+ '</tr>'
}).join('')
}
function renderPermissionRoleOptions() {
if (!state.roles.length) {
permissionRoleSelect.innerHTML = '<option value="">暂无角色</option>'
permissionRoleSelect.disabled = true
return
}
permissionRoleSelect.disabled = false
permissionRoleSelect.innerHTML = state.roles.map(function (role) {
return '<option value="' + escapeHtml(role.role_id) + '"' + (role.role_id === state.selectedPermissionRoleId ? ' selected' : '') + '>' + escapeHtml(role.role_name) + '</option>'
}).join('')
}
function getOperationLabel(operation) {
const map = {
list: '列表',
view: '详情',
create: '新增',
update: '修改',
delete: '删除',
}
return map[operation] || operation
}
function getRuleSummary(config, selectedRoleId) {
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
const items = []
if (current.mode === 'public') items.push('公开可访问')
if (current.mode === 'authenticated') items.push('登录用户可访问')
if (current.includeManagePlatform) items.push('含 ManagePlatform')
if (current.mode === 'custom') items.push('自定义规则')
if (Array.isArray(current.roles) && current.roles.length) {
items.push('已分配角色数:' + current.roles.length)
}
return items.length ? items.join('') : '当前无额外说明'
}
function canControlRule(config) {
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
return !!state.selectedPermissionRoleId && current.mode !== 'custom' && current.mode !== 'public'
}
function isCollectionFullyChecked(collection) {
if (!collection || !collection.parsedRules) {
return false
}
const operations = ['list', 'view', 'create', 'update', 'delete']
let controllableCount = 0
let checkedCount = 0
for (let i = 0; i < operations.length; i += 1) {
const config = collection.parsedRules[operations[i]]
if (!canControlRule(config)) {
continue
}
controllableCount += 1
if (config && Array.isArray(config.roles) && config.roles.indexOf(state.selectedPermissionRoleId) !== -1) {
checkedCount += 1
}
}
return controllableCount > 0 && controllableCount === checkedCount
}
function getCollectionControllableCount(collection) {
if (!collection || !collection.parsedRules) {
return 0
}
const operations = ['list', 'view', 'create', 'update', 'delete']
let controllableCount = 0
for (let i = 0; i < operations.length; i += 1) {
if (canControlRule(collection.parsedRules[operations[i]])) {
controllableCount += 1
}
}
return controllableCount
}
function renderRuleCard(collectionName, operation, config) {
const current = config || { mode: 'locked', includeManagePlatform: false, roles: [], rawExpression: '' }
const selectedRoleId = state.selectedPermissionRoleId
const checked = selectedRoleId && Array.isArray(current.roles) && current.roles.indexOf(selectedRoleId) !== -1
const canControl = canControlRule(current)
const summary = current.mode === 'public'
? '可公开访问'
: getRuleSummary(current, selectedRoleId)
return '<div class="rule-card" data-collection="' + escapeHtml(collectionName) + '" data-op="' + operation + '">'
+ '<h4>' + (canControl ? '<label class="rule-toggle"><input type="checkbox" data-rule-field="allowSelectedRole"' + (checked ? ' checked' : '') + ' /></label>' : '') + '<span>' + getOperationLabel(operation) + '</span></h4>'
+ '<div class="muted" style="margin-top:8px;">' + escapeHtml(summary) + '</div>'
+ (current.mode === 'custom' ? '<div class="muted" style="margin-top:8px;">当前操作使用 custom 规则,禁止修改</div>' : '')
+ '</div>'
}
function renderCollections() {
if (!state.collections.length) {
collectionTableBody.innerHTML = '<tr><td colspan="2" class="empty">暂无可管理集合。</td></tr>'
return
}
collectionTableBody.innerHTML = state.collections.map(function (collection) {
const allChecked = isCollectionFullyChecked(collection)
const controllableCount = getCollectionControllableCount(collection)
return '<tr data-collection-row="' + escapeHtml(collection.name) + '">'
+ '<td class="collection-col"><div class="collection-meta"><div><strong>' + escapeHtml(collection.name) + '</strong></div><div class="muted">' + escapeHtml(collection.type) + '</div><label class="rule-toggle"><input type="checkbox" data-rule-field="toggleCollection"' + (allChecked ? ' checked' : '') + (state.selectedPermissionRoleId && controllableCount > 0 ? '' : ' disabled') + ' /><span>全选</span></label></div></td>'
+ '<td><div class="rule-grid">'
+ renderRuleCard(collection.name, 'list', collection.parsedRules.list)
+ renderRuleCard(collection.name, 'view', collection.parsedRules.view)
+ renderRuleCard(collection.name, 'create', collection.parsedRules.create)
+ renderRuleCard(collection.name, 'update', collection.parsedRules.update)
+ renderRuleCard(collection.name, 'delete', collection.parsedRules.delete)
+ '</div></td>'
+ '</tr>'
}).join('')
}
async function loadContext() {
showLoading('正在加载权限管理数据...')
setStatus('正在加载权限管理数据...', '')
try {
const data = await requestJson('/context', { keyword: state.userKeyword })
state.roles = Array.isArray(data.roles) ? data.roles : []
state.users = Array.isArray(data.users) ? data.users : []
state.collections = Array.isArray(data.collections) ? data.collections : []
syncSelectedPermissionRole()
noteBox.textContent = data.note || '权限管理说明已加载。'
renderRoles()
renderUsers()
renderPermissionRoleOptions()
renderCollections()
setStatus('权限管理数据已刷新。', 'success')
} catch (err) {
setStatus(err.message || '加载权限管理数据失败', 'error')
} finally {
hideLoading()
}
}
function getRoleRowPayload(roleId) {
const targetId = decodeURIComponent(roleId)
const row = roleTableBody.querySelector('[data-role-id="' + targetId.replace(/"/g, '\\"') + '"]')
if (!row) {
throw new Error('未找到对应角色行')
}
const find = function (fieldName) {
const input = row.querySelector('[data-role-field="' + fieldName + '"]')
return input ? input.value : ''
}
return {
original_role_id: targetId,
role_name: find('role_name'),
role_code: find('role_code'),
role_status: find('role_status'),
role_remark: find('role_remark'),
}
}
function getRuleBoxConfig(collectionName, operation) {
const collection = state.collections.find(function (item) {
return item.name === collectionName
})
const current = collection && collection.parsedRules ? collection.parsedRules[operation] : null
const base = current || {
mode: 'locked',
includeManagePlatform: false,
roles: [],
rawExpression: '',
}
if (base.mode === 'custom') {
return {
mode: 'custom',
includeManagePlatform: !!base.includeManagePlatform,
roles: Array.isArray(base.roles) ? base.roles.slice() : [],
rawExpression: base.rawExpression || '',
}
}
const box = collectionTableBody.querySelector('.rule-card[data-collection="' + collectionName.replace(/"/g, '\\"') + '"][data-op="' + operation + '"]')
const allowEl = box ? box.querySelector('[data-rule-field="allowSelectedRole"]') : null
const roles = Array.isArray(base.roles) ? base.roles.slice() : []
const selectedRoleId = state.selectedPermissionRoleId
const nextRoles = roles.filter(function (roleId) {
return roleId !== selectedRoleId
})
if (selectedRoleId && allowEl && allowEl.checked && nextRoles.indexOf(selectedRoleId) === -1) {
nextRoles.push(selectedRoleId)
}
let nextMode = base.mode
if (nextMode === 'locked' && nextRoles.length) {
nextMode = 'roleBased'
} else if (nextMode === 'roleBased' && !nextRoles.length && !base.includeManagePlatform) {
nextMode = 'locked'
}
return {
mode: nextMode,
includeManagePlatform: !!base.includeManagePlatform,
roles: nextRoles,
rawExpression: base.rawExpression || '',
}
}
async function createRole() {
const payload = {
role_name: document.getElementById('newRoleName').value.trim(),
role_code: document.getElementById('newRoleCode').value.trim(),
role_status: document.getElementById('newRoleStatus').value.trim() || '1',
role_remark: document.getElementById('newRoleRemark').value.trim(),
}
showLoading('正在新增角色...')
try {
await requestJson('/role-save', payload)
document.getElementById('newRoleName').value = ''
document.getElementById('newRoleCode').value = ''
document.getElementById('newRoleStatus').value = '1'
document.getElementById('newRoleRemark').value = ''
await loadContext()
setStatus('角色新增成功。', 'success')
} catch (err) {
setStatus(err.message || '新增角色失败', 'error')
} finally {
hideLoading()
}
}
async function saveRoleRow(roleId) {
showLoading('正在保存角色...')
try {
await requestJson('/role-save', getRoleRowPayload(roleId))
await loadContext()
setStatus('角色保存成功。', 'success')
} catch (err) {
setStatus(err.message || '保存角色失败', 'error')
} finally {
hideLoading()
}
}
async function deleteRoleRow(roleId) {
const targetId = decodeURIComponent(roleId)
if (!window.confirm('确认删除角色「' + targetId + '」吗?这会清空绑定该角色的用户,并从已解析的集合规则中移除它。')) {
return
}
showLoading('正在删除角色...')
try {
await requestJson('/role-delete', { role_id: targetId })
await loadContext()
setStatus('角色删除成功。', 'success')
} catch (err) {
setStatus(err.message || '删除角色失败', 'error')
} finally {
hideLoading()
}
}
async function saveUserRole(pbId) {
const row = userTableBody.querySelector('[data-user-id="' + pbId.replace(/"/g, '\\"') + '"]')
const select = row ? row.querySelector('[data-user-role-select="1"]') : null
const payload = {
pb_id: pbId,
usergroups_id: select ? select.value : '',
}
showLoading('正在保存用户角色...')
try {
await requestJson('/user-role-update', payload)
await loadContext()
setStatus('用户角色已更新。', 'success')
} catch (err) {
setStatus(err.message || '更新用户角色失败', 'error')
} finally {
hideLoading()
}
}
async function saveCollectionRules(collectionName) {
const targetName = decodeURIComponent(collectionName)
if (!state.selectedPermissionRoleId) {
setStatus('请先选择一个要配置权限的角色。', 'error')
return
}
const payload = {
collection_name: targetName,
rules: {
list: getRuleBoxConfig(targetName, 'list'),
view: getRuleBoxConfig(targetName, 'view'),
create: getRuleBoxConfig(targetName, 'create'),
update: getRuleBoxConfig(targetName, 'update'),
delete: getRuleBoxConfig(targetName, 'delete'),
},
}
showLoading('正在同步集合权限...')
try {
await requestJson('/collection-save', payload)
await loadContext()
setStatus('已保存角色「' + (getRoleName(state.selectedPermissionRoleId) || '未命名角色') + '」在集合「' + targetName + '」上的权限。', 'success')
} catch (err) {
setStatus(err.message || '保存集合权限失败', 'error')
} finally {
hideLoading()
}
}
async function syncManagePlatform() {
if (!window.confirm('确认将 ManagePlatform 同步为所有业务集合的全权限吗?这不会创建 _superusers但会为业务表开放全部 CRUD。')) {
return
}
showLoading('正在同步 ManagePlatform 全权限...')
try {
const data = await requestJson('/manageplatform-sync', {})
await loadContext()
setStatus('已同步 ManagePlatform 全权限,共处理 ' + String((data && data.count) || 0) + ' 个集合。', 'success')
} catch (err) {
setStatus(err.message || '同步 ManagePlatform 全权限失败', 'error')
} finally {
hideLoading()
}
}
window.__saveRoleRow = saveRoleRow
window.__deleteRoleRow = deleteRoleRow
window.__saveUserRole = saveUserRole
window.__saveCollectionRules = saveCollectionRules
document.getElementById('createRoleBtn').addEventListener('click', createRole)
document.getElementById('refreshBtn').addEventListener('click', loadContext)
document.getElementById('syncManageBtn').addEventListener('click', syncManagePlatform)
permissionRoleSelect.addEventListener('change', function () {
state.selectedPermissionRoleId = permissionRoleSelect.value || ''
renderPermissionRoleOptions()
renderCollections()
})
collectionTableBody.addEventListener('change', function (event) {
const target = event.target
if (!target) {
return
}
const field = target.getAttribute('data-rule-field')
if (field === 'allowSelectedRole') {
const box = target.closest('.rule-card')
if (!box) {
return
}
const collectionName = box.getAttribute('data-collection') || ''
saveCollectionRules(collectionName)
return
}
if (field === 'toggleCollection') {
const row = target.closest('[data-collection-row]')
if (!row) {
return
}
const checkboxes = row.querySelectorAll('[data-rule-field="allowSelectedRole"]')
for (let i = 0; i < checkboxes.length; i += 1) {
if (!checkboxes[i].disabled) {
checkboxes[i].checked = !!target.checked
}
}
const collectionName = row.getAttribute('data-collection-row') || ''
saveCollectionRules(collectionName)
}
})
document.getElementById('searchUserBtn').addEventListener('click', function () {
state.userKeyword = document.getElementById('userKeywordInput').value.trim()
loadContext()
})
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')
})
loadContext()
</script>
</body>
</html>`
return e.html(200, html)
})

View File

@@ -57,6 +57,28 @@
- 已将 schema 脚本中的 `user_id` 改为非必填。
- 其余业务字段保持非必填。
### 4. 用户图片字段统一改为附件 ID 语义
- `users_picture`
- `users_id_pic_a`
- `users_id_pic_b`
- `users_title_picture`
以上字段已统一改为保存 `tbl_attachments.attachments_id`,不再直接保存 PocketBase `file` 字段或外部图片 URL。
查询用户信息时hooks 会自动联查 `tbl_attachments` 并补充:
- `users_picture_url`
- `users_id_pic_a_url`
- `users_id_pic_b_url`
- `users_title_picture_url`
说明:
- `tbl_attachments` 仍由附件表保存实际文件本体;
- 业务表仅负责保存附件 ID
- hooks 中原有 `ManagePlatform` 访问限制保持不变。
---
## 三、查询与排序修复
@@ -238,7 +260,7 @@
- 自动写入 `attachments_owner = 当前用户 openid`
- `POST /pb/api/attachment/delete`
-`attachments_id` 真删除附件
- 若该附件已被 `tbl_document.document_image``document_video` 中的任一附件列表引用,则拒绝删除
- 若该附件已被 `tbl_document.document_image``document_video``document_file` 中的任一附件列表引用,则拒绝删除
说明:
@@ -258,8 +280,10 @@
- 额外补充:
- `document_image_urls`
- `document_video_urls`
- `document_file_urls`
- `document_image_attachments`
- `document_video_attachments`
- `document_file_attachments`
- `POST /pb/api/document/detail`
-`document_id` 查询单条文档
- 返回与附件表联动解析后的多文件流链接
@@ -267,7 +291,7 @@
- 新增文档
- `document_id` 可不传,由服务端自动生成
- `document_title``document_type` 为必填;其余字段均允许为空
- `document_image``document_video` 支持传入多个已存在的 `attachments_id`
- `document_image``document_video``document_file` 支持传入多个已存在的 `attachments_id`
- `document_type` 前端从单个字典来源中多选枚举值,最终按 `system_dict_id@dict_word_enum|...` 保存
- `document_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一从固定字典多选并按 `|` 保存
- 其中 `document_product_categories` 改为从 `文档-产品关联文档` 读取,`document_application_scenarios` 改为从 `文档-筛选依据` 读取,`document_hotel_type` 改为从 `文档-适用场景` 读取
@@ -285,7 +309,7 @@
说明:
- `document_image``document_video` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
- `document_image``document_video``document_file` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。
- `document_owner` 语义为“上传者 openid”。
@@ -337,8 +361,8 @@
- 返回主页
- 文档管理页支持:
- 先上传附件到 `tbl_attachments`
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video`
- 图片视频都支持多选上传
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video` / `document_file`
- 图片视频、文件都支持多选上传
- 新增文档
- 编辑已有文档并回显多图片、多视频
- 从文档中移除附件并在保存后删除对应附件记录

View File

@@ -130,6 +130,28 @@ components:
type: string
users_picture:
type: string
description: "用户头像附件的 `attachments_id`"
users_picture_url:
type: string
description: "根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接"
users_id_pic_a:
type: string
description: "证件正面附件的 `attachments_id`"
users_id_pic_a_url:
type: string
description: "根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接"
users_id_pic_b:
type: string
description: "证件反面附件的 `attachments_id`"
users_id_pic_b_url:
type: string
description: "根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接"
users_title_picture:
type: string
description: "资质附件的 `attachments_id`"
users_title_picture_url:
type: string
description: "根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接"
openid:
type: string
description: "全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID"
@@ -190,6 +212,13 @@ components:
users_auth_type: 0
users_type: 注册用户
users_picture: ''
users_picture_url: ''
users_id_pic_a: ''
users_id_pic_a_url: ''
users_id_pic_b: ''
users_id_pic_b_url: ''
users_title_picture: ''
users_title_picture_url: ''
openid: app_momo
company_id: ''
users_parent_id: ''
@@ -221,7 +250,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f
users_picture:
type: string
example: https://example.com/avatar.png
description: "用户头像附件的 `attachments_id`"
example: ATT-1743123456789-abc123
users_id_pic_a:
type: string
description: "可选。证件正面附件的 `attachments_id`"
users_id_pic_b:
type: string
description: "可选。证件反面附件的 `attachments_id`"
users_title_picture:
type: string
description: "可选。资质附件的 `attachments_id`"
WechatProfileResponseData:
type: object
properties:
@@ -249,9 +288,19 @@ components:
example: 12345678
users_picture:
type: string
example: https://example.com/avatar.png
description: "用户头像附件的 `attachments_id`"
example: ATT-1743123456789-abc123
users_id_number:
type: string
users_id_pic_a:
type: string
description: "可选。证件正面附件的 `attachments_id`"
users_id_pic_b:
type: string
description: "可选。证件反面附件的 `attachments_id`"
users_title_picture:
type: string
description: "可选。资质附件的 `attachments_id`"
users_level:
type: string
users_type:
@@ -537,6 +586,30 @@ components:
anyOf:
- $ref: '#/components/schemas/AttachmentRecord'
- type: 'null'
document_file:
type: string
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_file_ids:
type: array
description: "`document_file` 解析后的附件 id 列表"
items:
type: string
document_file_urls:
type: array
description: "根据 `document_file -> tbl_attachments` 自动解析出的文件流链接列表"
items:
type: string
document_file_url:
type: string
description: "兼容字段,返回第一个文件的文件流链接"
document_file_attachments:
type: array
items:
$ref: '#/components/schemas/AttachmentRecord'
document_file_attachment:
anyOf:
- $ref: '#/components/schemas/AttachmentRecord'
- type: 'null'
document_owner:
type: string
description: "上传者 openid"
@@ -641,6 +714,14 @@ components:
items:
type: string
description: "视频附件 id 列表;支持数组或 `|` 分隔字符串"
document_file:
oneOf:
- type: string
description: "多个文件附件 id 使用 `|` 分隔"
- type: array
items:
type: string
description: "文件附件 id 列表;支持数组或 `|` 分隔字符串"
document_relation_model:
type: string
document_keywords:
@@ -1144,7 +1225,7 @@ paths:
summary: 删除附件
description: |
仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 引用,则拒绝删除。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 或 `document_file` 引用,则拒绝删除。
requestBody:
required: true
content:
@@ -1185,8 +1266,8 @@ paths:
description: |
仅允许 `ManagePlatform` 用户访问。
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。
返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
requestBody:
required: false
content:
@@ -1259,7 +1340,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。
`document_id` 可选;未传时服务端自动生成。
`document_title`、`document_type` 为必填;其余字段均允许为空。
`document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
`document_image`、`document_video`、`document_file` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
requestBody:
required: true
@@ -1297,7 +1378,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。
按 `document_id` 定位现有文档并更新。
`document_title`、`document_type` 为必填;其余字段均允许为空。
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。
若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
requestBody:
required: true

View File

@@ -0,0 +1,449 @@
openapi: 3.1.0
info:
title: PocketBase MiniApp Company API
version: 1.0.0
summary: 小程序端通过 PocketBase JS SDK 直连 tbl_company 的基础 CRUD 文档
description: >-
本文档面向小程序端直接使用 PocketBase JS SDK / REST API 访问 `tbl_company`。
本文档统一以 PocketBase 原生记录主键 `id` 作为唯一识别键。
`company_id` 保留为普通业务字段,可用于展示、筛选和业务关联,但不再作为 CRUD 的唯一键。
license:
name: Proprietary
identifier: LicenseRef-Proprietary
servers:
- url: https://bai-api.blv-oa.com/pb
description: 线上 PocketBase 服务
tags:
- name: Company
description: tbl_company 公司信息基础 CRUD
security:
- pocketbaseAuth: []
paths:
/api/collections/tbl_company/records:
get:
tags: [Company]
operationId: listCompanyRecords
summary: 查询公司列表
description: >-
使用 PocketBase 原生 records list/search 接口查询 `tbl_company`。
支持三种常见模式:
1. 全表查询:不传 `filter`
2. 精确查询:`filter=id="q1w2e3r4t5y6u7i"`
3. 模糊查询:`filter=(company_name~"华住" || company_usci~"9131" || company_entity~"张三")`。
parameters:
- $ref: '#/components/parameters/Page'
- $ref: '#/components/parameters/PerPage'
- $ref: '#/components/parameters/Sort'
- $ref: '#/components/parameters/Filter'
- $ref: '#/components/parameters/Fields'
- $ref: '#/components/parameters/SkipTotal'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyListResponse'
examples:
all:
summary: 全表查询
value:
page: 1
perPage: 30
totalItems: 2
totalPages: 1
items:
- id: q1w2e3r4t5y6u7i
collectionId: pbc_company_demo
collectionName: tbl_company
created: '2026-03-27 10:00:00.000Z'
updated: '2026-03-27 10:00:00.000Z'
company_id: C10001
company_name: 宝镜科技
company_type: 渠道商
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_province: 上海
company_city: 上海
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_remark: ''
exact:
summary: 按 id 精确查询
value:
page: 1
perPage: 1
totalItems: 1
totalPages: 1
items:
- id: q1w2e3r4t5y6u7i
collectionId: pbc_company_demo
collectionName: tbl_company
created: '2026-03-27 10:00:00.000Z'
updated: '2026-03-27 10:00:00.000Z'
company_id: C10001
company_name: 宝镜科技
company_type: 渠道商
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_province: 上海
company_city: 上海
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_remark: ''
'400':
description: 过滤表达式或查询参数不合法
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'403':
description: 当前调用方没有 list 权限
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
post:
tags: [Company]
operationId: createCompanyRecord
summary: 新增公司
description: >-
创建一条 `tbl_company` 记录。当前文档以 `id` 作为记录唯一识别键,
新建成功后由 PocketBase 自动生成 `id`;根据当前项目建表脚本,`company_id` 仍是必填业务字段,但不再作为 CRUD 唯一键。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyCreateRequest'
examples:
default:
value:
company_id: C10001
company_name: 宝镜科技
company_type: 渠道商
company_entity: 张三
company_usci: '91310000123456789A'
company_nationality: 中国
company_province: 上海
company_city: 上海
company_postalcode: '200000'
company_add: 上海市浦东新区XX路1号
company_status: 有效
company_level: A
company_remark: 首次创建
responses:
'200':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyRecord'
'400':
description: 校验失败,例如字段类型不合法或违反当前集合约束
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'403':
description: 当前调用方没有 create 权限
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'404':
description: 集合不存在
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
/api/collections/tbl_company/records/{recordId}:
get:
tags: [Company]
operationId: getCompanyRecordByRecordId
summary: 按 PocketBase 记录 id 查询公司
description: >-
这是 PocketBase 原生单条查询接口,路径参数必须传记录主键 `id`。
parameters:
- $ref: '#/components/parameters/RecordId'
- $ref: '#/components/parameters/Fields'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyRecord'
'403':
description: 当前调用方没有 view 权限
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'404':
description: 记录不存在
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
patch:
tags: [Company]
operationId: updateCompanyRecordByRecordId
summary: 按 PocketBase 记录 id 更新公司
description: >-
这是 PocketBase 原生更新接口,路径参数统一使用记录主键 `id`。
parameters:
- $ref: '#/components/parameters/RecordId'
- $ref: '#/components/parameters/Fields'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyUpdateRequest'
examples:
default:
value:
company_name: 宝镜科技(更新)
company_status: 有效
company_level: S
company_remark: 已更新基础资料
responses:
'200':
description: 更新成功
content:
application/json:
schema:
$ref: '#/components/schemas/CompanyRecord'
'400':
description: 更新参数不合法
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'403':
description: 当前调用方没有 update 权限
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'404':
description: 记录不存在
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
delete:
tags: [Company]
operationId: deleteCompanyRecordByRecordId
summary: 按 PocketBase 记录 id 删除公司
description: >-
这是 PocketBase 原生删除接口,路径参数统一使用记录主键 `id`。
parameters:
- $ref: '#/components/parameters/RecordId'
responses:
'204':
description: 删除成功
'400':
description: 删除失败,例如仍被必填 relation 引用
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'403':
description: 当前调用方没有 delete 权限
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
'404':
description: 记录不存在
content:
application/json:
schema:
$ref: '#/components/schemas/PocketBaseError'
components:
securitySchemes:
pocketbaseAuth:
type: apiKey
in: header
name: Authorization
description: PocketBase 认证 token。使用 JS SDK 时通常由 `pb.authStore` 自动附带。
parameters:
Page:
name: page
in: query
description: 页码,默认 1
schema:
type: integer
minimum: 1
default: 1
PerPage:
name: perPage
in: query
description: 每页返回条数,默认 30
schema:
type: integer
minimum: 1
default: 30
Sort:
name: sort
in: query
description: 排序字段,例如 `-created,+company_name`
schema:
type: string
Filter:
name: filter
in: query
description: >-
PocketBase 过滤表达式。
精确查询示例:`id="q1w2e3r4t5y6u7i"`
模糊查询示例:`(company_name~"宝镜" || company_usci~"9131" || company_entity~"张三")`
schema:
type: string
Fields:
name: fields
in: query
description: 逗号分隔的返回字段列表,例如 `id,company_id,company_name`
schema:
type: string
SkipTotal:
name: skipTotal
in: query
description: 是否跳过 totalItems/totalPages 统计
schema:
type: boolean
default: false
RecordId:
name: recordId
in: path
required: true
description: PocketBase 记录主键 id
schema:
type: string
schemas:
CompanyBase:
type: object
properties:
company_id:
type: string
description: 公司业务编号字段,不再作为 CRUD 唯一键
company_name:
type: string
description: 公司名称
company_type:
type: string
description: 公司类型
company_entity:
type: string
description: 公司法人
company_usci:
type: string
description: 统一社会信用代码
company_nationality:
type: string
description: 国家
company_province:
type: string
description: 省份
company_city:
type: string
description: 城市
company_postalcode:
type: string
description: 邮编
company_add:
type: string
description: 地址
company_status:
type: string
description: 公司状态
company_level:
type: string
description: 公司等级
company_remark:
type: string
description: 备注
CompanyCreateRequest:
allOf:
- $ref: '#/components/schemas/CompanyBase'
- type: object
required: [company_id]
CompanyUpdateRequest:
type: object
description: >-
更新时可只传需要修改的字段;记录定位统一依赖路径参数 `id`。
properties:
company_id:
type: string
company_name:
type: string
company_type:
type: string
company_entity:
type: string
company_usci:
type: string
company_nationality:
type: string
company_province:
type: string
company_city:
type: string
company_postalcode:
type: string
company_add:
type: string
company_status:
type: string
company_level:
type: string
company_remark:
type: string
CompanyRecord:
allOf:
- type: object
properties:
id:
type: string
description: PocketBase 记录主键 id
collectionId:
type: string
collectionName:
type: string
created:
type: string
updated:
type: string
- $ref: '#/components/schemas/CompanyBase'
CompanyListResponse:
type: object
properties:
page:
type: integer
perPage:
type: integer
totalItems:
type: integer
totalPages:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/CompanyRecord'
PocketBaseError:
type: object
properties:
status:
type: integer
message:
type: string
data:
type: object
additionalProperties: true

View File

@@ -297,6 +297,28 @@ components:
type: string
users_picture:
type: string
description: 用户头像附件的 `attachments_id`
users_picture_url:
type: string
description: 根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接
users_id_pic_a:
type: string
description: 证件正面附件的 `attachments_id`
users_id_pic_a_url:
type: string
description: 根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接
users_id_pic_b:
type: string
description: 证件反面附件的 `attachments_id`
users_id_pic_b_url:
type: string
description: 根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接
users_title_picture:
type: string
description: 资质附件的 `attachments_id`
users_title_picture_url:
type: string
description: 根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接
openid:
type: string
description: 全平台统一身份标识
@@ -338,7 +360,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f
users_picture:
type: string
example: https://example.com/avatar.png
description: 用户头像附件的 `attachments_id`
example: ATT-1743123456789-abc123
users_id_pic_a:
type: string
description: 可选。证件正面附件的 `attachments_id`
users_id_pic_b:
type: string
description: 可选。证件反面附件的 `attachments_id`
users_title_picture:
type: string
description: 可选。资质附件的 `attachments_id`
SystemRefreshTokenRequest:
type: object
properties:

View File

@@ -120,6 +120,28 @@ components:
type: string
users_picture:
type: string
description: 用户头像附件的 `attachments_id`
users_picture_url:
type: string
description: 根据 `users_picture -> tbl_attachments` 自动解析出的头像文件流链接
users_id_pic_a:
type: string
description: 证件正面附件的 `attachments_id`
users_id_pic_a_url:
type: string
description: 根据 `users_id_pic_a -> tbl_attachments` 自动解析出的文件流链接
users_id_pic_b:
type: string
description: 证件反面附件的 `attachments_id`
users_id_pic_b_url:
type: string
description: 根据 `users_id_pic_b -> tbl_attachments` 自动解析出的文件流链接
users_title_picture:
type: string
description: 资质附件的 `attachments_id`
users_title_picture_url:
type: string
description: 根据 `users_title_picture -> tbl_attachments` 自动解析出的文件流链接
openid:
type: string
description: 全平台统一身份标识;微信用户为微信 openid平台用户为服务端生成的 GUID
@@ -180,6 +202,13 @@ components:
users_auth_type: 0
users_type: 注册用户
users_picture: ''
users_picture_url: ''
users_id_pic_a: ''
users_id_pic_a_url: ''
users_id_pic_b: ''
users_id_pic_b_url: ''
users_title_picture: ''
users_title_picture_url: ''
openid: app_momo
company_id: ''
users_parent_id: ''
@@ -211,7 +240,17 @@ components:
example: 2b7d9f2e3c4a5b6d7e8f
users_picture:
type: string
example: https://example.com/avatar.png
description: 用户头像附件的 `attachments_id`
example: ATT-1743123456789-abc123
users_id_pic_a:
type: string
description: 可选。证件正面附件的 `attachments_id`
users_id_pic_b:
type: string
description: 可选。证件反面附件的 `attachments_id`
users_title_picture:
type: string
description: 可选。资质附件的 `attachments_id`
WechatProfileResponseData:
type: object
properties:
@@ -239,9 +278,19 @@ components:
example: 12345678
users_picture:
type: string
example: https://example.com/avatar.png
description: 用户头像附件的 `attachments_id`
example: ATT-1743123456789-abc123
users_id_number:
type: string
users_id_pic_a:
type: string
description: 可选。证件正面附件的 `attachments_id`
users_id_pic_b:
type: string
description: 可选。证件反面附件的 `attachments_id`
users_title_picture:
type: string
description: 可选。资质附件的 `attachments_id`
users_level:
type: string
users_type:
@@ -480,52 +529,76 @@ components:
type: string
document_image:
type: string
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_image_ids:
type: array
description: `document_image` 解析后的附件 id 列表
description: "`document_image` 解析后的附件 id 列表"
items:
type: string
document_image_urls:
type: array
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表
description: "根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表"
items:
type: string
document_image_url:
type: string
description: 兼容字段,返回第一张图片的文件流链接
description: "兼容字段,返回第一张图片的文件流链接"
document_image_attachments:
type: array
items:
$ref: '#/components/schemas/AttachmentRecord'
document_image_attachment:
allOf:
anyOf:
- $ref: '#/components/schemas/AttachmentRecord'
nullable: true
- type: 'null'
document_video:
type: string
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_video_ids:
type: array
description: `document_video` 解析后的附件 id 列表
description: "`document_video` 解析后的附件 id 列表"
items:
type: string
document_video_urls:
type: array
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表
description: "根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表"
items:
type: string
document_video_url:
type: string
description: 兼容字段,返回第一个视频的文件流链接
description: "兼容字段,返回第一个视频的文件流链接"
document_video_attachments:
type: array
items:
$ref: '#/components/schemas/AttachmentRecord'
document_video_attachment:
allOf:
anyOf:
- $ref: '#/components/schemas/AttachmentRecord'
nullable: true
- type: 'null'
document_file:
type: string
description: "关联多个 `attachments_id`,底层使用 `|` 分隔保存"
document_file_ids:
type: array
description: "`document_file` 解析后的附件 id 列表"
items:
type: string
document_file_urls:
type: array
description: "根据 `document_file -> tbl_attachments` 自动解析出的文件流链接列表"
items:
type: string
document_file_url:
type: string
description: "兼容字段,返回第一个文件的文件流链接"
document_file_attachments:
type: array
items:
$ref: '#/components/schemas/AttachmentRecord'
document_file_attachment:
anyOf:
- $ref: '#/components/schemas/AttachmentRecord'
- type: 'null'
document_owner:
type: string
description: 上传者 openid
@@ -630,6 +703,14 @@ components:
items:
type: string
description: 视频附件 id 列表;支持数组或 `|` 分隔字符串
document_file:
oneOf:
- type: string
description: 多个文件附件 id 使用 `|` 分隔
- type: array
items:
type: string
description: 文件附件 id 列表;支持数组或 `|` 分隔字符串
document_relation_model:
type: string
document_keywords:
@@ -1237,7 +1318,7 @@ paths:
summary: 删除附件
description: |
仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 引用,则拒绝删除。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image``document_video` 或 `document_file` 引用,则拒绝删除。
requestBody:
required: true
content:
@@ -1278,8 +1359,8 @@ paths:
description: |
仅允许 `ManagePlatform` 用户访问。
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。
返回结果会自动根据 `document_image`、`document_video`、`document_file` 中的多个 `attachments_id` 关联 `tbl_attachments`
额外补充 `document_image_urls`、`document_video_urls`、`document_file_urls` 以及对应附件对象数组。
requestBody:
required: false
content:
@@ -1352,7 +1433,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。
`document_id` 可选;未传时服务端自动生成。
`document_title`、`document_type` 为必填;其余字段均允许为空。
`document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
`document_image`、`document_video`、`document_file` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
requestBody:
required: true
@@ -1390,7 +1471,7 @@ paths:
仅允许 `ManagePlatform` 用户访问。
按 `document_id` 定位现有文档并更新。
`document_title`、`document_type` 为必填;其余字段均允许为空。
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。
若传入 `document_image`、`document_video`、`document_file`,则支持多个 `attachments_id`,并会逐一校验是否存在。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
requestBody:
required: true