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:
@@ -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 权限
|
||||
|
||||
## 与原项目关系
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -25,4 +25,4 @@ routerAdd('POST', '/api/attachment/upload', function (e) {
|
||||
data: (err && err.data) || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
}, $apis.bodyLimit(536870912))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || '') }))
|
||||
|
||||
@@ -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, ''')
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
717
pocket-base/bai_web_pb_hooks/pages/sdk-permission-manage.js
Normal file
717
pocket-base/bai_web_pb_hooks/pages/sdk-permission-manage.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -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`
|
||||
- 图片、视频、文件都支持多选上传
|
||||
- 新增文档
|
||||
- 编辑已有文档并回显多图片、多视频
|
||||
- 从文档中移除附件并在保存后删除对应附件记录
|
||||
|
||||
@@ -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
|
||||
|
||||
449
pocket-base/spec/openapi-miniapp-company.yaml
Normal file
449
pocket-base/spec/openapi-miniapp-company.yaml
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user