feat: 添加文档管理和登录页面功能,包含文档上传、列表展示及用户登录逻辑;新增数据库表结构初始化脚本

This commit is contained in:
2026-03-27 10:27:44 +08:00
parent 836e660842
commit 9feb0bb3a0
23 changed files with 2483 additions and 104 deletions

101
docs/pb_document_tables.md Normal file
View File

@@ -0,0 +1,101 @@
# PocketBase 文档相关表结构
本方案新增 3 张 PocketBase `base collection`,统一采用业务 id 字段进行关联,不直接依赖 PocketBase 自动生成的 `id` 作为业务主键。
补充约定:
- `document_image``document_video` 只保存关联的 `attachments_id`,不直接存文件。
- `document_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`
- `document_owner` 的业务含义为“上传者openid”。
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
---
## 1. `tbl_attachments` 附件表
**类型:** Base Collection
| 字段名 | 类型 | 备注 |
| :--- | :--- | :--- |
| attachments_id | text | 附件业务 id唯一标识符 |
| attachments_link | file | 附件本体,单文件,不限制文件类型,单文件上限 100MB |
| attachments_filename | text | 原始文件名 |
| attachments_filetype | text | 文件类型/MIME |
| attachments_size | number | 附件大小 |
| attachments_owner | text | 上传者业务 id |
| attachments_md5 | text | 附件 MD5 码 |
| attachments_ocr | text | OCR 识别结果 |
| attachments_status | text | 附件状态 |
| attachments_remark | text | 备注 |
**索引规划:**
- `attachments_id` 唯一索引
- `attachments_owner` 普通索引
- `attachments_status` 普通索引
---
## 2. `tbl_document` 文档表
**类型:** Base Collection
| 字段名 | 类型 | 备注 |
| :--- | :--- | :--- |
| document_id | text | 文档业务 id唯一标识符 |
| document_effect_date | date | 文档生效日期 |
| document_expiry_date | date | 文档到期日期 |
| document_type | text | 文档类型 |
| document_title | text | 文档标题 |
| document_subtitle | text | 文档副标题 |
| document_summary | text | 文档摘要 |
| document_content | text | 正文内容,保存 Markdown 原文 |
| document_image | text | 关联 `attachments_id` |
| document_video | text | 关联 `attachments_id` |
| document_owner | text | 上传者openid |
| document_relation_model | text | 关联机型/模型标识 |
| document_keywords | text | 关键词,竖线分隔 |
| document_share_count | number | 分享次数 |
| document_download_count | number | 下载次数 |
| document_favorite_count | number | 收藏次数 |
| document_status | text | 文档状态 |
| document_embedding_status | text | 文档嵌入状态 |
| document_embedding_error | text | 文档错误原因 |
| document_embedding_lasttime | date | 最后更新日期 |
| document_vector_version | text | 向量版本号或模型名称 |
| document_product_categories | text | 适用产品类别,竖线分隔 |
| document_application_scenarios | text | 适用场景,竖线分隔 |
| document_hotel_type | text | 适用酒店类型,竖线分隔 |
| document_remark | text | 备注 |
**索引规划:**
- `document_id` 唯一索引
- `document_owner` 普通索引
- `document_type` 普通索引
- `document_status` 普通索引
- `document_embedding_status` 普通索引
- `document_effect_date` 普通索引
- `document_expiry_date` 普通索引
---
## 3. `tbl_document_operation_history` 文档操作历史表
**类型:** Base Collection
| 字段名 | 类型 | 备注 |
| :--- | :--- | :--- |
| doh_id | text | 文档操作历史业务 id唯一标识符 |
| doh_document_id | text | 关联 `document_id` |
| doh_operation_type | text | 操作类型 |
| doh_user_id | text | 操作人业务 id |
| doh_current_count | number | 本次操作对应次数 |
| doh_remark | text | 备注 |
**索引规划:**
- `doh_id` 唯一索引
- `doh_document_id` 普通索引
- `doh_user_id` 普通索引
- `doh_operation_type` 普通索引

View File

@@ -28,5 +28,15 @@ require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/create.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/update.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/dictionary/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/detail.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/upload.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/attachment/delete.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/list.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/document/detail.js`)
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/wechat/login.js`)
require(`${__hooks}/bai_api_pb_hooks/bai_api_routes/wechat/profile.js`)

View File

@@ -1,4 +1,4 @@
require(`${__hooks}/bai_web_pb_hooks/pages/index.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/page-a.js`)
require(`${__hooks}/bai_web_pb_hooks/pages/page-b.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`)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
routerAdd('POST', '/api/attachment/upload', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
const authState = guards.requireManagePlatformUser(e)
const payload = guards.validateAttachmentUploadBody(e)
const files = e.findUploadedFiles('attachments_link') || []
const file = files.length ? files[0] : null
const data = documentService.uploadAttachment(authState.openid, payload, file)
return success(e, '上传附件成功', data)
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('上传附件失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '上传附件失败',
data: (err && err.data) || {},
})
}
})

View File

