feat: 添加微信端 API 文档和字典表初始化脚本
- 新增 openapi-wx.yaml 文件,定义微信端接口文档,包括用户统计、token 刷新、微信登录和用户资料更新等接口。 - 新增 pocketbase.dictionary.js 脚本,用于初始化和校验字典表结构,确保与预期一致。
This commit is contained in:
@@ -320,6 +320,21 @@ VUE_APP_VERSION=1.0.0
|
|||||||
4. 前端生产环境接口地址统一使用:`https://bai-api.blv-oa.com/api`
|
4. 前端生产环境接口地址统一使用:`https://bai-api.blv-oa.com/api`
|
||||||
5. 后端对外公开地址统一使用 `APP_BASE_URL=https://bai-api.blv-oa.com`
|
5. 后端对外公开地址统一使用 `APP_BASE_URL=https://bai-api.blv-oa.com`
|
||||||
|
|
||||||
|
### 大文件上传反向代理配置
|
||||||
|
|
||||||
|
如果 PocketBase 管理页或 hooks 页面需要上传较大的图片/视频附件,Nginx 需要同步放开请求体大小限制,否则浏览器会直接收到 `413 Content Too Large`,前端通常会看到 HTML 错页而不是 JSON。
|
||||||
|
|
||||||
|
建议在站点或对应 `location` 中增加:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 200m;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
```
|
||||||
|
|
||||||
|
如果当前 `tbl_attachments.attachments_link` 已放宽到约 4GB,反向代理层仍然需要按你的目标上传体积同步放开,否则即使 PocketBase 集合字段允许更大文件,上传仍会在进入 PocketBase 之前被拦截。
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
### Pocketbase设置
|
### Pocketbase设置
|
||||||
@@ -450,4 +465,4 @@ docker logs -f 容器名称
|
|||||||
|
|
||||||
## 总结
|
## 总结
|
||||||
|
|
||||||
本部署方案采用Docker容器化部署,实现了前后端分离的架构,支持一键部署和自动化管理。通过宝塔面板的反向代理功能,实现了域名访问和SSL证书配置,为系统提供了安全、稳定的运行环境。
|
本部署方案采用Docker容器化部署,实现了前后端分离的架构,支持一键部署和自动化管理。通过宝塔面板的反向代理功能,实现了域名访问和SSL证书配置,为系统提供了安全、稳定的运行环境。
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
补充约定:
|
补充约定:
|
||||||
|
|
||||||
- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。
|
- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。
|
||||||
|
- `document_type` 使用多选字符串持久化,但格式特殊:`system_dict_id@dict_word_enum|system_dict_id@dict_word_enum`;前端显示时用枚举值描述,存库时保留该组合值。
|
||||||
- `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。
|
- `document_keywords`、`document_product_categories`、`document_application_scenarios`、`document_hotel_type` 统一使用竖线分隔字符串,例如:`分类A|分类B|分类C`。
|
||||||
|
- `document_status` 仅允许 `有效`、`过期` 两种值,并由系统根据生效日期与到期日期自动计算;当两者都为空时默认 `有效`。
|
||||||
- `document_owner` 的业务含义为“上传者openid”。
|
- `document_owner` 的业务含义为“上传者openid”。
|
||||||
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
|
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
|
||||||
- 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。
|
- 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。
|
||||||
@@ -19,7 +21,7 @@
|
|||||||
| 字段名 | 类型 | 备注 |
|
| 字段名 | 类型 | 备注 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| attachments_id | text | 附件业务 id,唯一标识符 |
|
| attachments_id | text | 附件业务 id,唯一标识符 |
|
||||||
| attachments_link | file | 附件本体,单文件,不限制文件类型,单文件上限 100MB |
|
| attachments_link | file | 附件本体,单文件,不限制文件类型,数据库字段上限已放宽到约 4GB |
|
||||||
| attachments_filename | text | 原始文件名 |
|
| attachments_filename | text | 原始文件名 |
|
||||||
| attachments_filetype | text | 文件类型/MIME |
|
| attachments_filetype | text | 文件类型/MIME |
|
||||||
| attachments_size | number | 附件大小 |
|
| attachments_size | number | 附件大小 |
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
| document_id | text | 文档业务 id,唯一标识符 |
|
| document_id | text | 文档业务 id,唯一标识符 |
|
||||||
| document_effect_date | date | 文档生效日期 |
|
| document_effect_date | date | 文档生效日期 |
|
||||||
| document_expiry_date | date | 文档到期日期 |
|
| document_expiry_date | date | 文档到期日期 |
|
||||||
| document_type | text | 文档类型,必填 |
|
| document_type | text | 文档类型,必填;多选时按 `system_dict_id@dict_word_enum|...` 保存 |
|
||||||
| document_title | text | 文档标题,必填 |
|
| document_title | text | 文档标题,必填 |
|
||||||
| document_subtitle | text | 文档副标题 |
|
| document_subtitle | text | 文档副标题 |
|
||||||
| document_summary | text | 文档摘要 |
|
| document_summary | text | 文档摘要 |
|
||||||
@@ -55,18 +57,18 @@
|
|||||||
| document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
| document_video | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
||||||
| document_owner | text | 上传者openid |
|
| document_owner | text | 上传者openid |
|
||||||
| document_relation_model | text | 关联机型/模型标识 |
|
| document_relation_model | text | 关联机型/模型标识 |
|
||||||
| document_keywords | text | 关键词,竖线分隔 |
|
| document_keywords | text | 关键词,多选后用 `|` 分隔保存 |
|
||||||
| document_share_count | number | 分享次数 |
|
| document_share_count | number | 分享次数 |
|
||||||
| document_download_count | number | 下载次数 |
|
| document_download_count | number | 下载次数 |
|
||||||
| document_favorite_count | number | 收藏次数 |
|
| document_favorite_count | number | 收藏次数 |
|
||||||
| document_status | text | 文档状态 |
|
| document_status | text | 文档状态,仅允许 `有效` 或 `过期`,由系统依据生效日期和到期日期自动更新 |
|
||||||
| document_embedding_status | text | 文档嵌入状态 |
|
| document_embedding_status | text | 文档嵌入状态 |
|
||||||
| document_embedding_error | text | 文档错误原因 |
|
| document_embedding_error | text | 文档错误原因 |
|
||||||
| document_embedding_lasttime | date | 最后更新日期 |
|
| document_embedding_lasttime | date | 最后更新日期 |
|
||||||
| document_vector_version | text | 向量版本号或模型名称 |
|
| document_vector_version | text | 向量版本号或模型名称 |
|
||||||
| document_product_categories | text | 适用产品类别,竖线分隔 |
|
| document_product_categories | text | 产品关联文档,多选后从 `文档-产品关联文档` 字典保存为 `|` 分隔字符串 |
|
||||||
| document_application_scenarios | text | 适用场景,竖线分隔 |
|
| document_application_scenarios | text | 筛选依据,多选后从 `文档-筛选依据` 字典保存为 `|` 分隔字符串 |
|
||||||
| document_hotel_type | text | 适用酒店类型,竖线分隔 |
|
| document_hotel_type | text | 适用场景,多选后从 `文档-适用场景` 字典保存为 `|` 分隔字符串 |
|
||||||
| document_remark | text | 备注 |
|
| document_remark | text | 备注 |
|
||||||
|
|
||||||
**索引规划:**
|
**索引规划:**
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function normalizeDictionaryItem(item, index) {
|
|||||||
enum: String(current.enum),
|
enum: String(current.enum),
|
||||||
description: String(current.description),
|
description: String(current.description),
|
||||||
sortOrder: sortOrderNumber,
|
sortOrder: sortOrderNumber,
|
||||||
|
image: current.image ? String(current.image) : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
|
||||||
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
|
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
|
||||||
|
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
|
||||||
|
|
||||||
function buildSystemDictId() {
|
function buildSystemDictId() {
|
||||||
return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6)
|
return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6)
|
||||||
@@ -18,18 +19,31 @@ function safeJsonParse(text, fallback) {
|
|||||||
function normalizeItemsFromRecord(record) {
|
function normalizeItemsFromRecord(record) {
|
||||||
const enums = safeJsonParse(record.getString('dict_word_enum'), [])
|
const enums = safeJsonParse(record.getString('dict_word_enum'), [])
|
||||||
const descriptions = safeJsonParse(record.getString('dict_word_description'), [])
|
const descriptions = safeJsonParse(record.getString('dict_word_description'), [])
|
||||||
|
const images = safeJsonParse(record.getString('dict_word_image'), [])
|
||||||
const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), [])
|
const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), [])
|
||||||
const maxLength = Math.max(enums.length, descriptions.length, sortOrders.length)
|
const maxLength = Math.max(enums.length, descriptions.length, images.length, sortOrders.length)
|
||||||
const items = []
|
const items = []
|
||||||
|
|
||||||
for (let i = 0; i < maxLength; i += 1) {
|
for (let i = 0; i < maxLength; i += 1) {
|
||||||
if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof sortOrders[i] === 'undefined') {
|
if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof images[i] === 'undefined' && typeof sortOrders[i] === 'undefined') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageAttachmentId = typeof images[i] === 'undefined' ? '' : String(images[i] || '')
|
||||||
|
let imageAttachment = null
|
||||||
|
if (imageAttachmentId) {
|
||||||
|
try {
|
||||||
|
imageAttachment = documentService.getAttachmentDetail(imageAttachmentId)
|
||||||
|
} catch (_error) {
|
||||||
|
imageAttachment = null
|
||||||
|
}
|
||||||
|
}
|
||||||
items.push({
|
items.push({
|
||||||
enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]),
|
enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]),
|
||||||
description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]),
|
description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]),
|
||||||
|
image: imageAttachmentId,
|
||||||
|
imageUrl: imageAttachment ? imageAttachment.attachments_url : '',
|
||||||
|
imageAttachment: imageAttachment,
|
||||||
sortOrder: Number(sortOrders[i] || 0),
|
sortOrder: Number(sortOrders[i] || 0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -69,16 +83,19 @@ function ensureDictionaryNameUnique(dictName, excludeId) {
|
|||||||
function fillDictionaryItems(record, items) {
|
function fillDictionaryItems(record, items) {
|
||||||
const enums = []
|
const enums = []
|
||||||
const descriptions = []
|
const descriptions = []
|
||||||
|
const images = []
|
||||||
const sortOrders = []
|
const sortOrders = []
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
enums.push(items[i].enum)
|
enums.push(items[i].enum)
|
||||||
descriptions.push(items[i].description)
|
descriptions.push(items[i].description)
|
||||||
|
images.push(items[i].image || '')
|
||||||
sortOrders.push(items[i].sortOrder)
|
sortOrders.push(items[i].sortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
record.set('dict_word_enum', JSON.stringify(enums))
|
record.set('dict_word_enum', JSON.stringify(enums))
|
||||||
record.set('dict_word_description', JSON.stringify(descriptions))
|
record.set('dict_word_description', JSON.stringify(descriptions))
|
||||||
|
record.set('dict_word_image', JSON.stringify(images))
|
||||||
record.set('dict_word_sort_order', JSON.stringify(sortOrders))
|
record.set('dict_word_sort_order', JSON.stringify(sortOrders))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,4 +214,4 @@ module.exports = {
|
|||||||
createDictionary,
|
createDictionary,
|
||||||
updateDictionary,
|
updateDictionary,
|
||||||
deleteDictionary,
|
deleteDictionary,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,54 @@ function normalizeDateValue(value) {
|
|||||||
throw createAppError(400, '日期字段格式错误')
|
throw createAppError(400, '日期字段格式错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDateOnly(value) {
|
||||||
|
const text = String(value || '').replace(/^\s+|\s+$/g, '')
|
||||||
|
const match = text.match(/^\d{4}-\d{2}-\d{2}/)
|
||||||
|
return match ? match[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayDateString() {
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return year + '-' + month + '-' + day
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDocumentStatus(effectDate, expiryDate, fallbackStatus) {
|
||||||
|
const start = extractDateOnly(effectDate)
|
||||||
|
const end = extractDateOnly(expiryDate)
|
||||||
|
const today = getTodayDateString()
|
||||||
|
|
||||||
|
if (!start && !end) {
|
||||||
|
return fallbackStatus === '过期' ? '过期' : '有效'
|
||||||
|
}
|
||||||
|
if (start && today < start) {
|
||||||
|
return '过期'
|
||||||
|
}
|
||||||
|
if (end && today > end) {
|
||||||
|
return '过期'
|
||||||
|
}
|
||||||
|
return '有效'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDocumentStatus(record) {
|
||||||
|
const nextStatus = calculateDocumentStatus(
|
||||||
|
record.get('document_effect_date'),
|
||||||
|
record.get('document_expiry_date'),
|
||||||
|
record.getString('document_status')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (record.getString('document_status') !== nextStatus) {
|
||||||
|
record.set('document_status', nextStatus)
|
||||||
|
try {
|
||||||
|
$app.save(record)
|
||||||
|
} catch (_err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextStatus
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNumberValue(value, fieldName) {
|
function normalizeNumberValue(value, fieldName) {
|
||||||
if (value === '' || value === null || typeof value === 'undefined') {
|
if (value === '' || value === null || typeof value === 'undefined') {
|
||||||
return 0
|
return 0
|
||||||
@@ -147,6 +195,7 @@ function resolveAttachmentList(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportDocumentRecord(record) {
|
function exportDocumentRecord(record) {
|
||||||
|
const documentStatus = ensureDocumentStatus(record)
|
||||||
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
|
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
|
||||||
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
|
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
|
||||||
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
|
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
|
||||||
@@ -180,7 +229,7 @@ function exportDocumentRecord(record) {
|
|||||||
document_share_count: record.get('document_share_count'),
|
document_share_count: record.get('document_share_count'),
|
||||||
document_download_count: record.get('document_download_count'),
|
document_download_count: record.get('document_download_count'),
|
||||||
document_favorite_count: record.get('document_favorite_count'),
|
document_favorite_count: record.get('document_favorite_count'),
|
||||||
document_status: record.getString('document_status'),
|
document_status: documentStatus,
|
||||||
document_embedding_status: record.getString('document_embedding_status'),
|
document_embedding_status: record.getString('document_embedding_status'),
|
||||||
document_embedding_error: record.getString('document_embedding_error'),
|
document_embedding_error: record.getString('document_embedding_error'),
|
||||||
document_embedding_lasttime: String(record.get('document_embedding_lasttime') || ''),
|
document_embedding_lasttime: String(record.get('document_embedding_lasttime') || ''),
|
||||||
@@ -394,10 +443,13 @@ function createDocument(userOpenid, payload) {
|
|||||||
return $app.runInTransaction(function (txApp) {
|
return $app.runInTransaction(function (txApp) {
|
||||||
const collection = txApp.findCollectionByNameOrId('tbl_document')
|
const collection = txApp.findCollectionByNameOrId('tbl_document')
|
||||||
const record = new Record(collection)
|
const record = new Record(collection)
|
||||||
|
const effectDateValue = normalizeDateValue(payload.document_effect_date)
|
||||||
|
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
|
||||||
|
const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status)
|
||||||
|
|
||||||
record.set('document_id', targetDocumentId)
|
record.set('document_id', targetDocumentId)
|
||||||
record.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
|
record.set('document_effect_date', effectDateValue)
|
||||||
record.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
|
record.set('document_expiry_date', expiryDateValue)
|
||||||
record.set('document_type', payload.document_type || '')
|
record.set('document_type', payload.document_type || '')
|
||||||
record.set('document_title', payload.document_title || '')
|
record.set('document_title', payload.document_title || '')
|
||||||
record.set('document_subtitle', payload.document_subtitle || '')
|
record.set('document_subtitle', payload.document_subtitle || '')
|
||||||
@@ -411,7 +463,7 @@ function createDocument(userOpenid, payload) {
|
|||||||
record.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
|
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_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_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||||
record.set('document_status', payload.document_status || '')
|
record.set('document_status', documentStatus)
|
||||||
record.set('document_embedding_status', payload.document_embedding_status || '')
|
record.set('document_embedding_status', payload.document_embedding_status || '')
|
||||||
record.set('document_embedding_error', payload.document_embedding_error || '')
|
record.set('document_embedding_error', payload.document_embedding_error || '')
|
||||||
record.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
|
record.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
|
||||||
@@ -446,9 +498,12 @@ function updateDocument(userOpenid, payload) {
|
|||||||
|
|
||||||
return $app.runInTransaction(function (txApp) {
|
return $app.runInTransaction(function (txApp) {
|
||||||
const target = txApp.findRecordById('tbl_document', record.id)
|
const target = txApp.findRecordById('tbl_document', record.id)
|
||||||
|
const effectDateValue = normalizeDateValue(payload.document_effect_date)
|
||||||
|
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
|
||||||
|
const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status)
|
||||||
|
|
||||||
target.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
|
target.set('document_effect_date', effectDateValue)
|
||||||
target.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
|
target.set('document_expiry_date', expiryDateValue)
|
||||||
target.set('document_type', payload.document_type || '')
|
target.set('document_type', payload.document_type || '')
|
||||||
target.set('document_title', payload.document_title || '')
|
target.set('document_title', payload.document_title || '')
|
||||||
target.set('document_subtitle', payload.document_subtitle || '')
|
target.set('document_subtitle', payload.document_subtitle || '')
|
||||||
@@ -461,7 +516,7 @@ function updateDocument(userOpenid, payload) {
|
|||||||
target.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
|
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_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_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
|
||||||
target.set('document_status', payload.document_status || '')
|
target.set('document_status', documentStatus)
|
||||||
target.set('document_embedding_status', payload.document_embedding_status || '')
|
target.set('document_embedding_status', payload.document_embedding_status || '')
|
||||||
target.set('document_embedding_error', payload.document_embedding_error || '')
|
target.set('document_embedding_error', payload.document_embedding_error || '')
|
||||||
target.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
|
target.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ function buildUserId() {
|
|||||||
return 'U' + date + suffix
|
return 'U' + date + suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildUserConversId() {
|
||||||
|
return 'UC-' + new Date().getTime() + '-' + $security.randomString(6)
|
||||||
|
}
|
||||||
|
|
||||||
function buildGuid() {
|
function buildGuid() {
|
||||||
return $security.randomString(8) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(12)
|
return $security.randomString(8) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(12)
|
||||||
}
|
}
|
||||||
@@ -186,7 +190,17 @@ function ensureUserId(record) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureUsersConversId(record) {
|
||||||
|
if (!record.getString('users_convers_id')) {
|
||||||
|
record.set('users_convers_id', buildUserConversId())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function saveAuthUserRecord(record) {
|
function saveAuthUserRecord(record) {
|
||||||
|
ensureUserId(record)
|
||||||
|
ensureUsersConversId(record)
|
||||||
|
ensureAuthIdentity(record)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$app.save(record)
|
$app.save(record)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -227,11 +241,9 @@ function authenticateWechatUser(payload) {
|
|||||||
const collection = $app.findCollectionByNameOrId('tbl_auth_users')
|
const collection = $app.findCollectionByNameOrId('tbl_auth_users')
|
||||||
const record = new Record(collection)
|
const record = new Record(collection)
|
||||||
record.set('openid', openid)
|
record.set('openid', openid)
|
||||||
ensureUserId(record)
|
|
||||||
record.set('users_idtype', WECHAT_ID_TYPE)
|
record.set('users_idtype', WECHAT_ID_TYPE)
|
||||||
record.set('users_type', GUEST_USER_TYPE)
|
record.set('users_type', GUEST_USER_TYPE)
|
||||||
record.set('users_auth_type', 0)
|
record.set('users_auth_type', 0)
|
||||||
ensureAuthIdentity(record)
|
|
||||||
saveAuthUserRecord(record)
|
saveAuthUserRecord(record)
|
||||||
|
|
||||||
const user = enrichUser(record)
|
const user = enrichUser(record)
|
||||||
@@ -278,7 +290,6 @@ function registerPlatformUser(payload) {
|
|||||||
const record = new Record(collection)
|
const record = new Record(collection)
|
||||||
|
|
||||||
record.set('openid', platformOpenid)
|
record.set('openid', platformOpenid)
|
||||||
ensureUserId(record)
|
|
||||||
record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE)
|
record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE)
|
||||||
record.set('users_name', payload.users_name)
|
record.set('users_name', payload.users_name)
|
||||||
record.set('users_phone', payload.users_phone)
|
record.set('users_phone', payload.users_phone)
|
||||||
|
|||||||
@@ -17,18 +17,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
|
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
|
||||||
.container { max-width: 1240px; margin: 0 auto; padding: 32px 20px 60px; }
|
.container { max-width: 1440px; margin: 0 auto; padding: 24px 14px 40px; }
|
||||||
.topbar, .panel, .modal-card { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
|
.topbar, .panel, .modal-card { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
|
||||||
.topbar { border-radius: 24px; padding: 24px; margin-bottom: 24px; }
|
.topbar { border-radius: 20px; padding: 18px; margin-bottom: 14px; }
|
||||||
.topbar h1 { margin: 0 0 8px; font-size: 30px; }
|
.topbar h1 { margin: 0 0 8px; font-size: 30px; }
|
||||||
.topbar p { margin: 0; color: #4b5563; line-height: 1.7; }
|
.topbar p { margin: 0; color: #4b5563; line-height: 1.7; }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; }
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
||||||
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; }
|
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; }
|
||||||
.btn-primary { background: #2563eb; color: #fff; }
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
.btn-secondary { background: #e0e7ff; color: #1e3a8a; }
|
.btn-secondary { background: #e0e7ff; color: #1e3a8a; }
|
||||||
.btn-danger { background: #dc2626; color: #fff; }
|
.btn-danger { background: #dc2626; color: #fff; }
|
||||||
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
|
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
|
||||||
.panel { border-radius: 24px; padding: 24px; }
|
.panel { border-radius: 20px; padding: 18px; }
|
||||||
.toolbar { display: grid; grid-template-columns: 1.3fr 1fr 1fr auto auto; gap: 12px; margin-bottom: 18px; }
|
.toolbar { display: grid; grid-template-columns: 1.3fr 1fr 1fr auto auto; gap: 12px; margin-bottom: 18px; }
|
||||||
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px 12px; font-size: 14px; background: #fff; }
|
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px 12px; font-size: 14px; background: #fff; }
|
||||||
textarea { min-height: 88px; resize: vertical; }
|
textarea { min-height: 88px; resize: vertical; }
|
||||||
@@ -47,9 +47,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
.status.success { color: #15803d; }
|
.status.success { color: #15803d; }
|
||||||
.status.error { color: #b91c1c; }
|
.status.error { color: #b91c1c; }
|
||||||
.auth-box { display: grid; grid-template-columns: 1fr auto; gap: 12px; margin-top: 18px; }
|
.auth-box { display: grid; grid-template-columns: 1fr auto; gap: 12px; margin-top: 18px; }
|
||||||
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.4); padding: 20px; }
|
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.4); padding: 14px; }
|
||||||
.modal.show { display: flex; }
|
.modal.show { display: flex; }
|
||||||
.modal-card { width: min(920px, 100%); border-radius: 24px; padding: 24px; max-height: 90vh; overflow: auto; }
|
.modal-card { width: min(1104px, 100%); border-radius: 20px; padding: 18px; max-height: 90vh; overflow: auto; }
|
||||||
.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||||
.modal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
.modal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
.full { grid-column: 1 / -1; }
|
.full { grid-column: 1 / -1; }
|
||||||
@@ -57,6 +57,16 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
.item-table table { border-radius: 0; }
|
.item-table table { border-radius: 0; }
|
||||||
.modal-actions { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
.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; }
|
.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-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; }
|
||||||
|
.enum-preview-more { margin-top: 8px; display: none; }
|
||||||
|
.enum-preview-toggle { margin-top: 6px; }
|
||||||
|
.image-upload-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; margin-top: 10px; }
|
||||||
|
.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; }
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
|
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
|
||||||
table, thead, tbody, th, td, tr { display: block; }
|
table, thead, tbody, th, td, tr { display: block; }
|
||||||
@@ -71,10 +81,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="topbar">
|
<section class="topbar">
|
||||||
<h1>字典管理</h1>
|
<h1>字典管理</h1>
|
||||||
<p>页面仅允许 ManagePlatform 用户操作。当前请求会自动使用登录页保存到 localStorage 的 token。</p>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-light" href="/pb/manage">返回主页</a>
|
<a class="btn btn-light" href="/pb/manage">返回主页</a>
|
||||||
<a class="btn btn-light" href="/pb/manage/login">登录页</a>
|
|
||||||
<button class="btn btn-primary" id="createBtn" type="button">新增字典</button>
|
<button class="btn btn-primary" id="createBtn" type="button">新增字典</button>
|
||||||
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +122,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="modalTitle" style="margin:0;">新增字典</h2>
|
<h2 id="modalTitle" style="margin:0;">新增字典</h2>
|
||||||
<div class="muted">三个聚合字段将以 JSON 字符串形式保存,并在读取时自动还原为 items。</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-light" id="closeModalBtn" type="button">关闭</button>
|
<button class="btn btn-light" id="closeModalBtn" type="button">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,8 +147,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>枚举值</th>
|
|
||||||
<th>描述</th>
|
<th>描述</th>
|
||||||
|
<th>图片</th>
|
||||||
<th>排序</th>
|
<th>排序</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -165,11 +172,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<script>
|
<script>
|
||||||
const API_BASE = '/pb/api'
|
const API_BASE = '/pb/api'
|
||||||
const tokenKey = 'pb_manage_token'
|
const tokenKey = 'pb_manage_token'
|
||||||
|
const enumChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
const state = {
|
const state = {
|
||||||
list: [],
|
list: [],
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
editingOriginalName: '',
|
editingOriginalName: '',
|
||||||
items: [],
|
items: [],
|
||||||
|
enumSeed: '',
|
||||||
|
enumCounter: 1,
|
||||||
|
expandedPreviewKey: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusEl = document.getElementById('status')
|
const statusEl = document.getElementById('status')
|
||||||
@@ -224,6 +235,38 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadAttachment(file, label) {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
localStorage.removeItem('pb_manage_logged_in')
|
||||||
|
window.location.replace('/pb/manage/login')
|
||||||
|
throw new Error('登录状态已失效,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
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', 'dictionary-manage:' + label)
|
||||||
|
|
||||||
|
const res = await fetch(API_BASE + '/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) || '上传图片失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -233,11 +276,86 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
.replace(/'/g, ''')
|
.replace(/'/g, ''')
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItemsPreview(items) {
|
function randomEnumSeed() {
|
||||||
|
const first = enumChars[Math.floor(Math.random() * enumChars.length)]
|
||||||
|
const second = enumChars[Math.floor(Math.random() * enumChars.length)]
|
||||||
|
return first + second
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEnumSeed() {
|
||||||
|
if (!state.enumSeed) {
|
||||||
|
state.enumSeed = randomEnumSeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAutoEnum(usedSet) {
|
||||||
|
ensureEnumSeed()
|
||||||
|
let guard = 0
|
||||||
|
while (guard < 10000) {
|
||||||
|
const candidate = state.enumSeed + String(state.enumCounter)
|
||||||
|
state.enumCounter += 1
|
||||||
|
if (!usedSet.has(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
guard += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
state.enumSeed = randomEnumSeed()
|
||||||
|
state.enumCounter = 1
|
||||||
|
return nextAutoEnum(usedSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemEnums() {
|
||||||
|
const used = new Set()
|
||||||
|
state.items = state.items.map(function (item) {
|
||||||
|
const current = {
|
||||||
|
enum: String((item && item.enum) || '').trim(),
|
||||||
|
description: String((item && item.description) || ''),
|
||||||
|
image: (item && item.image) || '',
|
||||||
|
imageUrl: (item && item.imageUrl) || '',
|
||||||
|
imageAttachment: (item && item.imageAttachment) || null,
|
||||||
|
sortOrder: Number((item && item.sortOrder) || 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.enum || used.has(current.enum)) {
|
||||||
|
current.enum = nextAutoEnum(used)
|
||||||
|
}
|
||||||
|
|
||||||
|
used.add(current.enum)
|
||||||
|
if (!Number.isFinite(current.sortOrder) || current.sortOrder <= 0) {
|
||||||
|
current.sortOrder = used.size
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnumPreviewItem(item) {
|
||||||
|
const desc = escapeHtml(item && item.description ? item.description : '(无描述)')
|
||||||
|
const imageHtml = item && item.imageUrl
|
||||||
|
? '<img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" />'
|
||||||
|
: '<span class="muted">无图</span>'
|
||||||
|
return '<div class="enum-preview-item"><span>' + desc + '</span>' + imageHtml + '</div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemsPreview(items, previewKey, isExpanded) {
|
||||||
if (!items || !items.length) return '<span class="muted">无</span>'
|
if (!items || !items.length) return '<span class="muted">无</span>'
|
||||||
return items.map(function (item) {
|
|
||||||
return '<div><strong>' + escapeHtml(item.enum) + '</strong> → ' + escapeHtml(item.description) + '(排序 ' + escapeHtml(item.sortOrder) + ')</div>'
|
const first = renderEnumPreviewItem(items[0])
|
||||||
|
if (items.length === 1) {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = items.slice(1).map(function (item) {
|
||||||
|
return renderEnumPreviewItem(item)
|
||||||
}).join('')
|
}).join('')
|
||||||
|
const hiddenCount = items.length - 1
|
||||||
|
const moreStyle = isExpanded ? 'display:block;' : 'display:none;'
|
||||||
|
const toggleText = isExpanded ? '收起' : ('展开其余 ' + hiddenCount + ' 项')
|
||||||
|
|
||||||
|
return first
|
||||||
|
+ '<div class="enum-preview-more" style="' + moreStyle + '">' + rest + '</div>'
|
||||||
|
+ '<button class="btn btn-light enum-preview-toggle" type="button" onclick="window.__toggleEnumPreview(\\'' + previewKey + '\\')">' + toggleText + '</button>'
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(list) {
|
function renderTable(list) {
|
||||||
@@ -246,14 +364,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tableBody.innerHTML = list.map(function (item) {
|
tableBody.innerHTML = list.map(function (item, index) {
|
||||||
const enabledClass = item.dict_word_is_enabled ? 'badge badge-on' : 'badge badge-off'
|
const enabledClass = item.dict_word_is_enabled ? 'badge badge-on' : 'badge badge-off'
|
||||||
const enabledText = item.dict_word_is_enabled ? '启用' : '禁用'
|
const enabledText = item.dict_word_is_enabled ? '启用' : '禁用'
|
||||||
|
const previewKey = String(index)
|
||||||
return '<tr>'
|
return '<tr>'
|
||||||
+ '<td data-label="字典名称"><input class="inline-input" data-field="dict_name" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_name) + '" /></td>'
|
+ '<td data-label="字典名称"><input class="inline-input" data-field="dict_name" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_name) + '" /></td>'
|
||||||
+ '<td data-label="启用"><select class="inline-input" data-field="dict_word_is_enabled" data-name="' + escapeHtml(item.dict_name) + '"><option value="true"' + (item.dict_word_is_enabled ? ' selected' : '') + '>启用</option><option value="false"' + (!item.dict_word_is_enabled ? ' selected' : '') + '>禁用</option></select><div class="' + enabledClass + '" style="margin-top:8px;">' + enabledText + '</div></td>'
|
+ '<td data-label="启用"><select class="inline-input" data-field="dict_word_is_enabled" data-name="' + escapeHtml(item.dict_name) + '"><option value="true"' + (item.dict_word_is_enabled ? ' selected' : '') + '>启用</option><option value="false"' + (!item.dict_word_is_enabled ? ' selected' : '') + '>禁用</option></select><div class="' + enabledClass + '" style="margin-top:8px;">' + enabledText + '</div></td>'
|
||||||
+ '<td data-label="备注"><textarea class="inline-input" data-field="dict_word_remark" data-name="' + escapeHtml(item.dict_name) + '">' + escapeHtml(item.dict_word_remark) + '</textarea></td>'
|
+ '<td data-label="备注"><textarea class="inline-input" data-field="dict_word_remark" data-name="' + escapeHtml(item.dict_name) + '">' + escapeHtml(item.dict_word_remark) + '</textarea></td>'
|
||||||
+ '<td data-label="枚举项">' + renderItemsPreview(item.items) + '</td>'
|
+ '<td data-label="枚举项">' + renderItemsPreview(item.items, previewKey, state.expandedPreviewKey === previewKey) + '</td>'
|
||||||
+ '<td data-label="创建时间"><span class="muted">' + escapeHtml(item.created) + '</span></td>'
|
+ '<td data-label="创建时间"><span class="muted">' + escapeHtml(item.created) + '</span></td>'
|
||||||
+ '<td data-label="操作">'
|
+ '<td data-label="操作">'
|
||||||
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;">'
|
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;">'
|
||||||
@@ -269,13 +388,25 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
function openModal(mode, record) {
|
function openModal(mode, record) {
|
||||||
state.mode = mode
|
state.mode = mode
|
||||||
state.editingOriginalName = record ? record.dict_name : ''
|
state.editingOriginalName = record ? record.dict_name : ''
|
||||||
|
state.enumSeed = randomEnumSeed()
|
||||||
|
state.enumCounter = 1
|
||||||
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
|
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
|
||||||
dictNameInput.value = record ? record.dict_name : ''
|
dictNameInput.value = record ? record.dict_name : ''
|
||||||
enabledInput.value = record && !record.dict_word_is_enabled ? 'false' : 'true'
|
enabledInput.value = record && !record.dict_word_is_enabled ? 'false' : 'true'
|
||||||
remarkInput.value = record ? (record.dict_word_remark || '') : ''
|
remarkInput.value = record ? (record.dict_word_remark || '') : ''
|
||||||
state.items = record && Array.isArray(record.items) && record.items.length
|
state.items = record && Array.isArray(record.items) && record.items.length
|
||||||
? record.items.map(function (item) { return { enum: item.enum, description: item.description, sortOrder: item.sortOrder } })
|
? record.items.map(function (item) {
|
||||||
: [{ enum: '', description: '', sortOrder: 1 }]
|
return {
|
||||||
|
enum: item.enum,
|
||||||
|
description: item.description,
|
||||||
|
image: item.image || '',
|
||||||
|
imageUrl: item.imageUrl || '',
|
||||||
|
imageAttachment: item.imageAttachment || null,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [{ enum: '', description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: 1 }]
|
||||||
|
normalizeItemEnums()
|
||||||
renderItemsEditor()
|
renderItemsEditor()
|
||||||
editorModal.classList.add('show')
|
editorModal.classList.add('show')
|
||||||
}
|
}
|
||||||
@@ -284,36 +415,110 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
editorModal.classList.remove('show')
|
editorModal.classList.remove('show')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollEditorModalToBottom() {
|
||||||
|
const modalCard = editorModal.querySelector('.modal-card')
|
||||||
|
if (!modalCard) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const doScroll = function () {
|
||||||
|
modalCard.scrollTo({
|
||||||
|
top: modalCard.scrollHeight,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(doScroll)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTimeout(doScroll, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: handle heavy DOM updates that render slightly later.
|
||||||
|
setTimeout(doScroll, 120)
|
||||||
|
}
|
||||||
|
|
||||||
function renderItemsEditor() {
|
function renderItemsEditor() {
|
||||||
itemsBody.innerHTML = state.items.map(function (item, index) {
|
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="muted">未上传图片</div>'
|
||||||
return '<tr>'
|
return '<tr>'
|
||||||
+ '<td><input data-item-field="enum" data-index="' + index + '" value="' + escapeHtml(item.enum) + '" /></td>'
|
|
||||||
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
|
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
|
||||||
|
+ '<td ondragover="window.__allowItemDrop(event)" ondrop="window.__dropItemImage(' + index + ', event)">'
|
||||||
|
+ imageCell
|
||||||
|
+ '<div class="image-upload-row">'
|
||||||
|
+ '<input type="file" accept="image/*" onchange="window.__uploadItemImage(' + index + ', this)" />'
|
||||||
|
+ '<button class="btn btn-light icon-btn" type="button" title="删除图片" onclick="window.__clearItemImage(' + index + ')">🗑️</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="drop-tip">支持拖拽图片到本区域上传</div>'
|
||||||
|
+ '</td>'
|
||||||
+ '<td><input type="number" data-item-field="sortOrder" data-index="' + index + '" value="' + escapeHtml(item.sortOrder) + '" /></td>'
|
+ '<td><input type="number" data-item-field="sortOrder" data-index="' + index + '" value="' + escapeHtml(item.sortOrder) + '" /></td>'
|
||||||
+ '<td><button class="btn btn-danger" type="button" onclick="window.__removeItem(' + index + ')">删除</button></td>'
|
+ '<td><button class="btn btn-danger" type="button" onclick="window.__removeItem(' + index + ')">删除</button></td>'
|
||||||
+ '</tr>'
|
+ '</tr>'
|
||||||
}).join('')
|
}).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setItemImageFromFile(index, file) {
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncItemsStateFromEditor()
|
||||||
|
if (!state.items[index]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('正在上传字典项图片...', '')
|
||||||
|
try {
|
||||||
|
const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1))
|
||||||
|
state.items[index].image = attachment.attachments_id
|
||||||
|
state.items[index].imageUrl = attachment.attachments_url || ''
|
||||||
|
state.items[index].imageAttachment = attachment
|
||||||
|
renderItemsEditor()
|
||||||
|
setStatus('字典项图片上传成功。', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message || '字典项图片上传失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectItemsFromEditor() {
|
function collectItemsFromEditor() {
|
||||||
const rows = Array.from(itemsBody.querySelectorAll('tr'))
|
const rows = Array.from(itemsBody.querySelectorAll('tr'))
|
||||||
return rows.map(function (row, index) {
|
return rows.map(function (row, index) {
|
||||||
const enumInput = row.querySelector('[data-item-field="enum"]')
|
|
||||||
const descriptionInput = row.querySelector('[data-item-field="description"]')
|
const descriptionInput = row.querySelector('[data-item-field="description"]')
|
||||||
const sortOrderInput = row.querySelector('[data-item-field="sortOrder"]')
|
const sortOrderInput = row.querySelector('[data-item-field="sortOrder"]')
|
||||||
return {
|
return {
|
||||||
enum: enumInput.value.trim(),
|
enum: state.items[index] ? String(state.items[index].enum || '') : '',
|
||||||
description: descriptionInput.value.trim(),
|
description: descriptionInput.value.trim(),
|
||||||
|
image: state.items[index] ? (state.items[index].image || '') : '',
|
||||||
|
imageUrl: state.items[index] ? (state.items[index].imageUrl || '') : '',
|
||||||
|
imageAttachment: state.items[index] ? (state.items[index].imageAttachment || null) : null,
|
||||||
sortOrder: Number(sortOrderInput.value || index + 1),
|
sortOrder: Number(sortOrderInput.value || index + 1),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncItemsStateFromEditor() {
|
||||||
|
const rows = itemsBody.querySelectorAll('tr')
|
||||||
|
if (!rows.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.items = collectItemsFromEditor()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadItemImage(index, inputEl) {
|
||||||
|
const file = inputEl && inputEl.files && inputEl.files[0]
|
||||||
|
await setItemImageFromFile(index, file)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
setStatus('正在查询字典列表...', '')
|
setStatus('正在查询字典列表...', '')
|
||||||
try {
|
try {
|
||||||
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
|
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
|
||||||
state.list = data.items || []
|
state.list = data.items || []
|
||||||
|
state.expandedPreviewKey = ''
|
||||||
renderTable(state.list)
|
renderTable(state.list)
|
||||||
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
|
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -332,6 +537,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
try {
|
try {
|
||||||
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
|
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
|
||||||
state.list = [data]
|
state.list = [data]
|
||||||
|
state.expandedPreviewKey = ''
|
||||||
renderTable(state.list)
|
renderTable(state.list)
|
||||||
setStatus('查询详情成功。', 'success')
|
setStatus('查询详情成功。', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -340,7 +546,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveModalRecord() {
|
async function saveModalRecord() {
|
||||||
const items = collectItemsFromEditor()
|
syncItemsStateFromEditor()
|
||||||
|
normalizeItemEnums()
|
||||||
|
const items = state.items
|
||||||
const payload = {
|
const payload = {
|
||||||
dict_name: dictNameInput.value.trim(),
|
dict_name: dictNameInput.value.trim(),
|
||||||
original_dict_name: state.editingOriginalName,
|
original_dict_name: state.editingOriginalName,
|
||||||
@@ -420,11 +628,38 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
|
|
||||||
window.__saveInline = saveInline
|
window.__saveInline = saveInline
|
||||||
window.__deleteRow = deleteRow
|
window.__deleteRow = deleteRow
|
||||||
|
window.__toggleEnumPreview = function (previewKey) {
|
||||||
|
state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey
|
||||||
|
renderTable(state.list)
|
||||||
|
}
|
||||||
|
window.__uploadItemImage = uploadItemImage
|
||||||
|
window.__allowItemDrop = function (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
window.__dropItemImage = function (index, event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const files = event.dataTransfer && event.dataTransfer.files
|
||||||
|
const file = files && files[0]
|
||||||
|
setItemImageFromFile(index, file)
|
||||||
|
}
|
||||||
|
window.__clearItemImage = function (index) {
|
||||||
|
syncItemsStateFromEditor()
|
||||||
|
if (!state.items[index]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.items[index].image = ''
|
||||||
|
state.items[index].imageUrl = ''
|
||||||
|
state.items[index].imageAttachment = null
|
||||||
|
renderItemsEditor()
|
||||||
|
setStatus('已清空该枚举项图片。', 'success')
|
||||||
|
}
|
||||||
window.__removeItem = function (index) {
|
window.__removeItem = function (index) {
|
||||||
|
syncItemsStateFromEditor()
|
||||||
state.items.splice(index, 1)
|
state.items.splice(index, 1)
|
||||||
if (!state.items.length) {
|
if (!state.items.length) {
|
||||||
state.items.push({ enum: '', description: '', sortOrder: 1 })
|
state.items.push({ enum: '', description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: 1 })
|
||||||
}
|
}
|
||||||
|
normalizeItemEnums()
|
||||||
renderItemsEditor()
|
renderItemsEditor()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,8 +676,11 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
|
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
|
||||||
document.getElementById('cancelBtn').addEventListener('click', closeModal)
|
document.getElementById('cancelBtn').addEventListener('click', closeModal)
|
||||||
document.getElementById('addItemBtn').addEventListener('click', function () {
|
document.getElementById('addItemBtn').addEventListener('click', function () {
|
||||||
state.items.push({ enum: '', description: '', sortOrder: state.items.length + 1 })
|
syncItemsStateFromEditor()
|
||||||
|
const used = new Set(state.items.map(function (item) { return String(item.enum || '') }))
|
||||||
|
state.items.push({ enum: nextAutoEnum(used), description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: state.items.length + 1 })
|
||||||
renderItemsEditor()
|
renderItemsEditor()
|
||||||
|
scrollEditorModalToBottom()
|
||||||
})
|
})
|
||||||
document.getElementById('saveBtn').addEventListener('click', saveModalRecord)
|
document.getElementById('saveBtn').addEventListener('click', saveModalRecord)
|
||||||
document.getElementById('logoutBtn').addEventListener('click', function () {
|
document.getElementById('logoutBtn').addEventListener('click', function () {
|
||||||
@@ -452,9 +690,11 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
localStorage.removeItem('pb_manage_login_time')
|
localStorage.removeItem('pb_manage_login_time')
|
||||||
window.location.replace('/pb/manage/login')
|
window.location.replace('/pb/manage/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
loadList()
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
return e.html(200, html)
|
return e.html(200, html)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { 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; }
|
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; }
|
.container { 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: 24px; padding: 24px; }
|
.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: 24px; }
|
.panel + .panel { margin-top: 14px; }
|
||||||
h1, h2 { margin-top: 0; }
|
h1, h2 { margin-top: 0; }
|
||||||
p { color: #4b5563; line-height: 1.7; }
|
p { color: #4b5563; line-height: 1.7; }
|
||||||
.actions, .form-actions, .toolbar, .table-actions, .file-actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
.actions, .form-actions, .toolbar, .table-actions, .file-actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
@@ -48,6 +48,16 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
.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; }
|
.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(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||||
.file-box { border: 1px solid #dbe3f0; border-radius: 18px; padding: 16px; background: #f8fbff; }
|
.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; }
|
||||||
|
.option-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: #fff; border: 1px solid #e5e7eb; min-height: 48px; }
|
||||||
|
.option-item input[type="checkbox"] { width: auto; margin: 0; }
|
||||||
|
.option-item span { display: block; word-break: break-word; }
|
||||||
|
.selection-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
.selection-tag { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 12px; font-weight: 700; }
|
||||||
|
.choice-switch { display: inline-flex; gap: 8px; padding: 6px; border-radius: 14px; border: 1px solid #dbe3f0; background: #f8fbff; }
|
||||||
|
.choice-switch button { border: 1px solid transparent; background: transparent; color: #475569; padding: 9px 18px; border-radius: 10px; font-weight: 700; cursor: pointer; }
|
||||||
|
.choice-switch button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
.dropzone { margin-top: 8px; border: 2px dashed #bfdbfe; border-radius: 16px; background: #ffffff; padding: 16px; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; cursor: pointer; }
|
.dropzone { margin-top: 8px; border: 2px dashed #bfdbfe; border-radius: 16px; background: #ffffff; padding: 16px; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; cursor: pointer; }
|
||||||
.dropzone.dragover { border-color: #2563eb; background: #eff6ff; box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08); }
|
.dropzone.dragover { border-color: #2563eb; background: #eff6ff; box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08); }
|
||||||
.dropzone-title { font-weight: 700; color: #1e3a8a; }
|
.dropzone-title { font-weight: 700; color: #1e3a8a; }
|
||||||
@@ -60,6 +70,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
|
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.grid, .file-group { grid-template-columns: 1fr; }
|
.grid, .file-group { grid-template-columns: 1fr; }
|
||||||
|
.option-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
table, thead, tbody, th, td, tr { display: block; }
|
table, thead, tbody, th, td, tr { display: block; }
|
||||||
thead { display: none; }
|
thead { display: none; }
|
||||||
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
|
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
|
||||||
@@ -72,10 +83,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>文档管理</h1>
|
<h1>文档管理</h1>
|
||||||
<p>页面会通过 <code>/pb/api/*</code> 调用 PocketBase hooks。图片和视频都支持多选上传;编辑已有文档时,会先回显当前已绑定的多图片、多视频,并支持从文档中移除或继续追加附件。</p>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-light" href="/pb/manage">返回主页</a>
|
<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-light" id="reloadBtn" type="button">刷新列表</button>
|
||||||
<button class="btn btn-light" id="createModeBtn" type="button">新建模式</button>
|
<button class="btn btn-light" id="createModeBtn" type="button">新建模式</button>
|
||||||
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
||||||
@@ -87,7 +96,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<div class="toolbar" style="justify-content:space-between;align-items:center;">
|
<div class="toolbar" style="justify-content:space-between;align-items:center;">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="formTitle">新增文档</h2>
|
<h2 id="formTitle">新增文档</h2>
|
||||||
<div class="muted">只有文档标题和文档类型为必填,其余字段允许为空。</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-banner" id="editorMode">当前模式:新建</div>
|
<div class="editor-banner" id="editorMode">当前模式:新建</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,16 +105,31 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<input id="documentTitle" placeholder="请输入文档标题" />
|
<input id="documentTitle" placeholder="请输入文档标题" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="documentType">文档类型</label>
|
<label for="embeddingStatus">嵌入状态</label>
|
||||||
<input id="documentType" placeholder="例如:说明书、新闻、常见故障" />
|
<input id="embeddingStatus" placeholder="可为空" />
|
||||||
|
</div>
|
||||||
|
<div class="full">
|
||||||
|
<label for="documentSubtitle">副标题</label>
|
||||||
|
<input id="documentSubtitle" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="documentTypeSource">文档类型</label>
|
||||||
|
<div class="option-box">
|
||||||
|
<select id="documentTypeSource">
|
||||||
|
<option value="">请选择数据来源字典</option>
|
||||||
|
</select>
|
||||||
|
<div class="option-list" id="documentTypeOptions"></div>
|
||||||
|
<div class="selection-tags" id="documentTypeTags"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="documentStatus">文档状态</label>
|
<label for="documentStatus">文档状态</label>
|
||||||
<input id="documentStatus" placeholder="可为空" />
|
<input id="documentStatus" type="hidden" value="有效" />
|
||||||
</div>
|
<div class="choice-switch" id="documentStatusSwitch">
|
||||||
<div>
|
<button type="button" data-status-value="有效" class="active">有效</button>
|
||||||
<label for="embeddingStatus">嵌入状态</label>
|
<button type="button" data-status-value="过期">过期</button>
|
||||||
<input id="embeddingStatus" placeholder="可为空" />
|
</div>
|
||||||
|
<div class="hint">系统会根据生效日期和到期日期自动切换状态;都不填写时默认有效。</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="effectDate">生效日期</label>
|
<label for="effectDate">生效日期</label>
|
||||||
@@ -116,10 +139,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<label for="expiryDate">到期日期</label>
|
<label for="expiryDate">到期日期</label>
|
||||||
<input id="expiryDate" type="date" />
|
<input id="expiryDate" type="date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
|
||||||
<label for="documentSubtitle">副标题</label>
|
|
||||||
<input id="documentSubtitle" placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label for="documentSummary">摘要</label>
|
<label for="documentSummary">摘要</label>
|
||||||
<textarea id="documentSummary" placeholder="请输入文档摘要"></textarea>
|
<textarea id="documentSummary" placeholder="请输入文档摘要"></textarea>
|
||||||
@@ -137,21 +156,36 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<input id="vectorVersion" placeholder="可选" />
|
<input id="vectorVersion" placeholder="可选" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="documentKeywords">关键词</label>
|
<label>关键词</label>
|
||||||
<input id="documentKeywords" placeholder="多个值用 | 分隔" />
|
<div class="option-box">
|
||||||
<div class="hint">例如:安装|配置|排障</div>
|
<div class="hint">可不选,也可多选。</div>
|
||||||
|
<div class="option-list" id="documentKeywordsOptions"></div>
|
||||||
|
<div class="selection-tags" id="documentKeywordsTags"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="productCategories">适用产品类别</label>
|
<label>产品关联文档</label>
|
||||||
<input id="productCategories" placeholder="多个值用 | 分隔" />
|
<div class="option-box">
|
||||||
|
<div class="hint">可不选,也可多选。</div>
|
||||||
|
<div class="option-list" id="productCategoriesOptions"></div>
|
||||||
|
<div class="selection-tags" id="productCategoriesTags"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="applicationScenarios">适用场景</label>
|
<label>筛选依据</label>
|
||||||
<input id="applicationScenarios" placeholder="多个值用 | 分隔" />
|
<div class="option-box">
|
||||||
|
<div class="hint">可不选,也可多选。</div>
|
||||||
|
<div class="option-list" id="applicationScenariosOptions"></div>
|
||||||
|
<div class="selection-tags" id="applicationScenariosTags"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="hotelType">适用酒店类型</label>
|
<label>适用场景</label>
|
||||||
<input id="hotelType" placeholder="多个值用 | 分隔" />
|
<div class="option-box">
|
||||||
|
<div class="hint">可不选,也可多选。</div>
|
||||||
|
<div class="option-list" id="hotelTypeOptions"></div>
|
||||||
|
<div class="selection-tags" id="hotelTypeTags"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label for="documentRemark">备注</label>
|
<label for="documentRemark">备注</label>
|
||||||
@@ -164,7 +198,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<label for="imageFile">新增图片</label>
|
<label for="imageFile">新增图片</label>
|
||||||
<div class="dropzone" id="imageDropzone">
|
<div class="dropzone" id="imageDropzone">
|
||||||
<div class="dropzone-title">拖拽图片到这里,或点击选择文件</div>
|
<div class="dropzone-title">拖拽图片到这里,或点击选择文件</div>
|
||||||
<div class="dropzone-text">支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。</div>
|
|
||||||
<input id="imageFile" type="file" multiple />
|
<input id="imageFile" type="file" multiple />
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list" id="imageCurrentList"></div>
|
<div class="file-list" id="imageCurrentList"></div>
|
||||||
@@ -175,7 +208,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<label for="videoFile">新增视频</label>
|
<label for="videoFile">新增视频</label>
|
||||||
<div class="dropzone" id="videoDropzone">
|
<div class="dropzone" id="videoDropzone">
|
||||||
<div class="dropzone-title">拖拽视频到这里,或点击选择文件</div>
|
<div class="dropzone-title">拖拽视频到这里,或点击选择文件</div>
|
||||||
<div class="dropzone-text">支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。</div>
|
|
||||||
<input id="videoFile" type="file" multiple />
|
<input id="videoFile" type="file" multiple />
|
||||||
</div>
|
</div>
|
||||||
<div class="file-list" id="videoCurrentList"></div>
|
<div class="file-list" id="videoCurrentList"></div>
|
||||||
@@ -195,7 +227,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>document_id</th>
|
|
||||||
<th>标题</th>
|
<th>标题</th>
|
||||||
<th>类型/状态</th>
|
<th>类型/状态</th>
|
||||||
<th>附件链接</th>
|
<th>附件链接</th>
|
||||||
@@ -224,9 +255,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
const videoPendingListEl = document.getElementById('videoPendingList')
|
const videoPendingListEl = document.getElementById('videoPendingList')
|
||||||
const imageDropzoneEl = document.getElementById('imageDropzone')
|
const imageDropzoneEl = document.getElementById('imageDropzone')
|
||||||
const videoDropzoneEl = document.getElementById('videoDropzone')
|
const videoDropzoneEl = document.getElementById('videoDropzone')
|
||||||
|
const documentTypeSourceEl = document.getElementById('documentTypeSource')
|
||||||
|
const documentTypeOptionsEl = document.getElementById('documentTypeOptions')
|
||||||
|
const documentTypeTagsEl = document.getElementById('documentTypeTags')
|
||||||
|
const documentStatusSwitchEl = document.getElementById('documentStatusSwitch')
|
||||||
|
const documentKeywordsOptionsEl = document.getElementById('documentKeywordsOptions')
|
||||||
|
const documentKeywordsTagsEl = document.getElementById('documentKeywordsTags')
|
||||||
|
const productCategoriesOptionsEl = document.getElementById('productCategoriesOptions')
|
||||||
|
const productCategoriesTagsEl = document.getElementById('productCategoriesTags')
|
||||||
|
const applicationScenariosOptionsEl = document.getElementById('applicationScenariosOptions')
|
||||||
|
const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags')
|
||||||
|
const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions')
|
||||||
|
const hotelTypeTagsEl = document.getElementById('hotelTypeTags')
|
||||||
const fields = {
|
const fields = {
|
||||||
documentTitle: document.getElementById('documentTitle'),
|
documentTitle: document.getElementById('documentTitle'),
|
||||||
documentType: document.getElementById('documentType'),
|
|
||||||
documentStatus: document.getElementById('documentStatus'),
|
documentStatus: document.getElementById('documentStatus'),
|
||||||
embeddingStatus: document.getElementById('embeddingStatus'),
|
embeddingStatus: document.getElementById('embeddingStatus'),
|
||||||
effectDate: document.getElementById('effectDate'),
|
effectDate: document.getElementById('effectDate'),
|
||||||
@@ -236,14 +278,32 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
documentContent: document.getElementById('documentContent'),
|
documentContent: document.getElementById('documentContent'),
|
||||||
relationModel: document.getElementById('relationModel'),
|
relationModel: document.getElementById('relationModel'),
|
||||||
vectorVersion: document.getElementById('vectorVersion'),
|
vectorVersion: document.getElementById('vectorVersion'),
|
||||||
documentKeywords: document.getElementById('documentKeywords'),
|
|
||||||
productCategories: document.getElementById('productCategories'),
|
|
||||||
applicationScenarios: document.getElementById('applicationScenarios'),
|
|
||||||
hotelType: document.getElementById('hotelType'),
|
|
||||||
documentRemark: document.getElementById('documentRemark'),
|
documentRemark: document.getElementById('documentRemark'),
|
||||||
imageFile: document.getElementById('imageFile'),
|
imageFile: document.getElementById('imageFile'),
|
||||||
videoFile: document.getElementById('videoFile'),
|
videoFile: document.getElementById('videoFile'),
|
||||||
}
|
}
|
||||||
|
const dictionaryFieldConfig = {
|
||||||
|
documentKeywords: {
|
||||||
|
dictName: '文档-关键词',
|
||||||
|
optionsEl: documentKeywordsOptionsEl,
|
||||||
|
tagsEl: documentKeywordsTagsEl,
|
||||||
|
},
|
||||||
|
productCategories: {
|
||||||
|
dictName: '文档-产品关联文档',
|
||||||
|
optionsEl: productCategoriesOptionsEl,
|
||||||
|
tagsEl: productCategoriesTagsEl,
|
||||||
|
},
|
||||||
|
applicationScenarios: {
|
||||||
|
dictName: '文档-筛选依据',
|
||||||
|
optionsEl: applicationScenariosOptionsEl,
|
||||||
|
tagsEl: applicationScenariosTagsEl,
|
||||||
|
},
|
||||||
|
hotelType: {
|
||||||
|
dictName: '文档-适用场景',
|
||||||
|
optionsEl: hotelTypeOptionsEl,
|
||||||
|
tagsEl: hotelTypeTagsEl,
|
||||||
|
},
|
||||||
|
}
|
||||||
const state = {
|
const state = {
|
||||||
list: [],
|
list: [],
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
@@ -254,6 +314,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
pendingImageFiles: [],
|
pendingImageFiles: [],
|
||||||
pendingVideoFiles: [],
|
pendingVideoFiles: [],
|
||||||
removedAttachmentIds: [],
|
removedAttachmentIds: [],
|
||||||
|
dictionaries: [],
|
||||||
|
dictionariesByName: {},
|
||||||
|
dictionariesById: {},
|
||||||
|
selections: {
|
||||||
|
documentTypeSource: '',
|
||||||
|
documentTypeValues: [],
|
||||||
|
documentKeywords: [],
|
||||||
|
productCategories: [],
|
||||||
|
applicationScenarios: [],
|
||||||
|
hotelType: [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(message, type) {
|
function setStatus(message, type) {
|
||||||
@@ -275,6 +346,46 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
return match ? match[0] : ''
|
return match ? match[0] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTodayDateString() {
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return year + '-' + month + '-' + day
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDocumentStatus(effectDate, expiryDate, fallbackStatus) {
|
||||||
|
const start = toDateInputValue(effectDate)
|
||||||
|
const end = toDateInputValue(expiryDate)
|
||||||
|
const today = getTodayDateString()
|
||||||
|
|
||||||
|
if (!start && !end) {
|
||||||
|
return fallbackStatus === '过期' ? '过期' : '有效'
|
||||||
|
}
|
||||||
|
if (start && today < start) {
|
||||||
|
return '过期'
|
||||||
|
}
|
||||||
|
if (end && today > end) {
|
||||||
|
return '过期'
|
||||||
|
}
|
||||||
|
return '有效'
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDocumentStatusSwitch() {
|
||||||
|
const buttons = documentStatusSwitchEl ? documentStatusSwitchEl.querySelectorAll('[data-status-value]') : []
|
||||||
|
const currentValue = fields.documentStatus.value || '有效'
|
||||||
|
for (let i = 0; i < buttons.length; i += 1) {
|
||||||
|
const button = buttons[i]
|
||||||
|
const isActive = button.getAttribute('data-status-value') === currentValue
|
||||||
|
button.classList.toggle('active', isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDocumentStatus(preferredStatus) {
|
||||||
|
fields.documentStatus.value = calculateDocumentStatus(fields.effectDate.value, fields.expiryDate.value, preferredStatus || fields.documentStatus.value || '有效')
|
||||||
|
updateDocumentStatusSwitch()
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAttachmentList(attachments, ids, urls) {
|
function normalizeAttachmentList(attachments, ids, urls) {
|
||||||
const attachmentArray = Array.isArray(attachments) ? attachments : []
|
const attachmentArray = Array.isArray(attachments) ? attachments : []
|
||||||
if (attachmentArray.length) {
|
if (attachmentArray.length) {
|
||||||
@@ -304,6 +415,154 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitPipeValue(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.split('|')
|
||||||
|
.map(function (item) { return String(item || '').trim() })
|
||||||
|
.filter(function (item) { return !!item })
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPipeValue(values) {
|
||||||
|
return Array.from(new Set((values || []).map(function (item) {
|
||||||
|
return String(item || '').trim()
|
||||||
|
}).filter(function (item) {
|
||||||
|
return !!item
|
||||||
|
}))).join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDictionaryByName(dictName) {
|
||||||
|
return state.dictionariesByName[dictName] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocumentTypeSourceDictionary() {
|
||||||
|
const sourceId = String(state.selections.documentTypeSource || '')
|
||||||
|
return sourceId ? (state.dictionariesById[sourceId] || null) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentTypeStorageValue() {
|
||||||
|
const sourceDict = getDocumentTypeSourceDictionary()
|
||||||
|
if (!sourceDict) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinPipeValue(state.selections.documentTypeValues.map(function (enumValue) {
|
||||||
|
return sourceDict.system_dict_id + '@' + enumValue
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(fieldName, value, checked) {
|
||||||
|
const target = state.selections[fieldName] || []
|
||||||
|
const next = target.filter(function (item) {
|
||||||
|
return item !== value
|
||||||
|
})
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
next.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selections[fieldName] = next
|
||||||
|
renderDictionarySelectors()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectionTags(tagsEl, values, labelsMap) {
|
||||||
|
if (!values.length) {
|
||||||
|
tagsEl.innerHTML = '<span class="muted">未选择</span>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsEl.innerHTML = values.map(function (value) {
|
||||||
|
return '<span class="selection-tag">' + escapeHtml(labelsMap[value] || value) + '</span>'
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocumentTypeDisplay(value) {
|
||||||
|
const parts = splitPipeValue(value)
|
||||||
|
if (!parts.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.map(function (part) {
|
||||||
|
const separatorIndex = part.indexOf('@')
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceId = part.slice(0, separatorIndex)
|
||||||
|
const enumValue = part.slice(separatorIndex + 1)
|
||||||
|
const dict = state.dictionariesById[sourceId]
|
||||||
|
if (!dict || !Array.isArray(dict.items)) {
|
||||||
|
return enumValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = dict.items.find(function (item) {
|
||||||
|
return String(item.enum || '') === enumValue
|
||||||
|
})
|
||||||
|
|
||||||
|
return matched ? (matched.description || matched.enum || enumValue) : enumValue
|
||||||
|
}).join(' | ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOptionList(optionsEl, selectedValues, items, fieldName) {
|
||||||
|
if (!items || !items.length) {
|
||||||
|
optionsEl.innerHTML = '<div class="muted">暂无可选项</div>'
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelsMap = {}
|
||||||
|
optionsEl.innerHTML = items.map(function (item) {
|
||||||
|
const optionValue = String(item.enum || '')
|
||||||
|
const checked = selectedValues.indexOf(optionValue) !== -1
|
||||||
|
labelsMap[optionValue] = item.description || item.enum || optionValue
|
||||||
|
return '<label class="option-item">'
|
||||||
|
+ '<input type="checkbox" data-selection-field="' + fieldName + '" value="' + escapeHtml(optionValue) + '"' + (checked ? ' checked' : '') + ' />'
|
||||||
|
+ '<span>' + escapeHtml(item.description || item.enum || optionValue) + '</span>'
|
||||||
|
+ '</label>'
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return labelsMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocumentTypeSourceOptions() {
|
||||||
|
const currentValue = String(state.selections.documentTypeSource || '')
|
||||||
|
documentTypeSourceEl.innerHTML = ['<option value="">请选择数据来源字典</option>']
|
||||||
|
.concat(state.dictionaries.map(function (dict) {
|
||||||
|
return '<option value="' + escapeHtml(dict.system_dict_id) + '"' + (currentValue === dict.system_dict_id ? ' selected' : '') + '>' + escapeHtml(dict.dict_name) + '</option>'
|
||||||
|
}))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDictionarySelectors() {
|
||||||
|
renderDocumentTypeSourceOptions()
|
||||||
|
|
||||||
|
const sourceDict = getDocumentTypeSourceDictionary()
|
||||||
|
const sourceItems = sourceDict && Array.isArray(sourceDict.items) ? sourceDict.items : []
|
||||||
|
const documentTypeLabels = renderOptionList(documentTypeOptionsEl, state.selections.documentTypeValues, sourceItems, 'documentTypeValues')
|
||||||
|
renderSelectionTags(documentTypeTagsEl, state.selections.documentTypeValues, documentTypeLabels)
|
||||||
|
|
||||||
|
Object.keys(dictionaryFieldConfig).forEach(function (fieldName) {
|
||||||
|
const config = dictionaryFieldConfig[fieldName]
|
||||||
|
const dict = findDictionaryByName(config.dictName)
|
||||||
|
const items = dict && Array.isArray(dict.items) ? dict.items : []
|
||||||
|
const labelsMap = renderOptionList(config.optionsEl, state.selections[fieldName], items, fieldName)
|
||||||
|
renderSelectionTags(config.tagsEl, state.selections[fieldName], labelsMap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDictionaries() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDictionarySelectors()
|
||||||
|
}
|
||||||
|
|
||||||
function updateEditorMode() {
|
function updateEditorMode() {
|
||||||
if (state.mode === 'edit') {
|
if (state.mode === 'edit') {
|
||||||
formTitleEl.textContent = '编辑文档'
|
formTitleEl.textContent = '编辑文档'
|
||||||
@@ -345,6 +604,36 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
return data.data
|
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) {
|
async function uploadAttachment(file, label) {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -368,8 +657,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
body: form,
|
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.ok || !data || data.code >= 400) {
|
||||||
|
if (res.status === 413) {
|
||||||
|
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小。当前附件字段已放宽到约 4GB,但线上反向代理也需要同步放开到相应体积。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.isJson && parsed.text) {
|
||||||
|
throw new Error('上传' + label + '失败:服务端返回了非 JSON 响应,通常表示网关或反向代理提前拦截了上传请求。')
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error((data && data.msg) || ('上传' + label + '失败'))
|
throw new Error((data && data.msg) || ('上传' + label + '失败'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,15 +749,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
if (!state.list.length) {
|
if (!state.list.length) {
|
||||||
tableBody.innerHTML = '<tr><td colspan="6" class="empty">暂无文档数据。</td></tr>'
|
tableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无文档数据。</td></tr>'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tableBody.innerHTML = state.list.map(function (item) {
|
tableBody.innerHTML = state.list.map(function (item) {
|
||||||
return '<tr>'
|
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><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(formatDocumentTypeDisplay(item.document_type) || item.document_type) + '</div><div class="muted">' + escapeHtml(item.document_status) + ' / ' + escapeHtml(item.document_embedding_status) + '</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="附件链接">' + renderLinks(item) + '</td>'
|
||||||
+ '<td data-label="更新时间"><span class="muted">' + escapeHtml(item.updated) + '</span></td>'
|
+ '<td data-label="更新时间"><span class="muted">' + escapeHtml(item.updated) + '</span></td>'
|
||||||
+ '<td data-label="操作"><div class="table-actions">'
|
+ '<td data-label="操作"><div class="table-actions">'
|
||||||
@@ -555,8 +852,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
fields.documentTitle.value = ''
|
fields.documentTitle.value = ''
|
||||||
fields.documentType.value = ''
|
fields.documentStatus.value = '有效'
|
||||||
fields.documentStatus.value = ''
|
|
||||||
fields.embeddingStatus.value = ''
|
fields.embeddingStatus.value = ''
|
||||||
fields.effectDate.value = ''
|
fields.effectDate.value = ''
|
||||||
fields.expiryDate.value = ''
|
fields.expiryDate.value = ''
|
||||||
@@ -565,13 +861,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
fields.documentContent.value = ''
|
fields.documentContent.value = ''
|
||||||
fields.relationModel.value = ''
|
fields.relationModel.value = ''
|
||||||
fields.vectorVersion.value = ''
|
fields.vectorVersion.value = ''
|
||||||
fields.documentKeywords.value = ''
|
|
||||||
fields.productCategories.value = ''
|
|
||||||
fields.applicationScenarios.value = ''
|
|
||||||
fields.hotelType.value = ''
|
|
||||||
fields.documentRemark.value = ''
|
fields.documentRemark.value = ''
|
||||||
fields.imageFile.value = ''
|
fields.imageFile.value = ''
|
||||||
fields.videoFile.value = ''
|
fields.videoFile.value = ''
|
||||||
|
state.selections.documentTypeSource = ''
|
||||||
|
state.selections.documentTypeValues = []
|
||||||
|
state.selections.documentKeywords = []
|
||||||
|
state.selections.productCategories = []
|
||||||
|
state.selections.applicationScenarios = []
|
||||||
|
state.selections.hotelType = []
|
||||||
|
renderDictionarySelectors()
|
||||||
|
syncDocumentStatus('有效')
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterCreateMode() {
|
function enterCreateMode() {
|
||||||
@@ -590,8 +890,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
function fillFormFromItem(item) {
|
function fillFormFromItem(item) {
|
||||||
fields.documentTitle.value = item.document_title || ''
|
fields.documentTitle.value = item.document_title || ''
|
||||||
fields.documentType.value = item.document_type || ''
|
fields.documentStatus.value = item.document_status || '有效'
|
||||||
fields.documentStatus.value = item.document_status || ''
|
|
||||||
fields.embeddingStatus.value = item.document_embedding_status || ''
|
fields.embeddingStatus.value = item.document_embedding_status || ''
|
||||||
fields.effectDate.value = toDateInputValue(item.document_effect_date)
|
fields.effectDate.value = toDateInputValue(item.document_effect_date)
|
||||||
fields.expiryDate.value = toDateInputValue(item.document_expiry_date)
|
fields.expiryDate.value = toDateInputValue(item.document_expiry_date)
|
||||||
@@ -600,13 +899,27 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
fields.documentContent.value = item.document_content || ''
|
fields.documentContent.value = item.document_content || ''
|
||||||
fields.relationModel.value = item.document_relation_model || ''
|
fields.relationModel.value = item.document_relation_model || ''
|
||||||
fields.vectorVersion.value = item.document_vector_version || ''
|
fields.vectorVersion.value = item.document_vector_version || ''
|
||||||
fields.documentKeywords.value = item.document_keywords || ''
|
|
||||||
fields.productCategories.value = item.document_product_categories || ''
|
|
||||||
fields.applicationScenarios.value = item.document_application_scenarios || ''
|
|
||||||
fields.hotelType.value = item.document_hotel_type || ''
|
|
||||||
fields.documentRemark.value = item.document_remark || ''
|
fields.documentRemark.value = item.document_remark || ''
|
||||||
fields.imageFile.value = ''
|
fields.imageFile.value = ''
|
||||||
fields.videoFile.value = ''
|
fields.videoFile.value = ''
|
||||||
|
|
||||||
|
const documentTypeParts = splitPipeValue(item.document_type)
|
||||||
|
const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : ''
|
||||||
|
const sourceId = firstDocumentType.indexOf('@') !== -1 ? firstDocumentType.split('@')[0] : ''
|
||||||
|
state.selections.documentTypeSource = sourceId
|
||||||
|
state.selections.documentTypeValues = documentTypeParts
|
||||||
|
.map(function (part) {
|
||||||
|
return part.indexOf('@') !== -1 ? part.split('@').slice(1).join('@') : ''
|
||||||
|
})
|
||||||
|
.filter(function (part) {
|
||||||
|
return !!part
|
||||||
|
})
|
||||||
|
state.selections.documentKeywords = splitPipeValue(item.document_keywords)
|
||||||
|
state.selections.productCategories = splitPipeValue(item.document_product_categories)
|
||||||
|
state.selections.applicationScenarios = splitPipeValue(item.document_application_scenarios)
|
||||||
|
state.selections.hotelType = splitPipeValue(item.document_hotel_type)
|
||||||
|
renderDictionarySelectors()
|
||||||
|
syncDocumentStatus(item.document_status || '有效')
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterEditMode(documentId) {
|
function enterEditMode(documentId) {
|
||||||
@@ -639,8 +952,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
return {
|
return {
|
||||||
document_id: state.mode === 'edit' ? state.editingId : '',
|
document_id: state.mode === 'edit' ? state.editingId : '',
|
||||||
document_title: fields.documentTitle.value.trim(),
|
document_title: fields.documentTitle.value.trim(),
|
||||||
document_type: fields.documentType.value.trim(),
|
document_type: buildDocumentTypeStorageValue(),
|
||||||
document_status: fields.documentStatus.value.trim(),
|
document_status: fields.documentStatus.value.trim() || '有效',
|
||||||
document_embedding_status: fields.embeddingStatus.value.trim(),
|
document_embedding_status: fields.embeddingStatus.value.trim(),
|
||||||
document_effect_date: fields.effectDate.value,
|
document_effect_date: fields.effectDate.value,
|
||||||
document_expiry_date: fields.expiryDate.value,
|
document_expiry_date: fields.expiryDate.value,
|
||||||
@@ -650,16 +963,16 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
document_image: imageAttachments.map(function (item) { return item.attachments_id }),
|
document_image: imageAttachments.map(function (item) { return item.attachments_id }),
|
||||||
document_video: videoAttachments.map(function (item) { return item.attachments_id }),
|
document_video: videoAttachments.map(function (item) { return item.attachments_id }),
|
||||||
document_relation_model: fields.relationModel.value.trim(),
|
document_relation_model: fields.relationModel.value.trim(),
|
||||||
document_keywords: fields.documentKeywords.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,
|
document_share_count: typeof source.document_share_count === 'undefined' || source.document_share_count === null ? '' : source.document_share_count,
|
||||||
document_download_count: typeof source.document_download_count === 'undefined' || source.document_download_count === null ? '' : source.document_download_count,
|
document_download_count: typeof source.document_download_count === 'undefined' || source.document_download_count === null ? '' : source.document_download_count,
|
||||||
document_favorite_count: typeof source.document_favorite_count === 'undefined' || source.document_favorite_count === null ? '' : source.document_favorite_count,
|
document_favorite_count: typeof source.document_favorite_count === 'undefined' || source.document_favorite_count === null ? '' : source.document_favorite_count,
|
||||||
document_embedding_error: source.document_embedding_error || '',
|
document_embedding_error: source.document_embedding_error || '',
|
||||||
document_embedding_lasttime: source.document_embedding_lasttime || '',
|
document_embedding_lasttime: source.document_embedding_lasttime || '',
|
||||||
document_vector_version: fields.vectorVersion.value.trim(),
|
document_vector_version: fields.vectorVersion.value.trim(),
|
||||||
document_product_categories: fields.productCategories.value.trim(),
|
document_product_categories: joinPipeValue(state.selections.productCategories),
|
||||||
document_application_scenarios: fields.applicationScenarios.value.trim(),
|
document_application_scenarios: joinPipeValue(state.selections.applicationScenarios),
|
||||||
document_hotel_type: fields.hotelType.value.trim(),
|
document_hotel_type: joinPipeValue(state.selections.hotelType),
|
||||||
document_remark: fields.documentRemark.value.trim(),
|
document_remark: fields.documentRemark.value.trim(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,12 +998,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitDocument() {
|
async function submitDocument() {
|
||||||
|
syncDocumentStatus()
|
||||||
|
|
||||||
if (!fields.documentTitle.value.trim()) {
|
if (!fields.documentTitle.value.trim()) {
|
||||||
setStatus('请先填写文档标题。', 'error')
|
setStatus('请先填写文档标题。', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fields.documentType.value.trim()) {
|
if (!buildDocumentTypeStorageValue()) {
|
||||||
setStatus('请先填写文档类型。', 'error')
|
setStatus('请先填写文档类型。', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -760,6 +1075,39 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
window.__removePendingAttachment = removePendingAttachment
|
window.__removePendingAttachment = removePendingAttachment
|
||||||
window.__removeCurrentAttachment = removeCurrentAttachment
|
window.__removeCurrentAttachment = removeCurrentAttachment
|
||||||
|
|
||||||
|
documentTypeSourceEl.addEventListener('change', function (event) {
|
||||||
|
state.selections.documentTypeSource = event.target.value || ''
|
||||||
|
state.selections.documentTypeValues = []
|
||||||
|
renderDictionarySelectors()
|
||||||
|
})
|
||||||
|
documentStatusSwitchEl.addEventListener('click', function (event) {
|
||||||
|
const target = event.target
|
||||||
|
if (!target || !target.getAttribute) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = target.getAttribute('data-status-value')
|
||||||
|
if (!nextValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.documentStatus.value = nextValue
|
||||||
|
syncDocumentStatus(nextValue)
|
||||||
|
})
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
const target = event.target
|
||||||
|
if (!target || !target.getAttribute) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldName = target.getAttribute('data-selection-field')
|
||||||
|
if (!fieldName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection(fieldName, target.value, !!target.checked)
|
||||||
|
})
|
||||||
|
|
||||||
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
|
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
|
||||||
document.getElementById('createModeBtn').addEventListener('click', function () {
|
document.getElementById('createModeBtn').addEventListener('click', function () {
|
||||||
enterCreateMode()
|
enterCreateMode()
|
||||||
@@ -790,6 +1138,12 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
appendPendingFiles('image', event.target.files)
|
appendPendingFiles('image', event.target.files)
|
||||||
fields.imageFile.value = ''
|
fields.imageFile.value = ''
|
||||||
})
|
})
|
||||||
|
fields.effectDate.addEventListener('change', function () {
|
||||||
|
syncDocumentStatus()
|
||||||
|
})
|
||||||
|
fields.expiryDate.addEventListener('change', function () {
|
||||||
|
syncDocumentStatus()
|
||||||
|
})
|
||||||
fields.videoFile.addEventListener('change', function (event) {
|
fields.videoFile.addEventListener('change', function (event) {
|
||||||
appendPendingFiles('video', event.target.files)
|
appendPendingFiles('video', event.target.files)
|
||||||
fields.videoFile.value = ''
|
fields.videoFile.value = ''
|
||||||
@@ -798,7 +1152,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
|
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
|
||||||
|
|
||||||
enterCreateMode()
|
enterCreateMode()
|
||||||
loadDocuments()
|
;(async function initPage() {
|
||||||
|
try {
|
||||||
|
await loadDictionaries()
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message || '加载字典选项失败', 'error')
|
||||||
|
}
|
||||||
|
await loadDocuments()
|
||||||
|
})()
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ routerAdd('GET', '/manage', function (e) {
|
|||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
|
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: 760px; margin: 0 auto; padding: 48px 20px; }
|
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
|
||||||
.hero { background: #ffffff; border-radius: 24px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 36px; border: 1px solid #e5e7eb; }
|
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
|
||||||
h1 { margin: 0 0 20px; font-size: 32px; }
|
h1 { margin: 0 0 14px; font-size: 30px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
||||||
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 18px; padding: 22px; text-align: center; }
|
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
|
||||||
.card h2 { margin: 0 0 14px; font-size: 20px; }
|
.card h2 { margin: 0 0 8px; font-size: 19px; }
|
||||||
.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; }
|
.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; }
|
||||||
.actions { margin-top: 24px; display: flex; justify-content: flex-start; }
|
.actions { margin-top: 14px; display: flex; justify-content: flex-start; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ function renderLoginPage(e) {
|
|||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.wrap {
|
.wrap {
|
||||||
max-width: 420px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 72px 20px;
|
padding: 34px 14px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #dbe3f0;
|
border: 1px solid #dbe3f0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 28px;
|
padding: 22px;
|
||||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
h1 { margin-top: 0; }
|
h1 { margin-top: 0; }
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
|
|
||||||
- `POST /pb/api/dictionary/list`
|
- `POST /pb/api/dictionary/list`
|
||||||
- 支持按 `dict_name` 模糊搜索
|
- 支持按 `dict_name` 模糊搜索
|
||||||
- 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 组装为 `items`
|
- 返回字典全量信息,并将 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 组装为 `items`
|
||||||
- `POST /pb/api/dictionary/detail`
|
- `POST /pb/api/dictionary/detail`
|
||||||
- 按 `dict_name` 查询单条字典
|
- 按 `dict_name` 查询单条字典
|
||||||
- `POST /pb/api/dictionary/create`
|
- `POST /pb/api/dictionary/create`
|
||||||
@@ -216,8 +216,9 @@
|
|||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
|
- `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
|
||||||
- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]`
|
- 字典项图片需先调用 `/pb/api/attachment/upload` 上传,再把返回的 `attachments_id` 写入 `dict_word_image` 对应位置。
|
||||||
|
- 查询时统一聚合为:`items: [{ enum, description, image, imageUrl, imageAttachment, sortOrder }]`
|
||||||
|
|
||||||
### 附件管理接口
|
### 附件管理接口
|
||||||
|
|
||||||
@@ -267,11 +268,16 @@
|
|||||||
- `document_id` 可不传,由服务端自动生成
|
- `document_id` 可不传,由服务端自动生成
|
||||||
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
||||||
- `document_image`、`document_video` 支持传入多个已存在的 `attachments_id`
|
- `document_image`、`document_video` 支持传入多个已存在的 `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` 改为从 `文档-适用场景` 读取
|
||||||
|
- `document_status` 仅保留 `有效` / `过期` 两种状态,并由生效日期与到期日期自动计算
|
||||||
- 成功后会写入一条文档操作历史,类型为 `create`
|
- 成功后会写入一条文档操作历史,类型为 `create`
|
||||||
- `POST /pb/api/document/update`
|
- `POST /pb/api/document/update`
|
||||||
- 按 `document_id` 更新文档
|
- 按 `document_id` 更新文档
|
||||||
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
||||||
- 若传入附件字段,则会校验多个 `attachments_id` 是否都存在
|
- 若传入附件字段,则会校验多个 `attachments_id` 是否都存在
|
||||||
|
- 多选字段的持久化格式与新增接口一致
|
||||||
- 成功后会写入一条文档操作历史,类型为 `update`
|
- 成功后会写入一条文档操作历史,类型为 `update`
|
||||||
- `POST /pb/api/document/delete`
|
- `POST /pb/api/document/delete`
|
||||||
- 按 `document_id` 真删除文档
|
- 按 `document_id` 真删除文档
|
||||||
@@ -325,6 +331,8 @@
|
|||||||
- 指定字典查询
|
- 指定字典查询
|
||||||
- 行内编辑基础字段
|
- 行内编辑基础字段
|
||||||
- 弹窗编辑枚举项
|
- 弹窗编辑枚举项
|
||||||
|
- 为每个枚举项单独上传图片,并保存对应 `attachments_id`
|
||||||
|
- 回显字典项图片缩略图与文件流链接
|
||||||
- 新增 / 删除字典
|
- 新增 / 删除字典
|
||||||
- 返回主页
|
- 返回主页
|
||||||
- 文档管理页支持:
|
- 文档管理页支持:
|
||||||
|
|||||||
1408
pocket-base/spec/openapi-manage.yaml
Normal file
1408
pocket-base/spec/openapi-manage.yaml
Normal file
File diff suppressed because it is too large
Load Diff
419
pocket-base/spec/openapi-wx.yaml
Normal file
419
pocket-base/spec/openapi-wx.yaml
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: BAI PocketBase WeChat API
|
||||||
|
version: 1.0.0-wx
|
||||||
|
description: |
|
||||||
|
面向微信端的小程序接口文档。
|
||||||
|
本文档只包含微信登录、微信资料完善,以及微信端会共用的系统接口。
|
||||||
|
license:
|
||||||
|
name: Proprietary
|
||||||
|
identifier: LicenseRef-Proprietary
|
||||||
|
servers:
|
||||||
|
- url: https://bai-api.blv-oa.com
|
||||||
|
description: 生产环境
|
||||||
|
- url: http://localhost:8090
|
||||||
|
description: PocketBase 本地环境
|
||||||
|
tags:
|
||||||
|
- name: 系统
|
||||||
|
description: 微信端共用系统接口
|
||||||
|
- name: 微信认证
|
||||||
|
description: 微信登录、资料完善与 token 刷新接口
|
||||||
|
paths:
|
||||||
|
/pb/api/system/users-count:
|
||||||
|
post:
|
||||||
|
operationId: postSystemUsersCount
|
||||||
|
security: []
|
||||||
|
tags:
|
||||||
|
- 系统
|
||||||
|
summary: 查询用户总数
|
||||||
|
description: 统计 `tbl_auth_users` 集合中的记录总数。
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 查询成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UsersCountResponse'
|
||||||
|
'400':
|
||||||
|
description: 请求参数错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 服务端错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/pb/api/system/refresh-token:
|
||||||
|
post:
|
||||||
|
operationId: postSystemRefreshToken
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- {}
|
||||||
|
tags:
|
||||||
|
- 系统
|
||||||
|
summary: 刷新认证 token
|
||||||
|
description: |
|
||||||
|
当当前 `Authorization` 仍有效时,直接基于当前 auth 用户续签。
|
||||||
|
当 token 失效时,可传入 `users_wx_code` 走微信 code 重新签发。
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SystemRefreshTokenRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 刷新成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RefreshTokenResponse'
|
||||||
|
'400':
|
||||||
|
description: 参数错误或微信 code 换取失败
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: token 无效,且未提供有效的 `users_wx_code`
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: 当前用户不存在
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'415':
|
||||||
|
description: 请求体不是 JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: 请求过于频繁
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 服务端错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/pb/api/wechat/login:
|
||||||
|
post:
|
||||||
|
operationId: postWechatLogin
|
||||||
|
security: []
|
||||||
|
tags:
|
||||||
|
- 微信认证
|
||||||
|
summary: 微信登录或首次注册
|
||||||
|
description: |
|
||||||
|
使用微信 `users_wx_code` 换取微信 openid。
|
||||||
|
若 `tbl_auth_users` 中不存在对应用户,则自动创建新 auth 用户并返回 token。
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WechatLoginRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 登录或注册成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthSuccessResponse'
|
||||||
|
'400':
|
||||||
|
description: 参数错误或保存用户失败
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: 认证失败
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'415':
|
||||||
|
description: 请求体不是 JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: 请求过于频繁
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 服务端错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/pb/api/wechat/profile:
|
||||||
|
post:
|
||||||
|
operationId: postWechatProfile
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
tags:
|
||||||
|
- 微信认证
|
||||||
|
summary: 更新微信用户资料
|
||||||
|
description: |
|
||||||
|
基于当前 `Authorization` 对应的 auth 用户更新昵称、手机号和头像。
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WechatProfileRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 更新成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/WechatProfileResponse'
|
||||||
|
'400':
|
||||||
|
description: 参数错误、手机号已被占用或资料更新失败
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: token 无效或缺少 openid
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'415':
|
||||||
|
description: 请求体不是 JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: 请求过于频繁
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'500':
|
||||||
|
description: 服务端错误
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
schemas:
|
||||||
|
ApiResponseBase:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- msg
|
||||||
|
- data
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
example: 200
|
||||||
|
msg:
|
||||||
|
type: string
|
||||||
|
example: 操作成功
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- msg
|
||||||
|
- data
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
example: 400
|
||||||
|
msg:
|
||||||
|
type: string
|
||||||
|
example: 请求失败
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
CompanyInfo:
|
||||||
|
anyOf:
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
- type: 'null'
|
||||||
|
UserInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pb_id:
|
||||||
|
type: string
|
||||||
|
users_convers_id:
|
||||||
|
type: string
|
||||||
|
users_id:
|
||||||
|
type: string
|
||||||
|
users_idtype:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- WeChat
|
||||||
|
- ManagePlatform
|
||||||
|
users_id_number:
|
||||||
|
type: string
|
||||||
|
users_type:
|
||||||
|
type: string
|
||||||
|
users_name:
|
||||||
|
type: string
|
||||||
|
users_status:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- number
|
||||||
|
users_rank_level:
|
||||||
|
type:
|
||||||
|
- number
|
||||||
|
- integer
|
||||||
|
users_auth_type:
|
||||||
|
type:
|
||||||
|
- number
|
||||||
|
- integer
|
||||||
|
users_phone:
|
||||||
|
type: string
|
||||||
|
users_phone_masked:
|
||||||
|
type: string
|
||||||
|
users_level:
|
||||||
|
type: string
|
||||||
|
users_picture:
|
||||||
|
type: string
|
||||||
|
openid:
|
||||||
|
type: string
|
||||||
|
description: 全平台统一身份标识
|
||||||
|
company_id:
|
||||||
|
type: string
|
||||||
|
users_parent_id:
|
||||||
|
type: string
|
||||||
|
users_promo_code:
|
||||||
|
type: string
|
||||||
|
usergroups_id:
|
||||||
|
type: string
|
||||||
|
company:
|
||||||
|
$ref: '#/components/schemas/CompanyInfo'
|
||||||
|
created:
|
||||||
|
type: string
|
||||||
|
updated:
|
||||||
|
type: string
|
||||||
|
WechatLoginRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- users_wx_code
|
||||||
|
properties:
|
||||||
|
users_wx_code:
|
||||||
|
type: string
|
||||||
|
description: 微信小程序登录临时凭证 code
|
||||||
|
example: 0a1b2c3d4e5f6g
|
||||||
|
WechatProfileRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- users_name
|
||||||
|
- users_phone_code
|
||||||
|
- users_picture
|
||||||
|
properties:
|
||||||
|
users_name:
|
||||||
|
type: string
|
||||||
|
example: 张三
|
||||||
|
users_phone_code:
|
||||||
|
type: string
|
||||||
|
example: 2b7d9f2e3c4a5b6d7e8f
|
||||||
|
users_picture:
|
||||||
|
type: string
|
||||||
|
example: https://example.com/avatar.png
|
||||||
|
SystemRefreshTokenRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
users_wx_code:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
description: |
|
||||||
|
可选。
|
||||||
|
当前 token 失效时,可通过该 code 重新签发 token。
|
||||||
|
example: 0a1b2c3d4e5f6g
|
||||||
|
AuthSuccessData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- register_success
|
||||||
|
- login_success
|
||||||
|
is_info_complete:
|
||||||
|
type: boolean
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserInfo'
|
||||||
|
AuthSuccessResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiResponseBase'
|
||||||
|
- type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/AuthSuccessData'
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: PocketBase 原生 auth token
|
||||||
|
WechatProfileResponseData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- update_success
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/UserInfo'
|
||||||
|
WechatProfileResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiResponseBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/WechatProfileResponseData'
|
||||||
|
RefreshTokenResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiResponseBase'
|
||||||
|
- type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
example: {}
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: 新签发的 PocketBase 原生 auth token
|
||||||
|
UsersCountData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total_users:
|
||||||
|
type: integer
|
||||||
|
example: 128
|
||||||
|
UsersCountResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ApiResponseBase'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/UsersCountData'
|
||||||
@@ -292,6 +292,17 @@ components:
|
|||||||
description:
|
description:
|
||||||
type: string
|
type: string
|
||||||
example: 启用
|
example: 启用
|
||||||
|
image:
|
||||||
|
type: string
|
||||||
|
description: 对应图片附件的 `attachments_id`,允许为空
|
||||||
|
example: ATT-1743037200000-abc123
|
||||||
|
imageUrl:
|
||||||
|
type: string
|
||||||
|
description: 根据 `image -> tbl_attachments` 自动解析出的图片文件流链接
|
||||||
|
imageAttachment:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/AttachmentRecord'
|
||||||
|
nullable: true
|
||||||
sortOrder:
|
sortOrder:
|
||||||
type: integer
|
type: integer
|
||||||
example: 1
|
example: 1
|
||||||
@@ -355,6 +366,7 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
minItems: 1
|
minItems: 1
|
||||||
|
description: 每项会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order`
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/DictionaryItem'
|
$ref: '#/components/schemas/DictionaryItem'
|
||||||
DictionaryDeleteRequest:
|
DictionaryDeleteRequest:
|
||||||
@@ -457,6 +469,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_type:
|
document_type:
|
||||||
type: string
|
type: string
|
||||||
|
description: 多选时按 `system_dict_id@dict_word_enum|...` 保存
|
||||||
document_title:
|
document_title:
|
||||||
type: string
|
type: string
|
||||||
document_subtitle:
|
document_subtitle:
|
||||||
@@ -520,7 +533,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_keywords:
|
document_keywords:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 固定字典多选字段,使用 `|` 分隔
|
||||||
document_share_count:
|
document_share_count:
|
||||||
type: number
|
type: number
|
||||||
document_download_count:
|
document_download_count:
|
||||||
@@ -529,6 +542,7 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
document_status:
|
document_status:
|
||||||
type: string
|
type: string
|
||||||
|
description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`
|
||||||
document_embedding_status:
|
document_embedding_status:
|
||||||
type: string
|
type: string
|
||||||
document_embedding_error:
|
document_embedding_error:
|
||||||
@@ -539,13 +553,13 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_product_categories:
|
document_product_categories:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 固定字典多选字段,使用 `|` 分隔
|
||||||
document_application_scenarios:
|
document_application_scenarios:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 固定字典多选字段,使用 `|` 分隔
|
||||||
document_hotel_type:
|
document_hotel_type:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 固定字典多选字段,使用 `|` 分隔
|
||||||
document_remark:
|
document_remark:
|
||||||
type: string
|
type: string
|
||||||
created:
|
created:
|
||||||
@@ -564,6 +578,7 @@ components:
|
|||||||
example: active
|
example: active
|
||||||
document_type:
|
document_type:
|
||||||
type: string
|
type: string
|
||||||
|
description: 支持按存储值过滤;多选时格式为 `system_dict_id@dict_word_enum|...`
|
||||||
example: 说明书
|
example: 说明书
|
||||||
DocumentDetailRequest:
|
DocumentDetailRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -590,6 +605,7 @@ components:
|
|||||||
example: 2027-03-27
|
example: 2027-03-27
|
||||||
document_type:
|
document_type:
|
||||||
type: string
|
type: string
|
||||||
|
description: 必填;前端显示为字典项描述,存库时按 `system_dict_id@dict_word_enum|...` 保存
|
||||||
document_title:
|
document_title:
|
||||||
type: string
|
type: string
|
||||||
document_subtitle:
|
document_subtitle:
|
||||||
@@ -618,7 +634,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_keywords:
|
document_keywords:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 从 `文档-关键词` 字典多选后使用 `|` 分隔保存
|
||||||
document_share_count:
|
document_share_count:
|
||||||
type: number
|
type: number
|
||||||
document_download_count:
|
document_download_count:
|
||||||
@@ -627,6 +643,7 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
document_status:
|
document_status:
|
||||||
type: string
|
type: string
|
||||||
|
description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`
|
||||||
document_embedding_status:
|
document_embedding_status:
|
||||||
type: string
|
type: string
|
||||||
document_embedding_error:
|
document_embedding_error:
|
||||||
@@ -637,13 +654,13 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_product_categories:
|
document_product_categories:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 从 `文档-产品关联文档` 字典多选后使用 `|` 分隔保存
|
||||||
document_application_scenarios:
|
document_application_scenarios:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 从 `文档-筛选依据` 字典多选后使用 `|` 分隔保存
|
||||||
document_hotel_type:
|
document_hotel_type:
|
||||||
type: string
|
type: string
|
||||||
description: 多值字段,使用 `|` 分隔
|
description: 从 `文档-适用场景` 字典多选后使用 `|` 分隔保存
|
||||||
document_remark:
|
document_remark:
|
||||||
type: string
|
type: string
|
||||||
DocumentDeleteRequest:
|
DocumentDeleteRequest:
|
||||||
@@ -1005,7 +1022,7 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
仅允许 `ManagePlatform` 用户访问。
|
仅允许 `ManagePlatform` 用户访问。
|
||||||
`system_dict_id` 由服务端自动生成;`dict_name` 必须唯一;
|
`system_dict_id` 由服务端自动生成;`dict_name` 必须唯一;
|
||||||
`items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 三个字段。
|
`items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 四个字段。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"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"
|
"init:documents": "node pocketbase.documents.js",
|
||||||
|
"init:dictionary": "node pocketbase.dictionary.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
195
script/pocketbase.dictionary.js
Normal file
195
script/pocketbase.dictionary.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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 collectionData = {
|
||||||
|
name: 'tbl_system_dict',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'system_dict_id', type: 'text', required: true },
|
||||||
|
{ name: 'dict_name', type: 'text', required: true },
|
||||||
|
{ name: 'dict_word_enum', type: 'text' },
|
||||||
|
{ name: 'dict_word_description', type: 'text' },
|
||||||
|
{ name: 'dict_word_image', type: 'text' },
|
||||||
|
{ name: 'dict_word_is_enabled', type: 'bool' },
|
||||||
|
{ name: 'dict_word_sort_order', type: 'text' },
|
||||||
|
{ name: 'dict_word_parent_id', type: 'text' },
|
||||||
|
{ name: 'dict_word_remark', type: 'text' },
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
'CREATE UNIQUE INDEX idx_system_dict_id ON tbl_system_dict (system_dict_id)',
|
||||||
|
'CREATE UNIQUE INDEX idx_dict_name ON tbl_system_dict (dict_name)',
|
||||||
|
'CREATE INDEX idx_dict_word_parent_id ON tbl_system_dict (dict_word_parent_id)',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCollectionPayload(target, existingCollection) {
|
||||||
|
if (!existingCollection) {
|
||||||
|
return {
|
||||||
|
name: target.name,
|
||||||
|
type: target.type,
|
||||||
|
fields: target.fields.map((field) => normalizeFieldPayload(field, null)),
|
||||||
|
indexes: target.indexes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetFieldMap = new Map(target.fields.map((field) => [field.name, field]));
|
||||||
|
const fields = (existingCollection.fields || []).map((existingField) => {
|
||||||
|
const nextField = targetFieldMap.get(existingField.name);
|
||||||
|
if (!nextField) {
|
||||||
|
return existingField;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFieldMap.delete(existingField.name);
|
||||||
|
return normalizeFieldPayload(nextField, existingField);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const field of targetFieldMap.values()) {
|
||||||
|
fields.push(normalizeFieldPayload(field, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: target.name,
|
||||||
|
type: target.type,
|
||||||
|
fields: fields,
|
||||||
|
indexes: target.indexes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFieldList(fields) {
|
||||||
|
return (fields || []).map((field) => ({
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
required: !!field.required,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrUpdateCollection(target) {
|
||||||
|
console.log(`🔄 正在处理表: ${target.name} ...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await pb.collections.getOne(target.name);
|
||||||
|
await pb.collections.update(existing.id, buildCollectionPayload(target, existing));
|
||||||
|
console.log(`♻️ ${target.name} 已存在,已按最新结构更新。`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
await pb.collections.create(buildCollectionPayload(target, null));
|
||||||
|
console.log(`✅ ${target.name} 创建完成。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`❌ 处理集合 ${target.name} 失败:`, {
|
||||||
|
status: error.status,
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCollection(target) {
|
||||||
|
console.log('\n🔍 开始校验字典表结构与索引...');
|
||||||
|
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 remoteRequiredMap = new Map(remoteFields.map((field) => [field.name, field.required]));
|
||||||
|
const missingFields = [];
|
||||||
|
const mismatchedTypes = [];
|
||||||
|
const mismatchedRequired = [];
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteRequiredMap.get(field.name) !== !!field.required) {
|
||||||
|
mismatchedRequired.push(`${field.name}:${remoteRequiredMap.get(field.name)}!=${!!field.required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteIndexes = new Set(remote.indexes || []);
|
||||||
|
const missingIndexes = target.indexes.filter((indexSql) => !remoteIndexes.has(indexSql));
|
||||||
|
|
||||||
|
if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) {
|
||||||
|
console.log(`✅ ${target.name} 校验通过。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingFields.length) {
|
||||||
|
console.log(` - 缺失字段: ${missingFields.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (mismatchedTypes.length) {
|
||||||
|
console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (mismatchedRequired.length) {
|
||||||
|
console.log(` - 字段必填属性不匹配: ${mismatchedRequired.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 载入认证状态。');
|
||||||
|
|
||||||
|
await createOrUpdateCollection(collectionData);
|
||||||
|
await verifyCollection(collectionData);
|
||||||
|
console.log('\n🎉 字典表结构初始化并校验完成!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 初始化失败:', error.response?.data || error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -26,7 +26,7 @@ const collections = [
|
|||||||
type: 'base',
|
type: 'base',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'attachments_id', type: 'text', required: true },
|
{ name: 'attachments_id', type: 'text', required: true },
|
||||||
{ name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 104857600, mimeTypes: [] },
|
{ name: 'attachments_link', type: 'file', maxSelect: 0, maxSize: 4294967296, mimeTypes: [] },
|
||||||
{ name: 'attachments_filename', type: 'text' },
|
{ name: 'attachments_filename', type: 'text' },
|
||||||
{ name: 'attachments_filetype', type: 'text' },
|
{ name: 'attachments_filetype', type: 'text' },
|
||||||
{ name: 'attachments_size', type: 'number' },
|
{ name: 'attachments_size', type: 'number' },
|
||||||
|
|||||||
Reference in New Issue
Block a user