@@ -0,0 +1,30 @@
routerAdd('POST', '/api/document-history/list', function (e) {
const guards = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/middlewares/requestGuards.js`)
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { success } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/response.js`)
try {
guards.requireJson(e)
guards.requireManagePlatformUser(e)
const payload = guards.validateDocumentHistoryListBody(e)
const data = documentService.listDocumentHistories(payload)
return success(e, '查询文档操作历史成功', {
items: data,
})
} catch (err) {
const status = (err && err.statusCode) || (err && err.status) || 400
logger.error('查询文档操作历史失败', {
status: status,
message: (err && err.message) || '未知错误',
data: (err && err.data) || {},
})
return e.json(status, {
code: status,
msg: (err && err.message) || '查询文档操作历史失败',
data: (err && err.data) || {},
})
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,6 +167,112 @@ function validateDictionaryDeleteBody(e) {
}
}
function validateAttachmentListBody(e) {
const payload = parseBody(e)
return {
keyword: payload.keyword || '',
status: payload.status || '',
}
}
function validateAttachmentDetailBody(e) {
const payload = parseBody(e)
if (!payload.attachments_id) {
throw createAppError(400, 'attachments_id 为必填项')
}
return {
attachments_id: payload.attachments_id,
}
}
function validateAttachmentDeleteBody(e) {
return validateAttachmentDetailBody(e)
}
function validateAttachmentUploadBody(e) {
const payload = sanitizePayload(e.requestInfo().body || {})
return {
attachments_filename: payload.attachments_filename || '',
attachments_filetype: payload.attachments_filetype || '',
attachments_size: payload.attachments_size || 0,
attachments_md5: payload.attachments_md5 || '',
attachments_ocr: payload.attachments_ocr || '',
attachments_status: payload.attachments_status || 'active',
attachments_remark: payload.attachments_remark || '',
}
}
function validateDocumentListBody(e) {
const payload = parseBody(e)
return {
keyword: payload.keyword || '',
status: payload.status || '',
document_type: payload.document_type || '',
}
}
function validateDocumentDetailBody(e) {
const payload = parseBody(e)
if (!payload.document_id) {
throw createAppError(400, 'document_id 为必填项')
}
return {
document_id: payload.document_id,
}
}
function validateDocumentMutationBody(e, isUpdate) {
const payload = parseBody(e)
if (!payload.document_id && isUpdate) {
throw createAppError(400, 'document_id 为必填项')
}
return {
document_id: payload.document_id || '',
document_effect_date: payload.document_effect_date || '',
document_expiry_date: payload.document_expiry_date || '',
document_type: payload.document_type || '',
document_title: payload.document_title || '',
document_subtitle: payload.document_subtitle || '',
document_summary: payload.document_summary || '',
document_content: payload.document_content || '',
document_image: payload.document_image || '',
document_video: payload.document_video || '',
document_relation_model: payload.document_relation_model || '',
document_keywords: payload.document_keywords || '',
document_share_count: payload.document_share_count || 0,
document_download_count: payload.document_download_count || 0,
document_favorite_count: payload.document_favorite_count || 0,
document_status: payload.document_status || '',
document_embedding_status: payload.document_embedding_status || '',
document_embedding_error: payload.document_embedding_error || '',
document_embedding_lasttime: payload.document_embedding_lasttime || '',
document_vector_version: payload.document_vector_version || '',
document_product_categories: payload.document_product_categories || '',
document_application_scenarios: payload.document_application_scenarios || '',
document_hotel_type: payload.document_hotel_type || '',
document_remark: payload.document_remark || '',
}
}
function validateDocumentDeleteBody(e) {
return validateDocumentDetailBody(e)
}
function validateDocumentHistoryListBody(e) {
const payload = parseBody(e)
return {
document_id: payload.document_id || '',
}
}
function requireAuthOpenid(e) {
if (!e.auth) {
throw createAppError(401, '认证令牌无效或已过期')
@@ -236,6 +342,15 @@ module.exports = {
validateDictionaryDetailBody,
validateDictionaryMutationBody,
validateDictionaryDeleteBody,
validateAttachmentListBody,
validateAttachmentDetailBody,
validateAttachmentDeleteBody,
validateAttachmentUploadBody,
validateDocumentListBody,
validateDocumentDetailBody,
validateDocumentMutationBody,
validateDocumentDeleteBody,
validateDocumentHistoryListBody,
requireAuthOpenid,
requireAuthUser,
duplicateGuard,

View File

@@ -0,0 +1,473 @@
const env = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/config/env.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
function buildBusinessId(prefix) {
return prefix + '-' + new Date().getTime() + '-' + $security.randomString(6)
}
function normalizePublicBaseUrl() {
const base = String(env.appBaseUrl || '').replace(/\/+$/, '')
if (!base) return '/pb'
if (base.toLowerCase().endsWith('/pb')) return base
return base + '/pb'
}
function buildFileUrl(collectionName, recordId, filename, download) {
if (!filename) return ''
const base = normalizePublicBaseUrl()
const url = base + '/api/files/' + encodeURIComponent(collectionName) + '/' + encodeURIComponent(recordId) + '/' + encodeURIComponent(filename)
return download ? (url + '?download=1') : url
}
function normalizeDateValue(value) {
const text = String(value || '').replace(/^\s+|\s+$/g, '')
if (!text) return ''
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return text + ' 00:00:00.000Z'
}
if (/^\d{4}-\d{2}-\d{2}\s/.test(text) || text.indexOf('T') !== -1) {
return text
}
throw createAppError(400, '日期字段格式错误')
}
function normalizeNumberValue(value, fieldName) {
if (value === '' || value === null || typeof value === 'undefined') {
return 0
}
const num = Number(value)
if (!Number.isFinite(num)) {
throw createAppError(400, fieldName + ' 必须为数字')
}
return num
}
function exportAttachmentRecord(record) {
const storedFilename = record.getString('attachments_link')
return {
pb_id: record.id,
attachments_id: record.getString('attachments_id'),
attachments_link: storedFilename,
attachments_url: buildFileUrl('tbl_attachments', record.id, storedFilename, false),
attachments_download_url: buildFileUrl('tbl_attachments', record.id, storedFilename, true),
attachments_filename: record.getString('attachments_filename'),
attachments_filetype: record.getString('attachments_filetype'),
attachments_size: record.get('attachments_size'),
attachments_owner: record.getString('attachments_owner'),
attachments_md5: record.getString('attachments_md5'),
attachments_ocr: record.getString('attachments_ocr'),
attachments_status: record.getString('attachments_status'),
attachments_remark: record.getString('attachments_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
}
function findAttachmentRecordByAttachmentId(attachmentId) {
if (!attachmentId) return null
const records = $app.findRecordsByFilter('tbl_attachments', 'attachments_id = {:attachmentsId}', '', 1, 0, {
attachmentsId: attachmentId,
})
return records.length ? records[0] : null
}
function exportDocumentRecord(record) {
const imageAttachmentId = record.getString('document_image')
const videoAttachmentId = record.getString('document_video')
const imageAttachmentRecord = findAttachmentRecordByAttachmentId(imageAttachmentId)
const videoAttachmentRecord = findAttachmentRecordByAttachmentId(videoAttachmentId)
const imageAttachment = imageAttachmentRecord ? exportAttachmentRecord(imageAttachmentRecord) : null
const videoAttachment = videoAttachmentRecord ? exportAttachmentRecord(videoAttachmentRecord) : null
return {
pb_id: record.id,
document_id: record.getString('document_id'),
document_effect_date: String(record.get('document_effect_date') || ''),
document_expiry_date: String(record.get('document_expiry_date') || ''),
document_type: record.getString('document_type'),
document_title: record.getString('document_title'),
document_subtitle: record.getString('document_subtitle'),
document_summary: record.getString('document_summary'),
document_content: record.getString('document_content'),
document_image: imageAttachmentId,
document_image_url: imageAttachment ? imageAttachment.attachments_url : '',
document_image_attachment: imageAttachment,
document_video: videoAttachmentId,
document_video_url: videoAttachment ? videoAttachment.attachments_url : '',
document_video_attachment: videoAttachment,
document_owner: record.getString('document_owner'),
document_relation_model: record.getString('document_relation_model'),
document_keywords: record.getString('document_keywords'),
document_share_count: record.get('document_share_count'),
document_download_count: record.get('document_download_count'),
document_favorite_count: record.get('document_favorite_count'),
document_status: record.getString('document_status'),
document_embedding_status: record.getString('document_embedding_status'),
document_embedding_error: record.getString('document_embedding_error'),
document_embedding_lasttime: String(record.get('document_embedding_lasttime') || ''),
document_vector_version: record.getString('document_vector_version'),
document_product_categories: record.getString('document_product_categories'),
document_application_scenarios: record.getString('document_application_scenarios'),
document_hotel_type: record.getString('document_hotel_type'),
document_remark: record.getString('document_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
}
function findDocumentRecordByDocumentId(documentId) {
const records = $app.findRecordsByFilter('tbl_document', 'document_id = {:documentId}', '', 1, 0, {
documentId: documentId,
})
return records.length ? records[0] : null
}
function exportHistoryRecord(record) {
return {
pb_id: record.id,
doh_id: record.getString('doh_id'),
doh_document_id: record.getString('doh_document_id'),
doh_operation_type: record.getString('doh_operation_type'),
doh_user_id: record.getString('doh_user_id'),
doh_current_count: record.get('doh_current_count'),
doh_remark: record.getString('doh_remark'),
created: String(record.created || ''),
updated: String(record.updated || ''),
}
}
function createHistoryRecord(txApp, payload) {
const collection = txApp.findCollectionByNameOrId('tbl_document_operation_history')
const record = new Record(collection)
record.set('doh_id', buildBusinessId('DOH'))
record.set('doh_document_id', payload.documentId)
record.set('doh_operation_type', payload.operationType)
record.set('doh_user_id', payload.userOpenid || '')
record.set('doh_current_count', normalizeNumberValue(payload.currentCount, 'doh_current_count'))
record.set('doh_remark', payload.remark || '')
txApp.save(record)
}
function ensureAttachmentExists(attachmentId, fieldName) {
if (!attachmentId) return
const record = findAttachmentRecordByAttachmentId(attachmentId)
if (!record) {
throw createAppError(400, fieldName + ' 对应的附件不存在')
}
}
function listAttachments(payload) {
const allRecords = $app.findRecordsByFilter('tbl_attachments', '', '', 500, 0)
const keyword = String(payload.keyword || '').toLowerCase()
const status = String(payload.status || '')
const result = []
for (let i = 0; i < allRecords.length; i += 1) {
const item = exportAttachmentRecord(allRecords[i])
const matchedKeyword = !keyword
|| item.attachments_id.toLowerCase().indexOf(keyword) !== -1
|| item.attachments_filename.toLowerCase().indexOf(keyword) !== -1
const matchedStatus = !status || item.attachments_status === status
if (matchedKeyword && matchedStatus) {
result.push(item)
}
}
return result
}
function getAttachmentDetail(attachmentId) {
const record = findAttachmentRecordByAttachmentId(attachmentId)
if (!record) {
throw createAppError(404, '未找到对应附件')
}
return exportAttachmentRecord(record)
}
function uploadAttachment(userOpenid, payload, file) {
if (!file) {
throw createAppError(400, 'attachments_link 为必填文件')
}
const collection = $app.findCollectionByNameOrId('tbl_attachments')
const record = new Record(collection)
record.set('attachments_id', buildBusinessId('ATT'))
record.set('attachments_link', file)
record.set('attachments_filename', payload.attachments_filename || '')
record.set('attachments_filetype', payload.attachments_filetype || '')
record.set('attachments_size', normalizeNumberValue(payload.attachments_size, 'attachments_size'))
record.set('attachments_owner', userOpenid || '')
record.set('attachments_md5', payload.attachments_md5 || '')
record.set('attachments_ocr', payload.attachments_ocr || '')
record.set('attachments_status', payload.attachments_status || 'active')
record.set('attachments_remark', payload.attachments_remark || '')
try {
$app.save(record)
} catch (err) {
throw createAppError(400, '上传附件失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('附件上传成功', {
attachments_id: record.getString('attachments_id'),
attachments_owner: userOpenid || '',
})
return exportAttachmentRecord(record)
}
function deleteAttachment(attachmentId) {
const record = findAttachmentRecordByAttachmentId(attachmentId)
if (!record) {
throw createAppError(404, '未找到待删除的附件')
}
const quotedAttachmentId = '"' + attachmentId.replace(/"/g, '\\"') + '"'
const usedByDocument = $app.findRecordsByFilter('tbl_document', 'document_image = ' + quotedAttachmentId + ' || document_video = ' + quotedAttachmentId, '', 1, 0)
if (usedByDocument.length) {
throw createAppError(400, '附件已被文档引用,无法删除')
}
try {
$app.delete(record)
} catch (err) {
throw createAppError(400, '删除附件失败', {
originalMessage: (err && err.message) || '未知错误',
originalData: (err && err.data) || {},
})
}
logger.info('附件删除成功', {
attachments_id: attachmentId,
})
return {
attachments_id: attachmentId,
}
}
function listDocuments(payload) {
const allRecords = $app.findRecordsByFilter('tbl_document', '', '', 500, 0)
const keyword = String(payload.keyword || '').toLowerCase()
const status = String(payload.status || '')
const type = String(payload.document_type || '')
const result = []
for (let i = 0; i < allRecords.length; i += 1) {
const item = exportDocumentRecord(allRecords[i])
const matchedKeyword = !keyword
|| item.document_id.toLowerCase().indexOf(keyword) !== -1
|| item.document_title.toLowerCase().indexOf(keyword) !== -1
|| item.document_subtitle.toLowerCase().indexOf(keyword) !== -1
|| item.document_summary.toLowerCase().indexOf(keyword) !== -1
|| item.document_keywords.toLowerCase().indexOf(keyword) !== -1
const matchedStatus = !status || item.document_status === status
const matchedType = !type || item.document_type === type
if (matchedKeyword && matchedStatus && matchedType) {
result.push(item)
}
}
result.sort(function (a, b) {
return String(b.updated || '').localeCompare(String(a.updated || ''))
})
return result
}
function getDocumentDetail(documentId) {
const record = findDocumentRecordByDocumentId(documentId)
if (!record) {
throw createAppError(404, '未找到对应文档')
}
return exportDocumentRecord(record)
}
function createDocument(userOpenid, payload) {
ensureAttachmentExists(payload.document_image, 'document_image')
ensureAttachmentExists(payload.document_video, 'document_video')
const targetDocumentId = payload.document_id || buildBusinessId('DOC')
const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
if (duplicated) {
throw createAppError(400, 'document_id 已存在')
}
return $app.runInTransaction(function (txApp) {
const collection = txApp.findCollectionByNameOrId('tbl_document')
const record = new Record(collection)
record.set('document_id', targetDocumentId)
record.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
record.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
record.set('document_type', payload.document_type || '')
record.set('document_title', payload.document_title || '')
record.set('document_subtitle', payload.document_subtitle || '')
record.set('document_summary', payload.document_summary || '')
record.set('document_content', payload.document_content || '')
record.set('document_image', payload.document_image || '')
record.set('document_video', payload.document_video || '')
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', 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', payload.document_status || 'active')
record.set('document_embedding_status', payload.document_embedding_status || 'pending')
record.set('document_embedding_error', payload.document_embedding_error || '')
record.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
record.set('document_vector_version', payload.document_vector_version || '')
record.set('document_product_categories', payload.document_product_categories || '')
record.set('document_application_scenarios', payload.document_application_scenarios || '')
record.set('document_hotel_type', payload.document_hotel_type || '')
record.set('document_remark', payload.document_remark || '')
txApp.save(record)
createHistoryRecord(txApp, {
documentId: record.getString('document_id'),
operationType: 'create',
userOpenid: userOpenid,
currentCount: 1,
remark: '文档创建',
})
return exportDocumentRecord(record)
})
}
function updateDocument(userOpenid, payload) {
const record = findDocumentRecordByDocumentId(payload.document_id)
if (!record) {
throw createAppError(404, '未找到待修改的文档')
}
ensureAttachmentExists(payload.document_image, 'document_image')
ensureAttachmentExists(payload.document_video, 'document_video')
return $app.runInTransaction(function (txApp) {
const target = txApp.findRecordById('tbl_document', record.id)
target.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
target.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
target.set('document_type', payload.document_type || '')
target.set('document_title', payload.document_title || '')
target.set('document_subtitle', payload.document_subtitle || '')
target.set('document_summary', payload.document_summary || '')
target.set('document_content', payload.document_content || '')
target.set('document_image', payload.document_image || '')
target.set('document_video', payload.document_video || '')
target.set('document_relation_model', payload.document_relation_model || '')
target.set('document_keywords', payload.document_keywords || '')
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', payload.document_status || '')
target.set('document_embedding_status', payload.document_embedding_status || '')
target.set('document_embedding_error', payload.document_embedding_error || '')
target.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
target.set('document_vector_version', payload.document_vector_version || '')
target.set('document_product_categories', payload.document_product_categories || '')
target.set('document_application_scenarios', payload.document_application_scenarios || '')
target.set('document_hotel_type', payload.document_hotel_type || '')
target.set('document_remark', payload.document_remark || '')
txApp.save(target)
createHistoryRecord(txApp, {
documentId: target.getString('document_id'),
operationType: 'update',
userOpenid: userOpenid,
currentCount: 1,
remark: '文档更新',
})
return exportDocumentRecord(target)
})
}
function deleteDocument(userOpenid, documentId) {
const record = findDocumentRecordByDocumentId(documentId)
if (!record) {
throw createAppError(404, '未找到待删除的文档')
}
return $app.runInTransaction(function (txApp) {
createHistoryRecord(txApp, {
documentId: documentId,
operationType: 'delete',
userOpenid: userOpenid,
currentCount: 1,
remark: '文档删除',
})
const target = txApp.findRecordById('tbl_document', record.id)
txApp.delete(target)
logger.info('文档删除成功', {
document_id: documentId,
})
return {
document_id: documentId,
}
})
}
function listDocumentHistories(payload) {
const allRecords = payload.document_id
? $app.findRecordsByFilter('tbl_document_operation_history', 'doh_document_id = {:documentId}', '', 500, 0, {
documentId: payload.document_id,
})
: $app.findRecordsByFilter('tbl_document_operation_history', '', '', 500, 0)
const result = []
for (let i = 0; i < allRecords.length; i += 1) {
result.push(exportHistoryRecord(allRecords[i]))
}
result.sort(function (a, b) {
return String(b.created || '').localeCompare(String(a.created || ''))
})
return result
}
module.exports = {
listAttachments,
getAttachmentDetail,
uploadAttachment,
deleteAttachment,
listDocuments,
getDocumentDetail,
createDocument,
updateDocument,
deleteDocument,
listDocumentHistories,
}

View File

@@ -0,0 +1,429 @@
routerAdd('GET', '/manage/document-manage', function (e) {
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文档管理</title>
<script>
(function () {
var token = localStorage.getItem('pb_manage_token') || ''
var isLoggedIn = localStorage.getItem('pb_manage_logged_in') === '1'
if (!token || !isLoggedIn) {
window.location.replace('/pb/manage/login')
}
})()
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #eef6ff 0%, #f8fafc 100%); color: #1f2937; }
.container { max-width: 1280px; margin: 0 auto; padding: 32px 20px 64px; }
.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: 24px; padding: 24px; }
.panel + .panel { margin-top: 24px; }
h1, h2 { margin-top: 0; }
p { color: #4b5563; line-height: 1.7; }
.actions, .form-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; 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; }
.status { margin-top: 14px; min-height: 24px; font-size: 14px; }
.status.success { color: #15803d; }
.status.error { color: #b91c1c; }
.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; }
input, textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px 12px; font-size: 14px; background: #fff; }
textarea { min-height: 96px; resize: vertical; }
.hint { color: #64748b; font-size: 12px; margin-top: 6px; }
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; }
.empty { text-align: center; padding: 24px 16px; color: #64748b; }
.muted { color: #64748b; font-size: 12px; }
.doc-links a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; }
@media (max-width: 960px) {
.grid { grid-template-columns: 1fr; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
td { display: flex; justify-content: space-between; gap: 12px; }
td::before { content: attr(data-label); font-weight: 700; color: #475569; }
}
</style>
</head>
<body>
<div class="container">
<section class="panel">
<h1>文档管理</h1>
<p>页面会先把文件上传到 <code>tbl_attachments</code>,然后把返回的 <code>attachments_id</code> 写入 <code>tbl_document.document_image</code> 或 <code>document_video</code>。文档列表会直接显示 PocketBase 文件流链接。</p>
<div class="actions">
<a class="btn btn-light" href="/pb/manage">返回主页</a>
<a class="btn btn-light" href="/pb/manage/login">登录页</a>
<button class="btn btn-light" id="reloadBtn" type="button">刷新列表</button>
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
</div>
<div class="status" id="status"></div>
</section>
<section class="panel">
<h2>新增文档</h2>
<div class="grid">
<div>
<label for="documentTitle">文档标题</label>
<input id="documentTitle" placeholder="请输入文档标题" />
</div>
<div>
<label for="documentType">文档类型</label>
<input id="documentType" placeholder="例如:说明书、新闻、常见故障" />
</div>
<div>
<label for="documentStatus">文档状态</label>
<input id="documentStatus" placeholder="默认 active" value="active" />
</div>
<div>
<label for="embeddingStatus">嵌入状态</label>
<input id="embeddingStatus" placeholder="默认 pending" value="pending" />
</div>
<div>
<label for="effectDate">生效日期</label>
<input id="effectDate" type="date" />
</div>
<div>
<label for="expiryDate">到期日期</label>
<input id="expiryDate" type="date" />
</div>
<div class="full">
<label for="documentSubtitle">副标题</label>
<input id="documentSubtitle" placeholder="可选" />
</div>
<div class="full">
<label for="documentSummary">摘要</label>
<textarea id="documentSummary" placeholder="请输入文档摘要"></textarea>
</div>
<div class="full">
<label for="documentContent">正文内容</label>
<textarea id="documentContent" placeholder="支持 Markdown 原文"></textarea>
</div>
<div>
<label for="relationModel">关联机型</label>
<input id="relationModel" placeholder="可选" />
</div>
<div>
<label for="vectorVersion">向量版本</label>
<input id="vectorVersion" placeholder="可选" />
</div>
<div>
<label for="documentKeywords">关键词</label>
<input id="documentKeywords" placeholder="多个值用 | 分隔" />
<div class="hint">例如:安装|配置|排障</div>
</div>
<div>
<label for="productCategories">适用产品类别</label>
<input id="productCategories" placeholder="多个值用 | 分隔" />
</div>
<div>
<label for="applicationScenarios">适用场景</label>
<input id="applicationScenarios" placeholder="多个值用 | 分隔" />
</div>
<div>
<label for="hotelType">适用酒店类型</label>
<input id="hotelType" placeholder="多个值用 | 分隔" />
</div>
<div class="full">
<label for="documentRemark">备注</label>
<textarea id="documentRemark" placeholder="可选"></textarea>
</div>
<div>
<label for="imageFile">图片附件</label>
<input id="imageFile" type="file" />
</div>
<div>
<label for="videoFile">视频附件</label>
<input id="videoFile" type="file" />
</div>
</div>
<div class="form-actions" style="margin-top:16px;">
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
</div>
</section>
<section class="panel">
<h2>文档列表</h2>
<div style="overflow:auto;">
<table>
<thead>
<tr>
<th>document_id</th>
<th>标题</th>
<th>类型/状态</th>
<th>附件链接</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="6" class="empty">暂无数据,请先刷新列表。</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
const tokenKey = 'pb_manage_token'
const statusEl = document.getElementById('status')
const tableBody = document.getElementById('tableBody')
const fields = {
documentTitle: document.getElementById('documentTitle'),
documentType: document.getElementById('documentType'),
documentStatus: document.getElementById('documentStatus'),
embeddingStatus: document.getElementById('embeddingStatus'),
effectDate: document.getElementById('effectDate'),
expiryDate: document.getElementById('expiryDate'),
documentSubtitle: document.getElementById('documentSubtitle'),
documentSummary: document.getElementById('documentSummary'),
documentContent: document.getElementById('documentContent'),
relationModel: document.getElementById('relationModel'),
vectorVersion: document.getElementById('vectorVersion'),
documentKeywords: document.getElementById('documentKeywords'),
productCategories: document.getElementById('productCategories'),
applicationScenarios: document.getElementById('applicationScenarios'),
hotelType: document.getElementById('hotelType'),
documentRemark: document.getElementById('documentRemark'),
imageFile: document.getElementById('imageFile'),
videoFile: document.getElementById('videoFile'),
}
const state = { list: [] }
function setStatus(message, type) {
statusEl.textContent = message || ''
statusEl.className = 'status' + (type ? ' ' + type : '')
}
function getToken() {
return localStorage.getItem(tokenKey) || ''
}
async function requestJson(url, payload) {
const token = getToken()
if (!token) {
window.location.replace('/pb/manage/login')
throw new Error('登录状态已失效,请重新登录')
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify(payload || {}),
})
const data = await res.json()
if (!res.ok || !data || 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')
window.location.replace('/pb/manage/login')
}
throw new Error((data && data.msg) || '请求失败')
}
return data.data
}
async function uploadAttachment(file, label) {
const token = getToken()
const form = new FormData()
form.append('attachments_link', file)
form.append('attachments_filename', file.name || '')
form.append('attachments_filetype', file.type || '')
form.append('attachments_size', String(file.size || 0))
form.append('attachments_status', 'active')
form.append('attachments_remark', 'document-manage:' + label)
const res = await fetch('/api/attachment/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
},
body: form,
})
const data = await res.json()
if (!res.ok || !data || data.code >= 400) {
throw new Error((data && data.msg) || ('上传' + label + '失败'))
}
return data.data
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderLinks(item) {
const links = []
if (item.document_image_url) {
links.push('<a href="' + escapeHtml(item.document_image_url) + '" target="_blank" rel="noreferrer">图片流</a>')
}
if (item.document_video_url) {
links.push('<a href="' + escapeHtml(item.document_video_url) + '" target="_blank" rel="noreferrer">视频流</a>')
}
if (!links.length) {
return '<span class="muted">无</span>'
}
return '<div class="doc-links">' + links.join('') + '</div>'
}
function renderTable() {
if (!state.list.length) {
tableBody.innerHTML = '<tr><td colspan="6" class="empty">暂无文档数据。</td></tr>'
return
}
tableBody.innerHTML = state.list.map(function (item) {
return '<tr>'
+ '<td data-label="document_id"><div>' + escapeHtml(item.document_id) + '</div><div class="muted">owner: ' + escapeHtml(item.document_owner) + '</div></td>'
+ '<td data-label="标题"><div><strong>' + escapeHtml(item.document_title) + '</strong></div><div class="muted">' + escapeHtml(item.document_subtitle) + '</div></td>'
+ '<td data-label="类型/状态"><div>' + escapeHtml(item.document_type) + '</div><div class="muted">' + escapeHtml(item.document_status) + ' / ' + escapeHtml(item.document_embedding_status) + '</div></td>'
+ '<td data-label="附件链接">' + renderLinks(item) + '</td>'
+ '<td data-label="更新时间"><span class="muted">' + escapeHtml(item.updated) + '</span></td>'
+ '<td data-label="操作"><button class="btn btn-danger" type="button" onclick="window.__deleteDocument(\\'' + encodeURIComponent(item.document_id) + '\\')">删除</button></td>'
+ '</tr>'
}).join('')
}
async function loadDocuments() {
setStatus('正在加载文档列表...', '')
try {
const data = await requestJson('/api/document/list', {})
state.list = data.items || []
renderTable()
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
} catch (err) {
setStatus(err.message || '加载列表失败', 'error')
}
}
function resetForm() {
fields.documentTitle.value = ''
fields.documentType.value = ''
fields.documentStatus.value = 'active'
fields.embeddingStatus.value = 'pending'
fields.effectDate.value = ''
fields.expiryDate.value = ''
fields.documentSubtitle.value = ''
fields.documentSummary.value = ''
fields.documentContent.value = ''
fields.relationModel.value = ''
fields.vectorVersion.value = ''
fields.documentKeywords.value = ''
fields.productCategories.value = ''
fields.applicationScenarios.value = ''
fields.hotelType.value = ''
fields.documentRemark.value = ''
fields.imageFile.value = ''
fields.videoFile.value = ''
}
async function submitDocument() {
if (!fields.documentTitle.value.trim()) {
setStatus('请先填写文档标题。', 'error')
return
}
setStatus('正在上传附件并创建文档...', '')
try {
let imageAttachment = null
let videoAttachment = null
const imageFile = fields.imageFile.files && fields.imageFile.files[0]
const videoFile = fields.videoFile.files && fields.videoFile.files[0]
if (imageFile) {
imageAttachment = await uploadAttachment(imageFile, 'image')
}
if (videoFile) {
videoAttachment = await uploadAttachment(videoFile, 'video')
}
await requestJson('/api/document/create', {
document_title: fields.documentTitle.value.trim(),
document_type: fields.documentType.value.trim(),
document_status: fields.documentStatus.value.trim(),
document_embedding_status: fields.embeddingStatus.value.trim(),
document_effect_date: fields.effectDate.value,
document_expiry_date: fields.expiryDate.value,
document_subtitle: fields.documentSubtitle.value.trim(),
document_summary: fields.documentSummary.value.trim(),
document_content: fields.documentContent.value.trim(),
document_image: imageAttachment ? imageAttachment.attachments_id : '',
document_video: videoAttachment ? videoAttachment.attachments_id : '',
document_relation_model: fields.relationModel.value.trim(),
document_keywords: fields.documentKeywords.value.trim(),
document_vector_version: fields.vectorVersion.value.trim(),
document_product_categories: fields.productCategories.value.trim(),
document_application_scenarios: fields.applicationScenarios.value.trim(),
document_hotel_type: fields.hotelType.value.trim(),
document_remark: fields.documentRemark.value.trim(),
})
resetForm()
await loadDocuments()
setStatus('文档创建成功。', 'success')
} catch (err) {
setStatus(err.message || '创建文档失败', 'error')
}
}
async function deleteDocument(documentId) {
const target = decodeURIComponent(documentId)
if (!window.confirm('确认删除文档「' + target + '」吗?')) {
return
}
setStatus('正在删除文档...', '')
try {
await requestJson('/api/document/delete', { document_id: target })
await loadDocuments()
setStatus('文档删除成功。', 'success')
} catch (err) {
setStatus(err.message || '删除文档失败', 'error')
}
}
window.__deleteDocument = deleteDocument
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
document.getElementById('submitBtn').addEventListener('click', submitDocument)
document.getElementById('resetBtn').addEventListener('click', function () {
resetForm()
setStatus('表单已重置。', 'success')
})
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')
})
loadDocuments()
</script>
</body>
</html>`
return e.html(200, html)
})

View File

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

View File

@@ -155,4 +155,3 @@ function renderLoginPage(e) {
}
routerAdd('GET', '/manage/login', renderLoginPage)
routerAdd('GET', '/manage/page-a', renderLoginPage)

View File

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

View File

@@ -6,7 +6,7 @@
## 范围
本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。
本次变更覆盖 `pocket-base/` 下 PocketBase hooks 项目的微信登录、平台注册/登录、资料更新、token 刷新、认证落库、错误可观测性、索引策略、字典管理、附件管理、文档管理、文档操作历史、页面辅助操作、健康检查版本探针、OpenAPI 鉴权与统一响应规范。
---
@@ -144,6 +144,16 @@
- `POST /api/dictionary/create`
- `POST /api/dictionary/update`
- `POST /api/dictionary/delete`
- `POST /api/attachment/list`
- `POST /api/attachment/detail`
- `POST /api/attachment/upload`
- `POST /api/attachment/delete`
- `POST /api/document/list`
- `POST /api/document/detail`
- `POST /api/document/create`
- `POST /api/document/update`
- `POST /api/document/delete`
- `POST /api/document-history/list`
其中平台用户链路补充为:
@@ -209,16 +219,100 @@
- `dict_word_enum``dict_word_description``dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]`
### 附件管理接口
新增 `attachment` 分类接口,统一要求平台管理用户访问:
- `POST /api/attachment/list`
- 支持按 `attachments_id``attachments_filename` 模糊搜索
- 支持按 `attachments_status` 过滤
- 返回附件元数据以及 PocketBase 文件流链接 `attachments_url`
- `POST /api/attachment/detail`
-`attachments_id` 查询单个附件
- 返回文件流链接与下载链接
- `POST /api/attachment/upload`
- 使用 `multipart/form-data`
- 文件字段固定为 `attachments_link`
- 上传成功后自动生成 `attachments_id`
- 自动写入 `attachments_owner = 当前用户 openid`
- `POST /api/attachment/delete`
-`attachments_id` 真删除附件
- 若该附件已被 `tbl_document.document_image``document_video` 引用,则拒绝删除
说明:
- `tbl_attachments.attachments_link` 为 PocketBase `file` 字段,保存实际文件本体。
- 对外查询时会额外补充:
- `attachments_url`
- `attachments_download_url`
### 文档管理接口
新增 `document` 分类接口,统一要求平台管理用户访问:
- `POST /api/document/list`
- 支持按 `document_id``document_title``document_subtitle``document_summary``document_keywords` 模糊搜索
- 支持按 `document_status``document_type` 过滤
- 返回时会自动联查 `tbl_attachments`
- 额外补充:
- `document_image_url`
- `document_video_url`
- `document_image_attachment`
- `document_video_attachment`
- `POST /api/document/detail`
-`document_id` 查询单条文档
- 返回与附件表联动解析后的文件流链接
- `POST /api/document/create`
- 新增文档
- `document_id` 可不传,由服务端自动生成
- `document_image``document_video` 必须传入已存在的 `attachments_id`
- 成功后会写入一条文档操作历史,类型为 `create`
- `POST /api/document/update`
-`document_id` 更新文档
- 若传入附件字段,则会校验对应 `attachments_id` 是否存在
- 成功后会写入一条文档操作历史,类型为 `update`
- `POST /api/document/delete`
-`document_id` 真删除文档
- 删除前会写入一条文档操作历史,类型为 `delete`
说明:
- `document_image``document_video` 当前保存的是 `attachments_id`,不是 PocketBase 文件字段。
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接。
- `document_owner` 语义为“上传者 openid”。
### 文档操作历史接口
新增 `document-history` 分类接口,统一要求平台管理用户访问:
- `POST /api/document-history/list`
- 不传 `document_id` 时返回全部文档历史
- 传入 `document_id` 时仅返回该文档历史
- 结果按创建时间倒序排列
说明:
- 操作历史表为 `tbl_document_operation_history`
- 当前由文档新增、修改、删除接口自动写入
- 主要字段为:
- `doh_document_id`
- `doh_operation_type`
- `doh_user_id`
- `doh_current_count`
- `doh_remark`
---
## 七、页面与运维辅助能力新增
### 1. PocketBase 页面
新增页面
当前页面入口
- `/web`
- `/web/dictionary-manage`
- `/pb/manage`
- `/pb/manage/login`
- `/pb/manage/dictionary-manage`
- `/pb/manage/document-manage`
页面能力:
@@ -231,6 +325,22 @@
- 弹窗编辑枚举项
- 新增 / 删除字典
- 返回主页
- 文档管理页支持:
- 先上传附件到 `tbl_attachments`
- 再把返回的 `attachments_id` 写入 `tbl_document.document_image` / `document_video`
- 新增文档
- 查询文档列表
- 直接展示 PocketBase 文件流链接
- 删除文档
说明:
- 原页面 `page-b.js` 已替换为 `document-manage.js`
- 页面实际走的接口链路为:
- `/api/attachment/upload`
- `/api/document/create`
- `/api/document/list`
- `/api/document/delete`
### 2. 健康检查版本探针

View File

@@ -9,7 +9,7 @@ info:
请在 Apifox 环境中统一设置全局 Header`Authorization: Bearer {{token}}`。
version: 1.0.0
servers:
- url: https://bai-api.blv-oa.com/pb
- url: https://bai-api.blv-oa.com
description: 生产环境
- url: http://localhost:8090
description: PocketBase 本地环境
@@ -22,6 +22,12 @@ tags:
description: 面向平台用户的认证接口;平台用户会生成 GUID 并写入统一 `openid` 字段。
- name: 字典管理
description: 面向 ManagePlatform 用户的系统字典维护接口。
- name: 附件管理
description: 面向 ManagePlatform 用户的附件上传、查询与删除接口。
- name: 文档管理
description: 面向 ManagePlatform 用户的文档新增、查询、修改、删除接口;查询时会自动返回关联附件的 PocketBase 文件流链接。
- name: 文档历史
description: 面向 ManagePlatform 用户的文档操作历史查询接口。
components:
schemas:
ApiResponse:
@@ -358,8 +364,286 @@ components:
dict_name:
type: string
example: 用户状态
AttachmentRecord:
type: object
properties:
pb_id:
type: string
attachments_id:
type: string
attachments_link:
type: string
description: PocketBase 实际存储的文件名
attachments_url:
type: string
description: 附件文件流访问链接
attachments_download_url:
type: string
description: 附件下载链接
attachments_filename:
type: string
attachments_filetype:
type: string
attachments_size:
type: number
attachments_owner:
type: string
attachments_md5:
type: string
attachments_ocr:
type: string
attachments_status:
type: string
attachments_remark:
type: string
created:
type: string
updated:
type: string
AttachmentListRequest:
type: object
properties:
keyword:
type: string
description: 对 `attachments_id`、`attachments_filename` 的模糊搜索关键字
example: 手册
status:
type: string
description: 按附件状态过滤
example: active
AttachmentDetailRequest:
type: object
required: [attachments_id]
properties:
attachments_id:
type: string
example: ATT-1743037200000-abc123
AttachmentUploadRequest:
type: object
required: [attachments_link]
properties:
attachments_link:
type: string
format: binary
description: 要上传到 `tbl_attachments` 的单个文件
attachments_filename:
type: string
description: 原始文件名;不传时可由前端直接使用文件名
attachments_filetype:
type: string
description: 文件 MIME 类型
attachments_size:
type: number
description: 文件大小
attachments_md5:
type: string
attachments_ocr:
type: string
attachments_status:
type: string
example: active
attachments_remark:
type: string
DocumentRecord:
type: object
properties:
pb_id:
type: string
document_id:
type: string
document_effect_date:
type: string
document_expiry_date:
type: string
document_type:
type: string
document_title:
type: string
document_subtitle:
type: string
document_summary:
type: string
document_content:
type: string
document_image:
type: string
description: 关联的 `attachments_id`
document_image_url:
type: string
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接
document_image_attachment:
allOf:
- $ref: '#/components/schemas/AttachmentRecord'
nullable: true
document_video:
type: string
description: 关联的 `attachments_id`
document_video_url:
type: string
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接
document_video_attachment:
allOf:
- $ref: '#/components/schemas/AttachmentRecord'
nullable: true
document_owner:
type: string
description: 上传者 openid
document_relation_model:
type: string
document_keywords:
type: string
description: 多值字段,使用 `|` 分隔
document_share_count:
type: number
document_download_count:
type: number
document_favorite_count:
type: number
document_status:
type: string
document_embedding_status:
type: string
document_embedding_error:
type: string
document_embedding_lasttime:
type: string
document_vector_version:
type: string
document_product_categories:
type: string
description: 多值字段,使用 `|` 分隔
document_application_scenarios:
type: string
description: 多值字段,使用 `|` 分隔
document_hotel_type:
type: string
description: 多值字段,使用 `|` 分隔
document_remark:
type: string
created:
type: string
updated:
type: string
DocumentListRequest:
type: object
properties:
keyword:
type: string
description: 对 `document_id`、`document_title`、`document_subtitle`、`document_summary`、`document_keywords` 的模糊搜索关键字
example: 安装
status:
type: string
example: active
document_type:
type: string
example: 说明书
DocumentDetailRequest:
type: object
required: [document_id]
properties:
document_id:
type: string
example: DOC-1743037200000-abc123
DocumentMutationRequest:
type: object
properties:
document_id:
type: string
description: 创建时可不传,由服务端自动生成;更新时必填
example: DOC-1743037200000-abc123
document_effect_date:
type: string
description: 支持 `YYYY-MM-DD` 或 PocketBase 可识别日期时间字符串
example: 2026-03-27
document_expiry_date:
type: string
description: 支持 `YYYY-MM-DD` 或 PocketBase 可识别日期时间字符串
example: 2027-03-27
document_type:
type: string
document_title:
type: string
document_subtitle:
type: string
document_summary:
type: string
document_content:
type: string
document_image:
type: string
description: 图片附件的 `attachments_id`
document_video:
type: string
description: 视频附件的 `attachments_id`
document_relation_model:
type: string
document_keywords:
type: string
description: 多值字段,使用 `|` 分隔
document_share_count:
type: number
document_download_count:
type: number
document_favorite_count:
type: number
document_status:
type: string
document_embedding_status:
type: string
document_embedding_error:
type: string
document_embedding_lasttime:
type: string
document_vector_version:
type: string
document_product_categories:
type: string
description: 多值字段,使用 `|` 分隔
document_application_scenarios:
type: string
description: 多值字段,使用 `|` 分隔
document_hotel_type:
type: string
description: 多值字段,使用 `|` 分隔
document_remark:
type: string
DocumentDeleteRequest:
type: object
required: [document_id]
properties:
document_id:
type: string
example: DOC-1743037200000-abc123
DocumentHistoryRecord:
type: object
properties:
pb_id:
type: string
doh_id:
type: string
doh_document_id:
type: string
doh_operation_type:
type: string
doh_user_id:
type: string
doh_current_count:
type: number
doh_remark:
type: string
created:
type: string
updated:
type: string
DocumentHistoryListRequest:
type: object
properties:
document_id:
type: string
description: 可选;传入时仅查询指定文档的操作历史
example: DOC-1743037200000-abc123
paths:
/api/system/test-helloworld:
/pb/api/system/test-helloworld:
post:
tags: [系统]
summary: HelloWorld 测试接口
@@ -375,7 +659,7 @@ paths:
properties:
data:
$ref: '#/components/schemas/HelloWorldData'
/api/system/health:
/pb/api/system/health:
post:
tags: [系统]
summary: 健康检查
@@ -391,7 +675,7 @@ paths:
properties:
data:
$ref: '#/components/schemas/HealthData'
/api/system/users-count:
/pb/api/system/users-count:
post:
tags: [系统]
summary: 查询用户总数
@@ -408,7 +692,7 @@ paths:
properties:
data:
$ref: '#/components/schemas/UsersCountData'
/api/system/refresh-token:
/pb/api/system/refresh-token:
post:
tags: [系统]
summary: 刷新系统认证 token
@@ -451,7 +735,7 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/wechat/login:
/pb/api/wechat/login:
post:
tags: [微信认证]
summary: 微信登录/注册合一
@@ -483,7 +767,7 @@ paths:
description: 重复请求过于频繁
'500':
description: 保存 auth 用户失败或服务端内部错误
/api/platform/register:
/pb/api/platform/register:
post:
tags: [平台认证]
summary: 平台用户注册
@@ -513,7 +797,7 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/platform/login:
/pb/api/platform/login:
post:
tags: [平台认证]
summary: 平台用户登录
@@ -575,7 +859,7 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/wechat/profile:
/pb/api/wechat/profile:
post:
tags: [微信认证]
summary: 更新微信用户资料
@@ -604,7 +888,7 @@ paths:
description: token 无效或当前 auth record 缺少统一身份字段 openid
'400':
description: 参数错误、手机号已被注册或资料更新失败
/api/dictionary/list:
/pb/api/dictionary/list:
post:
tags: [字典管理]
summary: 查询字典列表
@@ -640,7 +924,7 @@ paths:
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
/api/dictionary/detail:
/pb/api/dictionary/detail:
post:
tags: [字典管理]
summary: 查询指定字典
@@ -675,7 +959,7 @@ paths:
description: 未找到对应字典
'415':
description: 请求体必须为 application/json
/api/dictionary/create:
/pb/api/dictionary/create:
post:
tags: [字典管理]
summary: 新增字典
@@ -711,7 +995,7 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/dictionary/update:
/pb/api/dictionary/update:
post:
tags: [字典管理]
summary: 修改字典
@@ -748,7 +1032,7 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/api/dictionary/delete:
/pb/api/dictionary/delete:
post:
tags: [字典管理]
summary: 删除字典
@@ -788,3 +1072,370 @@ paths:
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/pb/api/attachment/list:
post:
tags: [附件管理]
summary: 查询附件列表
description: |
仅允许 `ManagePlatform` 用户访问。
支持按 `attachments_id`、`attachments_filename` 模糊搜索,并可按 `attachments_status` 过滤。
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/AttachmentListRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/AttachmentRecord'
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
/pb/api/attachment/detail:
post:
tags: [附件管理]
summary: 查询附件详情
description: |
仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 查询单条附件,并返回 PocketBase 文件流链接与下载链接。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AttachmentDetailRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/AttachmentRecord'
'400':
description: 参数错误
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到对应附件
'415':
description: 请求体必须为 application/json
/pb/api/attachment/upload:
post:
tags: [附件管理]
summary: 上传附件
description: |
仅允许 `ManagePlatform` 用户访问。
使用 `multipart/form-data` 上传单个文件到 `tbl_attachments`,服务端会自动生成 `attachments_id`
并返回可直接访问的 PocketBase 文件流链接。
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/AttachmentUploadRequest'
responses:
'200':
description: 上传成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/AttachmentRecord'
'400':
description: 参数错误、缺少文件或附件保存失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
/pb/api/attachment/delete:
post:
tags: [附件管理]
summary: 删除附件
description: |
仅允许 `ManagePlatform` 用户访问。
按 `attachments_id` 真删除附件;若附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AttachmentDetailRequest'
responses:
'200':
description: 删除成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
attachments_id:
type: string
'400':
description: 参数错误、附件已被文档引用或删除失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到待删除附件
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/pb/api/document/list:
post:
tags: [文档管理]
summary: 查询文档列表
description: |
仅允许 `ManagePlatform` 用户访问。
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
返回结果会自动根据 `document_image`、`document_video` 关联 `tbl_attachments`
额外补充 `document_image_url`、`document_video_url` 以及对应附件对象。
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentListRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/DocumentRecord'
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
/pb/api/document/detail:
post:
tags: [文档管理]
summary: 查询文档详情
description: |
仅允许 `ManagePlatform` 用户访问。
按 `document_id` 查询单条文档,并返回与附件表联动解析后的文件流链接。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentDetailRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DocumentRecord'
'400':
description: 参数错误
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到对应文档
'415':
description: 请求体必须为 application/json
/pb/api/document/create:
post:
tags: [文档管理]
summary: 新增文档
description: |
仅允许 `ManagePlatform` 用户访问。
`document_id` 可选;未传时服务端自动生成。
`document_image`、`document_video` 需传入已存在于 `tbl_attachments` 的 `attachments_id`。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentMutationRequest'
responses:
'200':
description: 新增成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DocumentRecord'
'400':
description: 参数错误、附件不存在或文档创建失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/pb/api/document/update:
post:
tags: [文档管理]
summary: 修改文档
description: |
仅允许 `ManagePlatform` 用户访问。
按 `document_id` 定位现有文档并更新;若传入 `document_image`、`document_video`,则必须是已存在的 `attachments_id`。
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentMutationRequest'
responses:
'200':
description: 修改成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
$ref: '#/components/schemas/DocumentRecord'
'400':
description: 参数错误、附件不存在或修改失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到待修改文档
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/pb/api/document/delete:
post:
tags: [文档管理]
summary: 删除文档
description: |
仅允许 `ManagePlatform` 用户访问。
按 `document_id` 真删除文档,并在删除前同步写入一条 `tbl_document_operation_history`,操作类型为 `delete`。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentDeleteRequest'
responses:
'200':
description: 删除成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
document_id:
type: string
'400':
description: 参数错误或删除失败
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'404':
description: 未找到待删除文档
'415':
description: 请求体必须为 application/json
'429':
description: 重复请求过于频繁
/pb/api/document-history/list:
post:
tags: [文档历史]
summary: 查询文档操作历史
description: |
仅允许 `ManagePlatform` 用户访问。
若 body 传入 `document_id`,则仅查询该文档的历史;否则返回全部文档操作历史,按创建时间倒序排列。
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentHistoryListRequest'
responses:
'200':
description: 查询成功
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/DocumentHistoryRecord'
'401':
description: token 无效或已过期
'403':
description: 非 ManagePlatform 用户无权访问
'415':
description: 请求体必须为 application/json

View File

@@ -5,7 +5,8 @@
"main": "pocketbase.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"init:newpb": "node pocketbase.newpb.js"
"init:newpb": "node pocketbase.newpb.js",
"init:documents": "node pocketbase.documents.js"
},
"keywords": [],
"author": "",

View File

@@ -0,0 +1,272 @@
import { createRequire } from 'module';
import PocketBase from 'pocketbase';
const require = createRequire(import.meta.url);
let runtimeConfig = {};
try {
runtimeConfig = require('../pocket-base/bai_api_pb_hooks/bai_api_shared/config/runtime.js');
} catch (_error) {
runtimeConfig = {};
}
const PB_URL = (process.env.PB_URL || 'https://bai-api.blv-oa.com/pb').replace(/\/+$/, '');
const AUTH_TOKEN = process.env.POCKETBASE_AUTH_TOKEN || runtimeConfig.POCKETBASE_AUTH_TOKEN || '';
if (!AUTH_TOKEN) {
console.error('❌ 缺少 POCKETBASE_AUTH_TOKEN无法执行建表。');
process.exit(1);
}
const pb = new PocketBase(PB_URL);
const collections = [
{
name: 'tbl_attachments',
type: 'base',
fields: [
{ name: 'attachments_id', type: 'text', required: true },
{ name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 104857600, mimeTypes: [] },
{ name: 'attachments_filename', type: 'text' },
{ name: 'attachments_filetype', type: 'text' },
{ name: 'attachments_size', type: 'number' },
{ name: 'attachments_owner', type: 'text' },
{ name: 'attachments_md5', type: 'text' },
{ name: 'attachments_ocr', type: 'text' },
{ name: 'attachments_status', type: 'text' },
{ name: 'attachments_remark', type: 'text' },
],
indexes: [
'CREATE UNIQUE INDEX idx_tbl_attachments_attachments_id ON tbl_attachments (attachments_id)',
'CREATE INDEX idx_tbl_attachments_attachments_owner ON tbl_attachments (attachments_owner)',
'CREATE INDEX idx_tbl_attachments_attachments_status ON tbl_attachments (attachments_status)',
],
},
{
name: 'tbl_document',
type: 'base',
fields: [
{ name: 'document_id', type: 'text', required: true },
{ name: 'document_effect_date', type: 'date' },
{ name: 'document_expiry_date', type: 'date' },
{ name: 'document_type', type: 'text' },
{ name: 'document_title', type: 'text' },
{ name: 'document_subtitle', type: 'text' },
{ name: 'document_summary', type: 'text' },
{ name: 'document_content', type: 'text' },
{ name: 'document_image', type: 'text' },
{ name: 'document_video', type: 'text' },
{ name: 'document_owner', type: 'text' },
{ name: 'document_relation_model', type: 'text' },
{ name: 'document_keywords', type: 'text' },
{ name: 'document_share_count', type: 'number' },
{ name: 'document_download_count', type: 'number' },
{ name: 'document_favorite_count', type: 'number' },
{ name: 'document_status', type: 'text' },
{ name: 'document_embedding_status', type: 'text' },
{ name: 'document_embedding_error', type: 'text' },
{ name: 'document_embedding_lasttime', type: 'date' },
{ name: 'document_vector_version', type: 'text' },
{ name: 'document_product_categories', type: 'text' },
{ name: 'document_application_scenarios', type: 'text' },
{ name: 'document_hotel_type', type: 'text' },
{ name: 'document_remark', type: 'text' },
],
indexes: [
'CREATE UNIQUE INDEX idx_tbl_document_document_id ON tbl_document (document_id)',
'CREATE INDEX idx_tbl_document_document_owner ON tbl_document (document_owner)',
'CREATE INDEX idx_tbl_document_document_type ON tbl_document (document_type)',
'CREATE INDEX idx_tbl_document_document_status ON tbl_document (document_status)',
'CREATE INDEX idx_tbl_document_document_embedding_status ON tbl_document (document_embedding_status)',
'CREATE INDEX idx_tbl_document_document_effect_date ON tbl_document (document_effect_date)',
'CREATE INDEX idx_tbl_document_document_expiry_date ON tbl_document (document_expiry_date)',
],
},
{
name: 'tbl_document_operation_history',
type: 'base',
fields: [
{ name: 'doh_id', type: 'text', required: true },
{ name: 'doh_document_id', type: 'text', required: true },
{ name: 'doh_operation_type', type: 'text' },
{ name: 'doh_user_id', type: 'text' },
{ name: 'doh_current_count', type: 'number' },
{ name: 'doh_remark', type: 'text' },
],
indexes: [
'CREATE UNIQUE INDEX idx_tbl_document_operation_history_doh_id ON tbl_document_operation_history (doh_id)',
'CREATE INDEX idx_tbl_document_operation_history_doh_document_id ON tbl_document_operation_history (doh_document_id)',
'CREATE INDEX idx_tbl_document_operation_history_doh_user_id ON tbl_document_operation_history (doh_user_id)',
'CREATE INDEX idx_tbl_document_operation_history_doh_operation_type ON tbl_document_operation_history (doh_operation_type)',
],
},
];
function normalizeFieldPayload(field, existingField) {
const payload = existingField
? Object.assign({}, existingField)
: {
name: field.name,
type: field.type,
};
if (existingField && existingField.id) {
payload.id = existingField.id;
}
payload.name = field.name;
payload.type = field.type;
if (typeof field.required !== 'undefined') {
payload.required = field.required;
}
if (field.type === 'file') {
payload.maxSelect = typeof field.maxSelect === 'number' ? field.maxSelect : 0;
payload.maxSize = typeof field.maxSize === 'number' ? field.maxSize : 0;
payload.mimeTypes = Array.isArray(field.mimeTypes) && field.mimeTypes.length ? field.mimeTypes : null;
}
return payload;
}
function buildCollectionPayload(collectionData, existingCollection) {
if (!existingCollection) {
return {
name: collectionData.name,
type: collectionData.type,
fields: collectionData.fields.map((field) => normalizeFieldPayload(field, null)),
indexes: collectionData.indexes,
};
}
const targetFieldMap = new Map(collectionData.fields.map((field) => [field.name, field]));
const fields = (existingCollection.fields || []).map((existingField) => {
const targetField = targetFieldMap.get(existingField.name);
if (!targetField) {
return existingField;
}
targetFieldMap.delete(existingField.name);
return normalizeFieldPayload(targetField, existingField);
});
for (const field of targetFieldMap.values()) {
fields.push(normalizeFieldPayload(field, null));
}
return {
name: collectionData.name,
type: collectionData.type,
fields: fields,
indexes: collectionData.indexes,
};
}
function normalizeFieldList(fields) {
return (fields || []).map((field) => ({
name: field.name,
type: field.type,
}));
}
async function createOrUpdateCollection(collectionData) {
console.log(`🔄 正在处理表: ${collectionData.name} ...`);
try {
const existing = await pb.collections.getOne(collectionData.name);
await pb.collections.update(existing.id, buildCollectionPayload(collectionData, existing));
console.log(`♻️ ${collectionData.name} 已存在,已按最新结构更新。`);
} catch (error) {
if (error.status === 404) {
try {
await pb.collections.create(buildCollectionPayload(collectionData, null));
console.log(`${collectionData.name} 创建完成。`);
return;
} catch (createError) {
console.error(`❌ 创建集合 ${collectionData.name} 失败:`, {
status: createError.status,
message: createError.message,
response: createError.response?.data,
});
throw createError;
}
}
console.error(`❌ 处理集合 ${collectionData.name} 失败:`, {
status: error.status,
message: error.message,
response: error.response?.data,
});
throw error;
}
}
async function verifyCollections(targetCollections) {
console.log('\n🔍 开始校验文档相关表结构与索引...');
for (const target of targetCollections) {
const remote = await pb.collections.getOne(target.name);
const remoteFields = normalizeFieldList(remote.fields);
const targetFields = normalizeFieldList(target.fields);
const remoteFieldMap = new Map(remoteFields.map((field) => [field.name, field.type]));
const missingFields = [];
const mismatchedTypes = [];
for (const field of targetFields) {
if (!remoteFieldMap.has(field.name)) {
missingFields.push(field.name);
continue;
}
if (remoteFieldMap.get(field.name) !== field.type) {
mismatchedTypes.push(`${field.name}:${remoteFieldMap.get(field.name)}!=${field.type}`);
}
}
const remoteIndexes = new Set(remote.indexes || []);
const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql));
if (remote.type !== target.type) {
throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`);
}
if (!missingFields.length && !mismatchedTypes.length && !missingIndexes.length) {
console.log(`${target.name} 校验通过。`);
continue;
}
console.log(`${target.name} 校验失败:`);
if (missingFields.length) {
console.log(` - 缺失字段: ${missingFields.join(', ')}`);
}
if (mismatchedTypes.length) {
console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`);
}
if (missingIndexes.length) {
console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`);
}
throw new Error(`${target.name} 结构与预期不一致`);
}
}
async function init() {
try {
console.log(`🔄 正在连接 PocketBase: ${PB_URL}`);
pb.authStore.save(AUTH_TOKEN, null);
console.log('✅ 已使用 POCKETBASE_AUTH_TOKEN 载入认证状态。');
for (const collectionData of collections) {
await createOrUpdateCollection(collectionData);
}
await verifyCollections(collections);
console.log('\n🎉 文档相关表结构初始化并校验完成!');
} catch (error) {
console.error('❌ 初始化失败:', error.response?.data || error.message);
process.exitCode = 1;
}
}
init();