feat: 更新 PocketBase API 路由,增强认证安全性
- 修改登录路由为 `/pb/api/wechat/login`,新增局部 try/catch 以保留业务状态码并返回统一结构 `{ code, msg, data }`。
- 更新所有相关 API 路由前缀为 `/pb`,包括系统、平台、字典、附件和文档接口。
- 新增文档接口支持多个附件 ID,更新 OpenAPI 文档以反映这些变化。
- 在数据库模式中添加对字典名称的唯一索引,确保全局唯一性。
- 更新文档字段,设置 `document_title` 和 `document_type` 为必填项,并在验证过程中检查字段的必填属性。
This commit is contained in:
@@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
补充约定:
|
补充约定:
|
||||||
|
|
||||||
- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件。
|
- `document_image`、`document_video` 只保存关联的 `attachments_id`,不直接存文件;当存在多个附件时,统一使用 `|` 分隔,例如:`ATT-001|ATT-002|ATT-003`。
|
||||||
- `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_owner` 的业务含义为“上传者openid”。
|
- `document_owner` 的业务含义为“上传者openid”。
|
||||||
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
|
- `attachments_link` 是 PocketBase `file` 字段,保存附件本体;访问链接由 PocketBase 根据记录和文件名生成。
|
||||||
|
- 文档字段中,面向用户填写的字段里只有 `document_title`、`document_type` 设为必填,其余字段均允许为空。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -45,13 +46,13 @@
|
|||||||
| 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 | 文档类型,必填 |
|
||||||
| document_title | text | 文档标题 |
|
| document_title | text | 文档标题,必填 |
|
||||||
| document_subtitle | text | 文档副标题 |
|
| document_subtitle | text | 文档副标题 |
|
||||||
| document_summary | text | 文档摘要 |
|
| document_summary | text | 文档摘要 |
|
||||||
| document_content | text | 正文内容,保存 Markdown 原文 |
|
| document_content | text | 正文内容,保存 Markdown 原文 |
|
||||||
| document_image | text | 关联 `attachments_id` |
|
| document_image | text | 关联多个 `attachments_id`,使用 `|` 分隔 |
|
||||||
| 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 | 关键词,竖线分隔 |
|
||||||
|
|||||||
@@ -226,6 +226,35 @@ function validateDocumentDetailBody(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentIdList(value, fieldName) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map(function (item) {
|
||||||
|
return String(item || '').trim()
|
||||||
|
})
|
||||||
|
.filter(function (item) {
|
||||||
|
return !!item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'undefined' || value === null || value === '') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw createAppError(400, fieldName + ' 类型错误,需为字符串或数组')
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split('|')
|
||||||
|
.map(function (item) {
|
||||||
|
return String(item || '').trim()
|
||||||
|
})
|
||||||
|
.filter(function (item) {
|
||||||
|
return !!item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function validateDocumentMutationBody(e, isUpdate) {
|
function validateDocumentMutationBody(e, isUpdate) {
|
||||||
const payload = parseBody(e)
|
const payload = parseBody(e)
|
||||||
|
|
||||||
@@ -233,6 +262,14 @@ function validateDocumentMutationBody(e, isUpdate) {
|
|||||||
throw createAppError(400, 'document_id 为必填项')
|
throw createAppError(400, 'document_id 为必填项')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!payload.document_title) {
|
||||||
|
throw createAppError(400, 'document_title 为必填项')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.document_type) {
|
||||||
|
throw createAppError(400, 'document_type 为必填项')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document_id: payload.document_id || '',
|
document_id: payload.document_id || '',
|
||||||
document_effect_date: payload.document_effect_date || '',
|
document_effect_date: payload.document_effect_date || '',
|
||||||
@@ -242,13 +279,13 @@ function validateDocumentMutationBody(e, isUpdate) {
|
|||||||
document_subtitle: payload.document_subtitle || '',
|
document_subtitle: payload.document_subtitle || '',
|
||||||
document_summary: payload.document_summary || '',
|
document_summary: payload.document_summary || '',
|
||||||
document_content: payload.document_content || '',
|
document_content: payload.document_content || '',
|
||||||
document_image: payload.document_image || '',
|
document_image: normalizeAttachmentIdList(payload.document_image, 'document_image'),
|
||||||
document_video: payload.document_video || '',
|
document_video: normalizeAttachmentIdList(payload.document_video, 'document_video'),
|
||||||
document_relation_model: payload.document_relation_model || '',
|
document_relation_model: payload.document_relation_model || '',
|
||||||
document_keywords: payload.document_keywords || '',
|
document_keywords: payload.document_keywords || '',
|
||||||
document_share_count: payload.document_share_count || 0,
|
document_share_count: typeof payload.document_share_count === 'undefined' ? '' : payload.document_share_count,
|
||||||
document_download_count: payload.document_download_count || 0,
|
document_download_count: typeof payload.document_download_count === 'undefined' ? '' : payload.document_download_count,
|
||||||
document_favorite_count: payload.document_favorite_count || 0,
|
document_favorite_count: typeof payload.document_favorite_count === 'undefined' ? '' : payload.document_favorite_count,
|
||||||
document_status: payload.document_status || '',
|
document_status: payload.document_status || '',
|
||||||
document_embedding_status: payload.document_embedding_status || '',
|
document_embedding_status: payload.document_embedding_status || '',
|
||||||
document_embedding_error: payload.document_embedding_error || '',
|
document_embedding_error: payload.document_embedding_error || '',
|
||||||
|
|||||||
@@ -52,6 +52,45 @@ function normalizeNumberValue(value, fieldName) {
|
|||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalNumberValue(value, fieldName) {
|
||||||
|
if (value === '' || value === null || typeof value === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeNumberValue(value, fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttachmentIdList(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const result = []
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
const current = String(value[i] || '').replace(/^\s+|\s+$/g, '')
|
||||||
|
if (current && result.indexOf(current) === -1) {
|
||||||
|
result.push(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(value || '').replace(/^\s+|\s+$/g, '')
|
||||||
|
if (!text) return []
|
||||||
|
|
||||||
|
const parts = text.split('|')
|
||||||
|
const result = []
|
||||||
|
for (let i = 0; i < parts.length; i += 1) {
|
||||||
|
const current = String(parts[i] || '').replace(/^\s+|\s+$/g, '')
|
||||||
|
if (current && result.indexOf(current) === -1) {
|
||||||
|
result.push(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAttachmentIdList(value) {
|
||||||
|
return parseAttachmentIdList(value).join('|')
|
||||||
|
}
|
||||||
|
|
||||||
function exportAttachmentRecord(record) {
|
function exportAttachmentRecord(record) {
|
||||||
const storedFilename = record.getString('attachments_link')
|
const storedFilename = record.getString('attachments_link')
|
||||||
|
|
||||||
@@ -84,13 +123,34 @@ function findAttachmentRecordByAttachmentId(attachmentId) {
|
|||||||
return records.length ? records[0] : null
|
return records.length ? records[0] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAttachmentList(value) {
|
||||||
|
const ids = parseAttachmentIdList(value)
|
||||||
|
const attachments = []
|
||||||
|
const urls = []
|
||||||
|
|
||||||
|
for (let i = 0; i < ids.length; i += 1) {
|
||||||
|
const currentRecord = findAttachmentRecordByAttachmentId(ids[i])
|
||||||
|
if (!currentRecord) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const exported = exportAttachmentRecord(currentRecord)
|
||||||
|
attachments.push(exported)
|
||||||
|
urls.push(exported.attachments_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ids: ids,
|
||||||
|
attachments: attachments,
|
||||||
|
urls: urls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function exportDocumentRecord(record) {
|
function exportDocumentRecord(record) {
|
||||||
const imageAttachmentId = record.getString('document_image')
|
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
|
||||||
const videoAttachmentId = record.getString('document_video')
|
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
|
||||||
const imageAttachmentRecord = findAttachmentRecordByAttachmentId(imageAttachmentId)
|
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
|
||||||
const videoAttachmentRecord = findAttachmentRecordByAttachmentId(videoAttachmentId)
|
const firstVideoAttachment = videoAttachmentList.attachments.length ? videoAttachmentList.attachments[0] : null
|
||||||
const imageAttachment = imageAttachmentRecord ? exportAttachmentRecord(imageAttachmentRecord) : null
|
|
||||||
const videoAttachment = videoAttachmentRecord ? exportAttachmentRecord(videoAttachmentRecord) : null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pb_id: record.id,
|
pb_id: record.id,
|
||||||
@@ -102,12 +162,18 @@ function exportDocumentRecord(record) {
|
|||||||
document_subtitle: record.getString('document_subtitle'),
|
document_subtitle: record.getString('document_subtitle'),
|
||||||
document_summary: record.getString('document_summary'),
|
document_summary: record.getString('document_summary'),
|
||||||
document_content: record.getString('document_content'),
|
document_content: record.getString('document_content'),
|
||||||
document_image: imageAttachmentId,
|
document_image: imageAttachmentList.ids.join('|'),
|
||||||
document_image_url: imageAttachment ? imageAttachment.attachments_url : '',
|
document_image_ids: imageAttachmentList.ids,
|
||||||
document_image_attachment: imageAttachment,
|
document_image_urls: imageAttachmentList.urls,
|
||||||
document_video: videoAttachmentId,
|
document_image_attachments: imageAttachmentList.attachments,
|
||||||
document_video_url: videoAttachment ? videoAttachment.attachments_url : '',
|
document_image_url: firstImageAttachment ? firstImageAttachment.attachments_url : '',
|
||||||
document_video_attachment: videoAttachment,
|
document_image_attachment: firstImageAttachment,
|
||||||
|
document_video: videoAttachmentList.ids.join('|'),
|
||||||
|
document_video_ids: videoAttachmentList.ids,
|
||||||
|
document_video_urls: videoAttachmentList.urls,
|
||||||
|
document_video_attachments: videoAttachmentList.attachments,
|
||||||
|
document_video_url: firstVideoAttachment ? firstVideoAttachment.attachments_url : '',
|
||||||
|
document_video_attachment: firstVideoAttachment,
|
||||||
document_owner: record.getString('document_owner'),
|
document_owner: record.getString('document_owner'),
|
||||||
document_relation_model: record.getString('document_relation_model'),
|
document_relation_model: record.getString('document_relation_model'),
|
||||||
document_keywords: record.getString('document_keywords'),
|
document_keywords: record.getString('document_keywords'),
|
||||||
@@ -164,12 +230,14 @@ function createHistoryRecord(txApp, payload) {
|
|||||||
txApp.save(record)
|
txApp.save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAttachmentExists(attachmentId, fieldName) {
|
function ensureAttachmentIdsExist(value, fieldName) {
|
||||||
if (!attachmentId) return
|
const attachmentIds = parseAttachmentIdList(value)
|
||||||
|
|
||||||
const record = findAttachmentRecordByAttachmentId(attachmentId)
|
for (let i = 0; i < attachmentIds.length; i += 1) {
|
||||||
|
const record = findAttachmentRecordByAttachmentId(attachmentIds[i])
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw createAppError(400, fieldName + ' 对应的附件不存在')
|
throw createAppError(400, fieldName + ' 中存在不存在的附件:' + attachmentIds[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +313,16 @@ function deleteAttachment(attachmentId) {
|
|||||||
throw createAppError(404, '未找到待删除的附件')
|
throw createAppError(404, '未找到待删除的附件')
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotedAttachmentId = '"' + attachmentId.replace(/"/g, '\\"') + '"'
|
const documentRecords = $app.findRecordsByFilter('tbl_document', '', '', 500, 0)
|
||||||
const usedByDocument = $app.findRecordsByFilter('tbl_document', 'document_image = ' + quotedAttachmentId + ' || document_video = ' + quotedAttachmentId, '', 1, 0)
|
for (let i = 0; i < documentRecords.length; i += 1) {
|
||||||
if (usedByDocument.length) {
|
const current = documentRecords[i]
|
||||||
|
const imageIds = parseAttachmentIdList(current.getString('document_image'))
|
||||||
|
const videoIds = parseAttachmentIdList(current.getString('document_video'))
|
||||||
|
|
||||||
|
if (imageIds.indexOf(attachmentId) !== -1 || videoIds.indexOf(attachmentId) !== -1) {
|
||||||
throw createAppError(400, '附件已被文档引用,无法删除')
|
throw createAppError(400, '附件已被文档引用,无法删除')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$app.delete(record)
|
$app.delete(record)
|
||||||
@@ -309,8 +382,8 @@ function getDocumentDetail(documentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createDocument(userOpenid, payload) {
|
function createDocument(userOpenid, payload) {
|
||||||
ensureAttachmentExists(payload.document_image, 'document_image')
|
ensureAttachmentIdsExist(payload.document_image, 'document_image')
|
||||||
ensureAttachmentExists(payload.document_video, 'document_video')
|
ensureAttachmentIdsExist(payload.document_video, 'document_video')
|
||||||
|
|
||||||
const targetDocumentId = payload.document_id || buildBusinessId('DOC')
|
const targetDocumentId = payload.document_id || buildBusinessId('DOC')
|
||||||
const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
|
const duplicated = findDocumentRecordByDocumentId(targetDocumentId)
|
||||||
@@ -330,16 +403,16 @@ function createDocument(userOpenid, payload) {
|
|||||||
record.set('document_subtitle', payload.document_subtitle || '')
|
record.set('document_subtitle', payload.document_subtitle || '')
|
||||||
record.set('document_summary', payload.document_summary || '')
|
record.set('document_summary', payload.document_summary || '')
|
||||||
record.set('document_content', payload.document_content || '')
|
record.set('document_content', payload.document_content || '')
|
||||||
record.set('document_image', payload.document_image || '')
|
record.set('document_image', serializeAttachmentIdList(payload.document_image))
|
||||||
record.set('document_video', payload.document_video || '')
|
record.set('document_video', serializeAttachmentIdList(payload.document_video))
|
||||||
record.set('document_owner', userOpenid || '')
|
record.set('document_owner', userOpenid || '')
|
||||||
record.set('document_relation_model', payload.document_relation_model || '')
|
record.set('document_relation_model', payload.document_relation_model || '')
|
||||||
record.set('document_keywords', payload.document_keywords || '')
|
record.set('document_keywords', payload.document_keywords || '')
|
||||||
record.set('document_share_count', normalizeNumberValue(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', normalizeNumberValue(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', normalizeNumberValue(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 || 'active')
|
record.set('document_status', payload.document_status || '')
|
||||||
record.set('document_embedding_status', payload.document_embedding_status || 'pending')
|
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))
|
||||||
record.set('document_vector_version', payload.document_vector_version || '')
|
record.set('document_vector_version', payload.document_vector_version || '')
|
||||||
@@ -368,8 +441,8 @@ function updateDocument(userOpenid, payload) {
|
|||||||
throw createAppError(404, '未找到待修改的文档')
|
throw createAppError(404, '未找到待修改的文档')
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAttachmentExists(payload.document_image, 'document_image')
|
ensureAttachmentIdsExist(payload.document_image, 'document_image')
|
||||||
ensureAttachmentExists(payload.document_video, 'document_video')
|
ensureAttachmentIdsExist(payload.document_video, 'document_video')
|
||||||
|
|
||||||
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)
|
||||||
@@ -381,13 +454,13 @@ function updateDocument(userOpenid, payload) {
|
|||||||
target.set('document_subtitle', payload.document_subtitle || '')
|
target.set('document_subtitle', payload.document_subtitle || '')
|
||||||
target.set('document_summary', payload.document_summary || '')
|
target.set('document_summary', payload.document_summary || '')
|
||||||
target.set('document_content', payload.document_content || '')
|
target.set('document_content', payload.document_content || '')
|
||||||
target.set('document_image', payload.document_image || '')
|
target.set('document_image', serializeAttachmentIdList(payload.document_image))
|
||||||
target.set('document_video', payload.document_video || '')
|
target.set('document_video', serializeAttachmentIdList(payload.document_video))
|
||||||
target.set('document_relation_model', payload.document_relation_model || '')
|
target.set('document_relation_model', payload.document_relation_model || '')
|
||||||
target.set('document_keywords', payload.document_keywords || '')
|
target.set('document_keywords', payload.document_keywords || '')
|
||||||
target.set('document_share_count', normalizeNumberValue(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', normalizeNumberValue(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', normalizeNumberValue(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', payload.document_status || '')
|
||||||
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 || '')
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input id="keywordInput" placeholder="按 dict_name 模糊搜索" />
|
<input id="keywordInput" placeholder="按字典名称模糊搜索" />
|
||||||
<input id="detailInput" placeholder="查询指定 dict_name" />
|
<input id="detailInput" placeholder="查询指定字典名称" />
|
||||||
<button class="btn btn-secondary" id="listBtn" type="button">查询全部</button>
|
<button class="btn btn-secondary" id="listBtn" type="button">查询全部</button>
|
||||||
<button class="btn btn-light" id="detailBtn" type="button">查询指定</button>
|
<button class="btn btn-light" id="detailBtn" type="button">查询指定</button>
|
||||||
<button class="btn btn-light" id="resetBtn" type="button">重置</button>
|
<button class="btn btn-light" id="resetBtn" type="button">重置</button>
|
||||||
@@ -93,10 +93,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>dict_name</th>
|
<th>字典名称</th>
|
||||||
<th>启用</th>
|
<th>启用</th>
|
||||||
<th>备注</th>
|
<th>备注</th>
|
||||||
<th>parent_id</th>
|
|
||||||
<th>枚举项</th>
|
<th>枚举项</th>
|
||||||
<th>创建时间</th>
|
<th>创建时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@@ -121,23 +120,19 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-grid">
|
<div class="modal-grid">
|
||||||
<div>
|
<div>
|
||||||
<label>dict_name</label>
|
<label>字典名称</label>
|
||||||
<input id="dictNameInput" />
|
<input id="dictNameInput" placeholder="请输入字典名称" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>dict_word_parent_id</label>
|
<label>是否启用</label>
|
||||||
<input id="parentIdInput" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>dict_word_is_enabled</label>
|
|
||||||
<select id="enabledInput">
|
<select id="enabledInput">
|
||||||
<option value="true">启用</option>
|
<option value="true">启用</option>
|
||||||
<option value="false">禁用</option>
|
<option value="false">禁用</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label>dict_word_remark</label>
|
<label>备注说明</label>
|
||||||
<textarea id="remarkInput"></textarea>
|
<textarea id="remarkInput" placeholder="请输入备注说明"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,7 +179,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
const editorModal = document.getElementById('editorModal')
|
const editorModal = document.getElementById('editorModal')
|
||||||
const modalTitle = document.getElementById('modalTitle')
|
const modalTitle = document.getElementById('modalTitle')
|
||||||
const dictNameInput = document.getElementById('dictNameInput')
|
const dictNameInput = document.getElementById('dictNameInput')
|
||||||
const parentIdInput = document.getElementById('parentIdInput')
|
|
||||||
const enabledInput = document.getElementById('enabledInput')
|
const enabledInput = document.getElementById('enabledInput')
|
||||||
const remarkInput = document.getElementById('remarkInput')
|
const remarkInput = document.getElementById('remarkInput')
|
||||||
const itemsBody = document.getElementById('itemsBody')
|
const itemsBody = document.getElementById('itemsBody')
|
||||||
@@ -256,10 +250,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
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 ? '启用' : '禁用'
|
||||||
return '<tr>'
|
return '<tr>'
|
||||||
+ '<td data-label="dict_name"><input class="inline-input" data-field="dict_name" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_name) + '" /></td>'
|
+ '<td data-label="字典名称"><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="parent_id"><input class="inline-input" data-field="dict_word_parent_id" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_word_parent_id) + '" /></td>'
|
|
||||||
+ '<td data-label="枚举项">' + renderItemsPreview(item.items) + '</td>'
|
+ '<td data-label="枚举项">' + renderItemsPreview(item.items) + '</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="操作">'
|
||||||
@@ -278,7 +271,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
state.editingOriginalName = record ? record.dict_name : ''
|
state.editingOriginalName = record ? record.dict_name : ''
|
||||||
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
|
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
|
||||||
dictNameInput.value = record ? record.dict_name : ''
|
dictNameInput.value = record ? record.dict_name : ''
|
||||||
parentIdInput.value = record ? (record.dict_word_parent_id || '') : ''
|
|
||||||
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
|
||||||
@@ -352,7 +344,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
dict_name: dictNameInput.value.trim(),
|
dict_name: dictNameInput.value.trim(),
|
||||||
original_dict_name: state.editingOriginalName,
|
original_dict_name: state.editingOriginalName,
|
||||||
dict_word_parent_id: parentIdInput.value.trim(),
|
dict_word_parent_id: '',
|
||||||
dict_word_is_enabled: enabledInput.value === 'true',
|
dict_word_is_enabled: enabledInput.value === 'true',
|
||||||
dict_word_remark: remarkInput.value.trim(),
|
dict_word_remark: remarkInput.value.trim(),
|
||||||
items: items,
|
items: items,
|
||||||
@@ -386,7 +378,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
|
|||||||
dict_name: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_name' }) || {}).value || targetName,
|
dict_name: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_name' }) || {}).value || targetName,
|
||||||
dict_word_is_enabled: ((row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_is_enabled' }) || {}).value || 'true') === 'true',
|
dict_word_is_enabled: ((row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_is_enabled' }) || {}).value || 'true') === 'true',
|
||||||
dict_word_remark: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_remark' }) || {}).value || '',
|
dict_word_remark: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_remark' }) || {}).value || '',
|
||||||
dict_word_parent_id: (row.find(function (el) { return el.getAttribute('data-field') === 'dict_word_parent_id' }) || {}).value || '',
|
dict_word_parent_id: '',
|
||||||
items: record.items || [],
|
items: record.items || [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
.panel + .panel { margin-top: 24px; }
|
.panel + .panel { margin-top: 24px; }
|
||||||
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 { display: flex; flex-wrap: wrap; gap: 12px; }
|
.actions, .form-actions, .toolbar, .table-actions, .file-actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.btn-primary { background: #2563eb; color: #fff; }
|
.btn-primary { background: #2563eb; color: #fff; }
|
||||||
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
|
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
|
||||||
@@ -43,9 +43,23 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
tr:hover td { background: #f8fafc; }
|
tr:hover td { background: #f8fafc; }
|
||||||
.empty { text-align: center; padding: 24px 16px; color: #64748b; }
|
.empty { text-align: center; padding: 24px 16px; color: #64748b; }
|
||||||
.muted { color: #64748b; font-size: 12px; }
|
.muted { color: #64748b; font-size: 12px; }
|
||||||
.doc-links a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; }
|
.doc-links a, .file-card a { display: inline-block; margin-right: 10px; color: #2563eb; text-decoration: none; font-weight: 600; }
|
||||||
|
.btn-warning { background: #f59e0b; color: #fff; }
|
||||||
|
.editor-banner { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 13px; font-weight: 700; }
|
||||||
|
.file-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
|
||||||
|
.file-box { border: 1px solid #dbe3f0; border-radius: 18px; padding: 16px; background: #f8fbff; }
|
||||||
|
.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-title { font-weight: 700; color: #1e3a8a; }
|
||||||
|
.dropzone-text { color: #475569; font-size: 13px; margin-top: 6px; }
|
||||||
|
.dropzone input[type="file"] { margin-top: 12px; }
|
||||||
|
.file-list { display: grid; gap: 10px; margin-top: 12px; }
|
||||||
|
.file-card { border: 1px solid #dbe3f0; border-radius: 14px; padding: 12px; background: #fff; }
|
||||||
|
.file-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.file-card-title { font-weight: 700; word-break: break-all; }
|
||||||
|
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid, .file-group { grid-template-columns: 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; }
|
||||||
@@ -58,19 +72,26 @@ 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>tbl_attachments</code>,然后把返回的 <code>attachments_id</code> 写入 <code>tbl_document.document_image</code> 或 <code>document_video</code>。文档列表会直接显示 PocketBase 文件流链接。</p>
|
<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>
|
<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-danger" id="logoutBtn" type="button">退出登录</button>
|
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>新增文档</h2>
|
<div class="toolbar" style="justify-content:space-between;align-items:center;">
|
||||||
<div class="grid">
|
<div>
|
||||||
|
<h2 id="formTitle">新增文档</h2>
|
||||||
|
<div class="muted">只有文档标题和文档类型为必填,其余字段允许为空。</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-banner" id="editorMode">当前模式:新建</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" style="margin-top:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="documentTitle">文档标题</label>
|
<label for="documentTitle">文档标题</label>
|
||||||
<input id="documentTitle" placeholder="请输入文档标题" />
|
<input id="documentTitle" placeholder="请输入文档标题" />
|
||||||
@@ -81,11 +102,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="documentStatus">文档状态</label>
|
<label for="documentStatus">文档状态</label>
|
||||||
<input id="documentStatus" placeholder="默认 active" value="active" />
|
<input id="documentStatus" placeholder="可为空" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="embeddingStatus">嵌入状态</label>
|
<label for="embeddingStatus">嵌入状态</label>
|
||||||
<input id="embeddingStatus" placeholder="默认 pending" value="pending" />
|
<input id="embeddingStatus" placeholder="可为空" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="effectDate">生效日期</label>
|
<label for="effectDate">生效日期</label>
|
||||||
@@ -136,17 +157,34 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
<label for="documentRemark">备注</label>
|
<label for="documentRemark">备注</label>
|
||||||
<textarea id="documentRemark" placeholder="可选"></textarea>
|
<textarea id="documentRemark" placeholder="可选"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="imageFile">图片附件</label>
|
|
||||||
<input id="imageFile" type="file" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="file-group">
|
||||||
<label for="videoFile">视频附件</label>
|
<div class="file-box">
|
||||||
<input id="videoFile" type="file" />
|
<h3>图片附件</h3>
|
||||||
|
<label for="imageFile">新增图片</label>
|
||||||
|
<div class="dropzone" id="imageDropzone">
|
||||||
|
<div class="dropzone-title">拖拽图片到这里,或点击选择文件</div>
|
||||||
|
<div class="dropzone-text">支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。</div>
|
||||||
|
<input id="imageFile" type="file" multiple />
|
||||||
|
</div>
|
||||||
|
<div class="file-list" id="imageCurrentList"></div>
|
||||||
|
<div class="file-list" id="imagePendingList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-box">
|
||||||
|
<h3>视频附件</h3>
|
||||||
|
<label for="videoFile">新增视频</label>
|
||||||
|
<div class="dropzone" id="videoDropzone">
|
||||||
|
<div class="dropzone-title">拖拽视频到这里,或点击选择文件</div>
|
||||||
|
<div class="dropzone-text">支持从 Windows 文件夹直接拖入;选择后先进入待上传区,保存文档时才会真正上传。</div>
|
||||||
|
<input id="videoFile" type="file" multiple />
|
||||||
|
</div>
|
||||||
|
<div class="file-list" id="videoCurrentList"></div>
|
||||||
|
<div class="file-list" id="videoPendingList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" style="margin-top:16px;">
|
<div class="form-actions" style="margin-top:16px;">
|
||||||
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
|
<button class="btn btn-primary" id="submitBtn" type="button">上传附件并创建文档</button>
|
||||||
|
<button class="btn btn-warning" id="cancelEditBtn" type="button">取消编辑</button>
|
||||||
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
|
<button class="btn btn-light" id="resetBtn" type="button">重置表单</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -175,8 +213,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const tokenKey = 'pb_manage_token'
|
const tokenKey = 'pb_manage_token'
|
||||||
|
const API_BASE = '/pb/api'
|
||||||
const statusEl = document.getElementById('status')
|
const statusEl = document.getElementById('status')
|
||||||
const tableBody = document.getElementById('tableBody')
|
const tableBody = document.getElementById('tableBody')
|
||||||
|
const formTitleEl = document.getElementById('formTitle')
|
||||||
|
const editorModeEl = document.getElementById('editorMode')
|
||||||
|
const imageCurrentListEl = document.getElementById('imageCurrentList')
|
||||||
|
const imagePendingListEl = document.getElementById('imagePendingList')
|
||||||
|
const videoCurrentListEl = document.getElementById('videoCurrentList')
|
||||||
|
const videoPendingListEl = document.getElementById('videoPendingList')
|
||||||
|
const imageDropzoneEl = document.getElementById('imageDropzone')
|
||||||
|
const videoDropzoneEl = document.getElementById('videoDropzone')
|
||||||
const fields = {
|
const fields = {
|
||||||
documentTitle: document.getElementById('documentTitle'),
|
documentTitle: document.getElementById('documentTitle'),
|
||||||
documentType: document.getElementById('documentType'),
|
documentType: document.getElementById('documentType'),
|
||||||
@@ -197,7 +244,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
imageFile: document.getElementById('imageFile'),
|
imageFile: document.getElementById('imageFile'),
|
||||||
videoFile: document.getElementById('videoFile'),
|
videoFile: document.getElementById('videoFile'),
|
||||||
}
|
}
|
||||||
const state = { list: [] }
|
const state = {
|
||||||
|
list: [],
|
||||||
|
mode: 'create',
|
||||||
|
editingId: '',
|
||||||
|
editingSource: null,
|
||||||
|
currentImageAttachments: [],
|
||||||
|
currentVideoAttachments: [],
|
||||||
|
pendingImageFiles: [],
|
||||||
|
pendingVideoFiles: [],
|
||||||
|
removedAttachmentIds: [],
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(message, type) {
|
function setStatus(message, type) {
|
||||||
statusEl.textContent = message || ''
|
statusEl.textContent = message || ''
|
||||||
@@ -208,14 +265,65 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
return localStorage.getItem(tokenKey) || ''
|
return localStorage.getItem(tokenKey) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(url, payload) {
|
function getApiUrl(path) {
|
||||||
|
return API_BASE + path
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(value) {
|
||||||
|
const text = String(value || '')
|
||||||
|
const match = text.match(/^\\d{4}-\\d{2}-\\d{2}/)
|
||||||
|
return match ? match[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachmentList(attachments, ids, urls) {
|
||||||
|
const attachmentArray = Array.isArray(attachments) ? attachments : []
|
||||||
|
if (attachmentArray.length) {
|
||||||
|
return attachmentArray.map(function (item) {
|
||||||
|
return {
|
||||||
|
attachments_id: item.attachments_id || '',
|
||||||
|
attachments_filename: item.attachments_filename || item.attachments_id || '',
|
||||||
|
attachments_url: item.attachments_url || '',
|
||||||
|
attachments_download_url: item.attachments_download_url || '',
|
||||||
|
attachments_filetype: item.attachments_filetype || '',
|
||||||
|
attachments_size: item.attachments_size || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const idArray = Array.isArray(ids) ? ids : []
|
||||||
|
const urlArray = Array.isArray(urls) ? urls : []
|
||||||
|
return idArray.map(function (id, index) {
|
||||||
|
return {
|
||||||
|
attachments_id: id,
|
||||||
|
attachments_filename: id,
|
||||||
|
attachments_url: urlArray[index] || '',
|
||||||
|
attachments_download_url: '',
|
||||||
|
attachments_filetype: '',
|
||||||
|
attachments_size: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditorMode() {
|
||||||
|
if (state.mode === 'edit') {
|
||||||
|
formTitleEl.textContent = '编辑文档'
|
||||||
|
editorModeEl.textContent = '当前模式:编辑 ' + state.editingId
|
||||||
|
document.getElementById('submitBtn').textContent = '保存文档修改'
|
||||||
|
} else {
|
||||||
|
formTitleEl.textContent = '新增文档'
|
||||||
|
editorModeEl.textContent = '当前模式:新建'
|
||||||
|
document.getElementById('submitBtn').textContent = '上传附件并创建文档'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(path, payload) {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
window.location.replace('/pb/manage/login')
|
window.location.replace('/pb/manage/login')
|
||||||
throw new Error('登录状态已失效,请重新登录')
|
throw new Error('登录状态已失效,请重新登录')
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(getApiUrl(path), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -239,6 +347,11 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
async function uploadAttachment(file, label) {
|
async function uploadAttachment(file, label) {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
|
window.location.replace('/pb/manage/login')
|
||||||
|
throw new Error('登录状态已失效,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('attachments_link', file)
|
form.append('attachments_link', file)
|
||||||
form.append('attachments_filename', file.name || '')
|
form.append('attachments_filename', file.name || '')
|
||||||
@@ -247,7 +360,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
form.append('attachments_status', 'active')
|
form.append('attachments_status', 'active')
|
||||||
form.append('attachments_remark', 'document-manage:' + label)
|
form.append('attachments_remark', 'document-manage:' + label)
|
||||||
|
|
||||||
const res = await fetch('/api/attachment/upload', {
|
const res = await fetch(getApiUrl('/attachment/upload'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer ' + token,
|
Authorization: 'Bearer ' + token,
|
||||||
@@ -263,6 +376,16 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadPendingFiles(fileItems, label) {
|
||||||
|
const uploaded = []
|
||||||
|
|
||||||
|
for (let i = 0; i < fileItems.length; i += 1) {
|
||||||
|
uploaded.push(await uploadAttachment(fileItems[i].file, label + '-' + (i + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return uploaded
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -272,13 +395,53 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
.replace(/'/g, ''')
|
.replace(/'/g, ''')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAttachmentCards(container, items, category, pending) {
|
||||||
|
if (!items.length) {
|
||||||
|
container.innerHTML = '<div class="muted">' + (pending ? '暂无待上传附件。' : '暂无已绑定附件。') + '</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = items.map(function (item, index) {
|
||||||
|
const title = pending
|
||||||
|
? (item.file && item.file.name ? item.file.name : ('待上传文件' + (index + 1)))
|
||||||
|
: (item.attachments_filename || item.attachments_id || ('附件' + (index + 1)))
|
||||||
|
const meta = pending
|
||||||
|
? ('大小:' + String(item.file && item.file.size ? item.file.size : 0) + ' bytes')
|
||||||
|
: ('ID:' + escapeHtml(item.attachments_id || '') + (item.attachments_filetype ? ' / ' + escapeHtml(item.attachments_filetype) : ''))
|
||||||
|
const linkHtml = pending || !item.attachments_url
|
||||||
|
? ''
|
||||||
|
: '<a href="' + escapeHtml(item.attachments_url) + '" target="_blank" rel="noreferrer">打开文件</a>'
|
||||||
|
const actionLabel = pending ? '移除待上传' : '从文档移除'
|
||||||
|
const handler = pending ? '__removePendingAttachment' : '__removeCurrentAttachment'
|
||||||
|
|
||||||
|
return '<div class="file-card">'
|
||||||
|
+ '<div class="file-card-head"><div class="file-card-title">' + escapeHtml(title) + '</div></div>'
|
||||||
|
+ '<div class="file-meta">' + meta + '</div>'
|
||||||
|
+ '<div class="file-actions">'
|
||||||
|
+ linkHtml
|
||||||
|
+ '<button class="btn btn-light" type="button" onclick="window.' + handler + '(\\'' + category + '\\',' + index + ')">' + actionLabel + '</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachmentEditors() {
|
||||||
|
renderAttachmentCards(imageCurrentListEl, state.currentImageAttachments, 'image', false)
|
||||||
|
renderAttachmentCards(videoCurrentListEl, state.currentVideoAttachments, 'video', false)
|
||||||
|
renderAttachmentCards(imagePendingListEl, state.pendingImageFiles, 'image', true)
|
||||||
|
renderAttachmentCards(videoPendingListEl, state.pendingVideoFiles, 'video', true)
|
||||||
|
}
|
||||||
|
|
||||||
function renderLinks(item) {
|
function renderLinks(item) {
|
||||||
const links = []
|
const links = []
|
||||||
if (item.document_image_url) {
|
const imageUrls = Array.isArray(item.document_image_urls) ? item.document_image_urls : (item.document_image_url ? [item.document_image_url] : [])
|
||||||
links.push('<a href="' + escapeHtml(item.document_image_url) + '" target="_blank" rel="noreferrer">图片流</a>')
|
const videoUrls = Array.isArray(item.document_video_urls) ? item.document_video_urls : (item.document_video_url ? [item.document_video_url] : [])
|
||||||
|
|
||||||
|
for (let i = 0; i < imageUrls.length; i += 1) {
|
||||||
|
links.push('<a href="' + escapeHtml(imageUrls[i]) + '" target="_blank" rel="noreferrer">图片流' + (i + 1) + '</a>')
|
||||||
}
|
}
|
||||||
if (item.document_video_url) {
|
for (let i = 0; i < videoUrls.length; i += 1) {
|
||||||
links.push('<a href="' + escapeHtml(item.document_video_url) + '" target="_blank" rel="noreferrer">视频流</a>')
|
links.push('<a href="' + escapeHtml(videoUrls[i]) + '" target="_blank" rel="noreferrer">视频流' + (i + 1) + '</a>')
|
||||||
}
|
}
|
||||||
if (!links.length) {
|
if (!links.length) {
|
||||||
return '<span class="muted">无</span>'
|
return '<span class="muted">无</span>'
|
||||||
@@ -299,15 +462,89 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
+ '<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="类型/状态"><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="操作"><button class="btn btn-danger" type="button" onclick="window.__deleteDocument(\\'' + encodeURIComponent(item.document_id) + '\\')">删除</button></td>'
|
+ '<td data-label="操作"><div class="table-actions">'
|
||||||
|
+ '<button class="btn btn-light" type="button" onclick="window.__editDocument(\\'' + encodeURIComponent(item.document_id) + '\\')">编辑</button>'
|
||||||
|
+ '<button class="btn btn-danger" type="button" onclick="window.__deleteDocument(\\'' + encodeURIComponent(item.document_id) + '\\')">删除</button>'
|
||||||
|
+ '</div></td>'
|
||||||
+ '</tr>'
|
+ '</tr>'
|
||||||
}).join('')
|
}).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendPendingFiles(category, fileList) {
|
||||||
|
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
|
||||||
|
const files = Array.from(fileList || [])
|
||||||
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
|
target.push({
|
||||||
|
key: Date.now() + '-' + Math.random().toString(36).slice(2),
|
||||||
|
file: files[i],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderAttachmentEditors()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDropzone(dropzoneEl, inputEl, category) {
|
||||||
|
if (!dropzoneEl || !inputEl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener('click', function (event) {
|
||||||
|
const tagName = String((event.target && event.target.tagName) || '').toLowerCase()
|
||||||
|
if (tagName === 'input') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inputEl.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener('dragenter', function (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
dropzoneEl.classList.add('dragover')
|
||||||
|
})
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener('dragover', function (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
dropzoneEl.classList.add('dragover')
|
||||||
|
})
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener('dragleave', function (event) {
|
||||||
|
if (dropzoneEl.contains(event.relatedTarget)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropzoneEl.classList.remove('dragover')
|
||||||
|
})
|
||||||
|
|
||||||
|
dropzoneEl.addEventListener('drop', function (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
dropzoneEl.classList.remove('dragover')
|
||||||
|
const files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : []
|
||||||
|
appendPendingFiles(category, files)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingAttachment(category, index) {
|
||||||
|
const target = category === 'image' ? state.pendingImageFiles : state.pendingVideoFiles
|
||||||
|
if (index >= 0 && index < target.length) {
|
||||||
|
target.splice(index, 1)
|
||||||
|
}
|
||||||
|
renderAttachmentEditors()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCurrentAttachment(category, index) {
|
||||||
|
const target = category === 'image' ? state.currentImageAttachments : state.currentVideoAttachments
|
||||||
|
if (index < 0 || index >= target.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = target.splice(index, 1)[0]
|
||||||
|
if (removed && removed.attachments_id && state.removedAttachmentIds.indexOf(removed.attachments_id) === -1) {
|
||||||
|
state.removedAttachmentIds.push(removed.attachments_id)
|
||||||
|
}
|
||||||
|
renderAttachmentEditors()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDocuments() {
|
async function loadDocuments() {
|
||||||
setStatus('正在加载文档列表...', '')
|
setStatus('正在加载文档列表...', '')
|
||||||
try {
|
try {
|
||||||
const data = await requestJson('/api/document/list', {})
|
const data = await requestJson('/document/list', {})
|
||||||
state.list = data.items || []
|
state.list = data.items || []
|
||||||
renderTable()
|
renderTable()
|
||||||
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
|
setStatus('文档列表已刷新,共 ' + state.list.length + ' 条。', 'success')
|
||||||
@@ -319,8 +556,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
function resetForm() {
|
function resetForm() {
|
||||||
fields.documentTitle.value = ''
|
fields.documentTitle.value = ''
|
||||||
fields.documentType.value = ''
|
fields.documentType.value = ''
|
||||||
fields.documentStatus.value = 'active'
|
fields.documentStatus.value = ''
|
||||||
fields.embeddingStatus.value = 'pending'
|
fields.embeddingStatus.value = ''
|
||||||
fields.effectDate.value = ''
|
fields.effectDate.value = ''
|
||||||
fields.expiryDate.value = ''
|
fields.expiryDate.value = ''
|
||||||
fields.documentSubtitle.value = ''
|
fields.documentSubtitle.value = ''
|
||||||
@@ -337,29 +574,70 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
fields.videoFile.value = ''
|
fields.videoFile.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDocument() {
|
function enterCreateMode() {
|
||||||
if (!fields.documentTitle.value.trim()) {
|
state.mode = 'create'
|
||||||
setStatus('请先填写文档标题。', 'error')
|
state.editingId = ''
|
||||||
|
state.editingSource = null
|
||||||
|
state.currentImageAttachments = []
|
||||||
|
state.currentVideoAttachments = []
|
||||||
|
state.pendingImageFiles = []
|
||||||
|
state.pendingVideoFiles = []
|
||||||
|
state.removedAttachmentIds = []
|
||||||
|
resetForm()
|
||||||
|
updateEditorMode()
|
||||||
|
renderAttachmentEditors()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFormFromItem(item) {
|
||||||
|
fields.documentTitle.value = item.document_title || ''
|
||||||
|
fields.documentType.value = item.document_type || ''
|
||||||
|
fields.documentStatus.value = item.document_status || ''
|
||||||
|
fields.embeddingStatus.value = item.document_embedding_status || ''
|
||||||
|
fields.effectDate.value = toDateInputValue(item.document_effect_date)
|
||||||
|
fields.expiryDate.value = toDateInputValue(item.document_expiry_date)
|
||||||
|
fields.documentSubtitle.value = item.document_subtitle || ''
|
||||||
|
fields.documentSummary.value = item.document_summary || ''
|
||||||
|
fields.documentContent.value = item.document_content || ''
|
||||||
|
fields.relationModel.value = item.document_relation_model || ''
|
||||||
|
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.imageFile.value = ''
|
||||||
|
fields.videoFile.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterEditMode(documentId) {
|
||||||
|
const target = state.list.find(function (item) {
|
||||||
|
return item.document_id === documentId
|
||||||
|
})
|
||||||
|
if (!target) {
|
||||||
|
setStatus('未找到待编辑文档。', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('正在上传附件并创建文档...', '')
|
state.mode = 'edit'
|
||||||
|
state.editingId = target.document_id
|
||||||
|
state.editingSource = target
|
||||||
|
state.currentImageAttachments = normalizeAttachmentList(target.document_image_attachments, target.document_image_ids, target.document_image_urls)
|
||||||
|
state.currentVideoAttachments = normalizeAttachmentList(target.document_video_attachments, target.document_video_ids, target.document_video_urls)
|
||||||
|
state.pendingImageFiles = []
|
||||||
|
state.pendingVideoFiles = []
|
||||||
|
state.removedAttachmentIds = []
|
||||||
|
|
||||||
try {
|
fillFormFromItem(target)
|
||||||
let imageAttachment = null
|
updateEditorMode()
|
||||||
let videoAttachment = null
|
renderAttachmentEditors()
|
||||||
const imageFile = fields.imageFile.files && fields.imageFile.files[0]
|
setStatus('已进入编辑模式:' + target.document_id, 'success')
|
||||||
const videoFile = fields.videoFile.files && fields.videoFile.files[0]
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
|
||||||
if (imageFile) {
|
|
||||||
imageAttachment = await uploadAttachment(imageFile, 'image')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoFile) {
|
function buildMutationPayload(imageAttachments, videoAttachments) {
|
||||||
videoAttachment = await uploadAttachment(videoFile, 'video')
|
const source = state.editingSource || {}
|
||||||
}
|
return {
|
||||||
|
document_id: state.mode === 'edit' ? state.editingId : '',
|
||||||
await requestJson('/api/document/create', {
|
|
||||||
document_title: fields.documentTitle.value.trim(),
|
document_title: fields.documentTitle.value.trim(),
|
||||||
document_type: fields.documentType.value.trim(),
|
document_type: fields.documentType.value.trim(),
|
||||||
document_status: fields.documentStatus.value.trim(),
|
document_status: fields.documentStatus.value.trim(),
|
||||||
@@ -369,22 +647,90 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
document_subtitle: fields.documentSubtitle.value.trim(),
|
document_subtitle: fields.documentSubtitle.value.trim(),
|
||||||
document_summary: fields.documentSummary.value.trim(),
|
document_summary: fields.documentSummary.value.trim(),
|
||||||
document_content: fields.documentContent.value.trim(),
|
document_content: fields.documentContent.value.trim(),
|
||||||
document_image: imageAttachment ? imageAttachment.attachments_id : '',
|
document_image: imageAttachments.map(function (item) { return item.attachments_id }),
|
||||||
document_video: videoAttachment ? videoAttachment.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: fields.documentKeywords.value.trim(),
|
||||||
|
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_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_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: fields.productCategories.value.trim(),
|
||||||
document_application_scenarios: fields.applicationScenarios.value.trim(),
|
document_application_scenarios: fields.applicationScenarios.value.trim(),
|
||||||
document_hotel_type: fields.hotelType.value.trim(),
|
document_hotel_type: fields.hotelType.value.trim(),
|
||||||
document_remark: fields.documentRemark.value.trim(),
|
document_remark: fields.documentRemark.value.trim(),
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resetForm()
|
async function cleanupUploadedAttachments(uploadedAttachments) {
|
||||||
|
for (let i = 0; i < uploadedAttachments.length; i += 1) {
|
||||||
|
try {
|
||||||
|
await requestJson('/attachment/delete', { attachments_id: uploadedAttachments[i].attachments_id })
|
||||||
|
} catch (_error) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRemovedAttachments() {
|
||||||
|
const failed = []
|
||||||
|
for (let i = 0; i < state.removedAttachmentIds.length; i += 1) {
|
||||||
|
try {
|
||||||
|
await requestJson('/attachment/delete', { attachments_id: state.removedAttachmentIds[i] })
|
||||||
|
} catch (error) {
|
||||||
|
failed.push(state.removedAttachmentIds[i] + ':' + (error.message || '删除失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDocument() {
|
||||||
|
if (!fields.documentTitle.value.trim()) {
|
||||||
|
setStatus('请先填写文档标题。', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields.documentType.value.trim()) {
|
||||||
|
setStatus('请先填写文档类型。', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(state.mode === 'edit' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '')
|
||||||
|
|
||||||
|
const uploadedAttachments = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image')
|
||||||
|
const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video')
|
||||||
|
uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments)
|
||||||
|
uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments)
|
||||||
|
|
||||||
|
const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments)
|
||||||
|
const finalVideoAttachments = state.currentVideoAttachments.concat(newVideoAttachments)
|
||||||
|
const payload = buildMutationPayload(finalImageAttachments, finalVideoAttachments)
|
||||||
|
|
||||||
|
if (state.mode === 'edit') {
|
||||||
|
await requestJson('/document/update', payload)
|
||||||
|
const deleteFailed = await deleteRemovedAttachments()
|
||||||
await loadDocuments()
|
await loadDocuments()
|
||||||
|
enterCreateMode()
|
||||||
|
if (deleteFailed.length) {
|
||||||
|
setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(';'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('文档修改成功。', 'success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestJson('/document/create', payload)
|
||||||
|
await loadDocuments()
|
||||||
|
enterCreateMode()
|
||||||
setStatus('文档创建成功。', 'success')
|
setStatus('文档创建成功。', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(err.message || '创建文档失败', 'error')
|
if (uploadedAttachments.length) {
|
||||||
|
await cleanupUploadedAttachments(uploadedAttachments)
|
||||||
|
}
|
||||||
|
setStatus(err.message || (state.mode === 'edit' ? '修改文档失败' : '创建文档失败'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +742,10 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
|
|
||||||
setStatus('正在删除文档...', '')
|
setStatus('正在删除文档...', '')
|
||||||
try {
|
try {
|
||||||
await requestJson('/api/document/delete', { document_id: target })
|
await requestJson('/document/delete', { document_id: target })
|
||||||
|
if (state.mode === 'edit' && state.editingId === target) {
|
||||||
|
enterCreateMode()
|
||||||
|
}
|
||||||
await loadDocuments()
|
await loadDocuments()
|
||||||
setStatus('文档删除成功。', 'success')
|
setStatus('文档删除成功。', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -405,11 +754,29 @@ routerAdd('GET', '/manage/document-manage', function (e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.__deleteDocument = deleteDocument
|
window.__deleteDocument = deleteDocument
|
||||||
|
window.__editDocument = function (documentId) {
|
||||||
|
enterEditMode(decodeURIComponent(documentId))
|
||||||
|
}
|
||||||
|
window.__removePendingAttachment = removePendingAttachment
|
||||||
|
window.__removeCurrentAttachment = removeCurrentAttachment
|
||||||
|
|
||||||
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
|
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
|
||||||
|
document.getElementById('createModeBtn').addEventListener('click', function () {
|
||||||
|
enterCreateMode()
|
||||||
|
setStatus('已切换到新建模式。', 'success')
|
||||||
|
})
|
||||||
document.getElementById('submitBtn').addEventListener('click', submitDocument)
|
document.getElementById('submitBtn').addEventListener('click', submitDocument)
|
||||||
|
document.getElementById('cancelEditBtn').addEventListener('click', function () {
|
||||||
|
enterCreateMode()
|
||||||
|
setStatus('已取消编辑。', 'success')
|
||||||
|
})
|
||||||
document.getElementById('resetBtn').addEventListener('click', function () {
|
document.getElementById('resetBtn').addEventListener('click', function () {
|
||||||
resetForm()
|
if (state.mode === 'edit' && state.editingSource) {
|
||||||
|
enterEditMode(state.editingId)
|
||||||
|
setStatus('编辑表单已恢复到当前文档数据。', 'success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enterCreateMode()
|
||||||
setStatus('表单已重置。', 'success')
|
setStatus('表单已重置。', 'success')
|
||||||
})
|
})
|
||||||
document.getElementById('logoutBtn').addEventListener('click', function () {
|
document.getElementById('logoutBtn').addEventListener('click', function () {
|
||||||
@@ -419,7 +786,18 @@ routerAdd('GET', '/manage/document-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')
|
||||||
})
|
})
|
||||||
|
fields.imageFile.addEventListener('change', function (event) {
|
||||||
|
appendPendingFiles('image', event.target.files)
|
||||||
|
fields.imageFile.value = ''
|
||||||
|
})
|
||||||
|
fields.videoFile.addEventListener('change', function (event) {
|
||||||
|
appendPendingFiles('video', event.target.files)
|
||||||
|
fields.videoFile.value = ''
|
||||||
|
})
|
||||||
|
bindDropzone(imageDropzoneEl, fields.imageFile, 'image')
|
||||||
|
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
|
||||||
|
|
||||||
|
enterCreateMode()
|
||||||
loadDocuments()
|
loadDocuments()
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
### 1. 登录路由显式错误响应
|
### 1. 登录路由显式错误响应
|
||||||
|
|
||||||
`POST /api/wechat/login` 新增局部 try/catch:
|
`POST /pb/api/wechat/login` 新增局部 try/catch:
|
||||||
|
|
||||||
- 保留业务状态码
|
- 保留业务状态码
|
||||||
- 返回 `{ code, msg, data }`
|
- 返回 `{ code, msg, data }`
|
||||||
@@ -132,39 +132,39 @@
|
|||||||
|
|
||||||
当前 active PocketBase hooks 契约如下:
|
当前 active PocketBase hooks 契约如下:
|
||||||
|
|
||||||
- `POST /api/system/test-helloworld`
|
- `POST /pb/api/system/test-helloworld`
|
||||||
- `POST /api/system/health`
|
- `POST /pb/api/system/health`
|
||||||
- `POST /api/system/refresh-token`
|
- `POST /pb/api/system/refresh-token`
|
||||||
- `POST /api/platform/register`
|
- `POST /pb/api/platform/register`
|
||||||
- `POST /api/platform/login`
|
- `POST /pb/api/platform/login`
|
||||||
- `POST /api/wechat/login`
|
- `POST /pb/api/wechat/login`
|
||||||
- `POST /api/wechat/profile`
|
- `POST /pb/api/wechat/profile`
|
||||||
- `POST /api/dictionary/list`
|
- `POST /pb/api/dictionary/list`
|
||||||
- `POST /api/dictionary/detail`
|
- `POST /pb/api/dictionary/detail`
|
||||||
- `POST /api/dictionary/create`
|
- `POST /pb/api/dictionary/create`
|
||||||
- `POST /api/dictionary/update`
|
- `POST /pb/api/dictionary/update`
|
||||||
- `POST /api/dictionary/delete`
|
- `POST /pb/api/dictionary/delete`
|
||||||
- `POST /api/attachment/list`
|
- `POST /pb/api/attachment/list`
|
||||||
- `POST /api/attachment/detail`
|
- `POST /pb/api/attachment/detail`
|
||||||
- `POST /api/attachment/upload`
|
- `POST /pb/api/attachment/upload`
|
||||||
- `POST /api/attachment/delete`
|
- `POST /pb/api/attachment/delete`
|
||||||
- `POST /api/document/list`
|
- `POST /pb/api/document/list`
|
||||||
- `POST /api/document/detail`
|
- `POST /pb/api/document/detail`
|
||||||
- `POST /api/document/create`
|
- `POST /pb/api/document/create`
|
||||||
- `POST /api/document/update`
|
- `POST /pb/api/document/update`
|
||||||
- `POST /api/document/delete`
|
- `POST /pb/api/document/delete`
|
||||||
- `POST /api/document-history/list`
|
- `POST /pb/api/document-history/list`
|
||||||
|
|
||||||
其中平台用户链路补充为:
|
其中平台用户链路补充为:
|
||||||
|
|
||||||
### `POST /api/platform/register`
|
### `POST /pb/api/platform/register`
|
||||||
|
|
||||||
- body 必填:`users_name`、`users_phone`、`password`、`passwordConfirm`、`users_picture`
|
- body 必填:`users_name`、`users_phone`、`password`、`passwordConfirm`、`users_picture`
|
||||||
- 自动生成 GUID 并写入统一身份字段 `openid`
|
- 自动生成 GUID 并写入统一身份字段 `openid`
|
||||||
- 写入 `users_idtype = ManagePlatform`
|
- 写入 `users_idtype = ManagePlatform`
|
||||||
- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token`
|
- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token`
|
||||||
|
|
||||||
### `POST /api/platform/login`
|
### `POST /pb/api/platform/login`
|
||||||
|
|
||||||
- body 必填:`login_account`、`password`
|
- body 必填:`login_account`、`password`
|
||||||
- 仅允许 `users_idtype = ManagePlatform`
|
- 仅允许 `users_idtype = ManagePlatform`
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
其中:
|
其中:
|
||||||
|
|
||||||
### `POST /api/wechat/login`
|
### `POST /pb/api/wechat/login`
|
||||||
|
|
||||||
- body 必填:`users_wx_code`
|
- body 必填:`users_wx_code`
|
||||||
- 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段
|
- 自动以微信 code 换取微信侧 `openid` 并写入统一身份字段
|
||||||
@@ -182,13 +182,13 @@
|
|||||||
- 写入 `users_idtype = WeChat`
|
- 写入 `users_idtype = WeChat`
|
||||||
- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token`
|
- 成功时返回统一结构:`code`、`msg`、`data`,并在顶层额外返回 `token`
|
||||||
|
|
||||||
### `POST /api/wechat/profile`
|
### `POST /pb/api/wechat/profile`
|
||||||
|
|
||||||
- 需 `Authorization`
|
- 需 `Authorization`
|
||||||
- 基于当前 auth record 的 `openid` 定位用户
|
- 基于当前 auth record 的 `openid` 定位用户
|
||||||
- 服务端用 `users_phone_code` 换取手机号后保存
|
- 服务端用 `users_phone_code` 换取手机号后保存
|
||||||
|
|
||||||
### `POST /api/system/refresh-token`
|
### `POST /pb/api/system/refresh-token`
|
||||||
|
|
||||||
- body 可选:`users_wx_code`(允许为空)
|
- body 可选:`users_wx_code`(允许为空)
|
||||||
- `Authorization` 可选:
|
- `Authorization` 可选:
|
||||||
@@ -202,16 +202,16 @@
|
|||||||
|
|
||||||
新增 `dictionary` 分类接口,统一要求平台管理用户访问:
|
新增 `dictionary` 分类接口,统一要求平台管理用户访问:
|
||||||
|
|
||||||
- `POST /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_sort_order` 组装为 `items`
|
||||||
- `POST /api/dictionary/detail`
|
- `POST /pb/api/dictionary/detail`
|
||||||
- 按 `dict_name` 查询单条字典
|
- 按 `dict_name` 查询单条字典
|
||||||
- `POST /api/dictionary/create`
|
- `POST /pb/api/dictionary/create`
|
||||||
- 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一
|
- 新增字典,`system_dict_id` 自动生成,`dict_name` 唯一
|
||||||
- `POST /api/dictionary/update`
|
- `POST /pb/api/dictionary/update`
|
||||||
- 按 `original_dict_name` / `dict_name` 更新字典
|
- 按 `original_dict_name` / `dict_name` 更新字典
|
||||||
- `POST /api/dictionary/delete`
|
- `POST /pb/api/dictionary/delete`
|
||||||
- 按 `dict_name` 真删除字典
|
- 按 `dict_name` 真删除字典
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -223,21 +223,21 @@
|
|||||||
|
|
||||||
新增 `attachment` 分类接口,统一要求平台管理用户访问:
|
新增 `attachment` 分类接口,统一要求平台管理用户访问:
|
||||||
|
|
||||||
- `POST /api/attachment/list`
|
- `POST /pb/api/attachment/list`
|
||||||
- 支持按 `attachments_id`、`attachments_filename` 模糊搜索
|
- 支持按 `attachments_id`、`attachments_filename` 模糊搜索
|
||||||
- 支持按 `attachments_status` 过滤
|
- 支持按 `attachments_status` 过滤
|
||||||
- 返回附件元数据以及 PocketBase 文件流链接 `attachments_url`
|
- 返回附件元数据以及 PocketBase 文件流链接 `attachments_url`
|
||||||
- `POST /api/attachment/detail`
|
- `POST /pb/api/attachment/detail`
|
||||||
- 按 `attachments_id` 查询单个附件
|
- 按 `attachments_id` 查询单个附件
|
||||||
- 返回文件流链接与下载链接
|
- 返回文件流链接与下载链接
|
||||||
- `POST /api/attachment/upload`
|
- `POST /pb/api/attachment/upload`
|
||||||
- 使用 `multipart/form-data`
|
- 使用 `multipart/form-data`
|
||||||
- 文件字段固定为 `attachments_link`
|
- 文件字段固定为 `attachments_link`
|
||||||
- 上传成功后自动生成 `attachments_id`
|
- 上传成功后自动生成 `attachments_id`
|
||||||
- 自动写入 `attachments_owner = 当前用户 openid`
|
- 自动写入 `attachments_owner = 当前用户 openid`
|
||||||
- `POST /api/attachment/delete`
|
- `POST /pb/api/attachment/delete`
|
||||||
- 按 `attachments_id` 真删除附件
|
- 按 `attachments_id` 真删除附件
|
||||||
- 若该附件已被 `tbl_document.document_image` 或 `document_video` 引用,则拒绝删除
|
- 若该附件已被 `tbl_document.document_image` 或 `document_video` 中的任一附件列表引用,则拒绝删除
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
@@ -250,42 +250,44 @@
|
|||||||
|
|
||||||
新增 `document` 分类接口,统一要求平台管理用户访问:
|
新增 `document` 分类接口,统一要求平台管理用户访问:
|
||||||
|
|
||||||
- `POST /api/document/list`
|
- `POST /pb/api/document/list`
|
||||||
- 支持按 `document_id`、`document_title`、`document_subtitle`、`document_summary`、`document_keywords` 模糊搜索
|
- 支持按 `document_id`、`document_title`、`document_subtitle`、`document_summary`、`document_keywords` 模糊搜索
|
||||||
- 支持按 `document_status`、`document_type` 过滤
|
- 支持按 `document_status`、`document_type` 过滤
|
||||||
- 返回时会自动联查 `tbl_attachments`
|
- 返回时会自动联查 `tbl_attachments`
|
||||||
- 额外补充:
|
- 额外补充:
|
||||||
- `document_image_url`
|
- `document_image_urls`
|
||||||
- `document_video_url`
|
- `document_video_urls`
|
||||||
- `document_image_attachment`
|
- `document_image_attachments`
|
||||||
- `document_video_attachment`
|
- `document_video_attachments`
|
||||||
- `POST /api/document/detail`
|
- `POST /pb/api/document/detail`
|
||||||
- 按 `document_id` 查询单条文档
|
- 按 `document_id` 查询单条文档
|
||||||
- 返回与附件表联动解析后的文件流链接
|
- 返回与附件表联动解析后的多文件流链接
|
||||||
- `POST /api/document/create`
|
- `POST /pb/api/document/create`
|
||||||
- 新增文档
|
- 新增文档
|
||||||
- `document_id` 可不传,由服务端自动生成
|
- `document_id` 可不传,由服务端自动生成
|
||||||
- `document_image`、`document_video` 必须传入已存在的 `attachments_id`
|
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
||||||
|
- `document_image`、`document_video` 支持传入多个已存在的 `attachments_id`
|
||||||
- 成功后会写入一条文档操作历史,类型为 `create`
|
- 成功后会写入一条文档操作历史,类型为 `create`
|
||||||
- `POST /api/document/update`
|
- `POST /pb/api/document/update`
|
||||||
- 按 `document_id` 更新文档
|
- 按 `document_id` 更新文档
|
||||||
- 若传入附件字段,则会校验对应 `attachments_id` 是否存在
|
- `document_title`、`document_type` 为必填;其余字段均允许为空
|
||||||
|
- 若传入附件字段,则会校验多个 `attachments_id` 是否都存在
|
||||||
- 成功后会写入一条文档操作历史,类型为 `update`
|
- 成功后会写入一条文档操作历史,类型为 `update`
|
||||||
- `POST /api/document/delete`
|
- `POST /pb/api/document/delete`
|
||||||
- 按 `document_id` 真删除文档
|
- 按 `document_id` 真删除文档
|
||||||
- 删除前会写入一条文档操作历史,类型为 `delete`
|
- 删除前会写入一条文档操作历史,类型为 `delete`
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `document_image`、`document_video` 当前保存的是 `attachments_id`,不是 PocketBase 文件字段。
|
- `document_image`、`document_video` 当前保存的是多个 `attachments_id`,底层以 `|` 分隔文本持久化,不是 PocketBase 文件字段。
|
||||||
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接。
|
- 文档查询时通过 `attachments_id -> tbl_attachments` 反查实际文件,并返回可直接访问的数据流链接数组。
|
||||||
- `document_owner` 语义为“上传者 openid”。
|
- `document_owner` 语义为“上传者 openid”。
|
||||||
|
|
||||||
### 文档操作历史接口
|
### 文档操作历史接口
|
||||||
|
|
||||||
新增 `document-history` 分类接口,统一要求平台管理用户访问:
|
新增 `document-history` 分类接口,统一要求平台管理用户访问:
|
||||||
|
|
||||||
- `POST /api/document-history/list`
|
- `POST /pb/api/document-history/list`
|
||||||
- 不传 `document_id` 时返回全部文档历史
|
- 不传 `document_id` 时返回全部文档历史
|
||||||
- 传入 `document_id` 时仅返回该文档历史
|
- 传入 `document_id` 时仅返回该文档历史
|
||||||
- 结果按创建时间倒序排列
|
- 结果按创建时间倒序排列
|
||||||
@@ -327,8 +329,11 @@
|
|||||||
- 返回主页
|
- 返回主页
|
||||||
- 文档管理页支持:
|
- 文档管理页支持:
|
||||||
- 先上传附件到 `tbl_attachments`
|
- 先上传附件到 `tbl_attachments`
|
||||||
- 再把返回的 `attachments_id` 写入 `tbl_document.document_image` / `document_video`
|
- 再把返回的多个 `attachments_id` 写入 `tbl_document.document_image` / `document_video`
|
||||||
|
- 图片和视频都支持多选上传
|
||||||
- 新增文档
|
- 新增文档
|
||||||
|
- 编辑已有文档并回显多图片、多视频
|
||||||
|
- 从文档中移除附件并在保存后删除对应附件记录
|
||||||
- 查询文档列表
|
- 查询文档列表
|
||||||
- 直接展示 PocketBase 文件流链接
|
- 直接展示 PocketBase 文件流链接
|
||||||
- 删除文档
|
- 删除文档
|
||||||
@@ -337,14 +342,16 @@
|
|||||||
|
|
||||||
- 原页面 `page-b.js` 已替换为 `document-manage.js`
|
- 原页面 `page-b.js` 已替换为 `document-manage.js`
|
||||||
- 页面实际走的接口链路为:
|
- 页面实际走的接口链路为:
|
||||||
- `/api/attachment/upload`
|
- `/pb/api/attachment/upload`
|
||||||
- `/api/document/create`
|
- `/pb/api/document/create`
|
||||||
- `/api/document/list`
|
- `/pb/api/document/update`
|
||||||
- `/api/document/delete`
|
- `/pb/api/attachment/delete`
|
||||||
|
- `/pb/api/document/list`
|
||||||
|
- `/pb/api/document/delete`
|
||||||
|
|
||||||
### 2. 健康检查版本探针
|
### 2. 健康检查版本探针
|
||||||
|
|
||||||
`POST /api/system/health` 新增:
|
`POST /pb/api/system/health` 新增:
|
||||||
|
|
||||||
- `data.version`
|
- `data.version`
|
||||||
|
|
||||||
@@ -419,9 +426,9 @@ OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authoriz
|
|||||||
|
|
||||||
建议归档后的发布核验顺序:
|
建议归档后的发布核验顺序:
|
||||||
|
|
||||||
1. `POST /api/system/health`:确认 `data.version`
|
1. `POST /pb/api/system/health`:确认 `data.version`
|
||||||
2. `POST /api/platform/login`:确认返回统一结构与顶层 `token`
|
2. `POST /pb/api/platform/login`:确认返回统一结构与顶层 `token`
|
||||||
3. `POST /api/dictionary/list`:确认鉴权与字典接口可用
|
3. `POST /pb/api/dictionary/list`:确认鉴权与字典接口可用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -429,3 +436,5 @@ OpenAPI 文档中已取消 `bearerAuth` 鉴权组件与接口级重复 `Authoriz
|
|||||||
|
|
||||||
- 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。
|
- 已将本轮字典管理、PocketBase 页面、统一响应、平台登录兼容修复、健康检查版本探针、OpenAPI/Apifox 鉴权策略调整并入本变更记录。
|
||||||
- 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。
|
- 本记录可作为当前 PocketBase hooks 阶段性归档基线继续维护。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -467,20 +467,48 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
document_image:
|
document_image:
|
||||||
type: string
|
type: string
|
||||||
description: 关联的 `attachments_id`
|
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
|
||||||
|
document_image_ids:
|
||||||
|
type: array
|
||||||
|
description: `document_image` 解析后的附件 id 列表
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
document_image_urls:
|
||||||
|
type: array
|
||||||
|
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接列表
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
document_image_url:
|
document_image_url:
|
||||||
type: string
|
type: string
|
||||||
description: 根据 `document_image -> tbl_attachments` 自动解析出的图片文件流链接
|
description: 兼容字段,返回第一张图片的文件流链接
|
||||||
|
document_image_attachments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AttachmentRecord'
|
||||||
document_image_attachment:
|
document_image_attachment:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/AttachmentRecord'
|
- $ref: '#/components/schemas/AttachmentRecord'
|
||||||
nullable: true
|
nullable: true
|
||||||
document_video:
|
document_video:
|
||||||
type: string
|
type: string
|
||||||
description: 关联的 `attachments_id`
|
description: 关联多个 `attachments_id`,底层使用 `|` 分隔保存
|
||||||
|
document_video_ids:
|
||||||
|
type: array
|
||||||
|
description: `document_video` 解析后的附件 id 列表
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
document_video_urls:
|
||||||
|
type: array
|
||||||
|
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接列表
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
document_video_url:
|
document_video_url:
|
||||||
type: string
|
type: string
|
||||||
description: 根据 `document_video -> tbl_attachments` 自动解析出的视频文件流链接
|
description: 兼容字段,返回第一个视频的文件流链接
|
||||||
|
document_video_attachments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AttachmentRecord'
|
||||||
document_video_attachment:
|
document_video_attachment:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: '#/components/schemas/AttachmentRecord'
|
- $ref: '#/components/schemas/AttachmentRecord'
|
||||||
@@ -546,6 +574,7 @@ components:
|
|||||||
example: DOC-1743037200000-abc123
|
example: DOC-1743037200000-abc123
|
||||||
DocumentMutationRequest:
|
DocumentMutationRequest:
|
||||||
type: object
|
type: object
|
||||||
|
required: [document_title, document_type]
|
||||||
properties:
|
properties:
|
||||||
document_id:
|
document_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -570,11 +599,21 @@ components:
|
|||||||
document_content:
|
document_content:
|
||||||
type: string
|
type: string
|
||||||
document_image:
|
document_image:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
description: 多个图片附件 id 使用 `|` 分隔
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: 图片附件的 `attachments_id`
|
description: 图片附件 id 列表;支持数组或 `|` 分隔字符串
|
||||||
document_video:
|
document_video:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
description: 多个视频附件 id 使用 `|` 分隔
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: 视频附件的 `attachments_id`
|
description: 视频附件 id 列表;支持数组或 `|` 分隔字符串
|
||||||
document_relation_model:
|
document_relation_model:
|
||||||
type: string
|
type: string
|
||||||
document_keywords:
|
document_keywords:
|
||||||
@@ -1222,8 +1261,8 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
仅允许 `ManagePlatform` 用户访问。
|
仅允许 `ManagePlatform` 用户访问。
|
||||||
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
|
支持按标题、摘要、关键词等字段模糊搜索,并可按 `document_status`、`document_type` 过滤。
|
||||||
返回结果会自动根据 `document_image`、`document_video` 关联 `tbl_attachments`,
|
返回结果会自动根据 `document_image`、`document_video` 中的多个 `attachments_id` 关联 `tbl_attachments`,
|
||||||
额外补充 `document_image_url`、`document_video_url` 以及对应附件对象。
|
额外补充 `document_image_urls`、`document_video_urls` 以及对应附件对象数组。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -1259,7 +1298,7 @@ paths:
|
|||||||
summary: 查询文档详情
|
summary: 查询文档详情
|
||||||
description: |
|
description: |
|
||||||
仅允许 `ManagePlatform` 用户访问。
|
仅允许 `ManagePlatform` 用户访问。
|
||||||
按 `document_id` 查询单条文档,并返回与附件表联动解析后的文件流链接。
|
按 `document_id` 查询单条文档,并返回与附件表联动解析后的多文件流链接。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1295,7 +1334,8 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
仅允许 `ManagePlatform` 用户访问。
|
仅允许 `ManagePlatform` 用户访问。
|
||||||
`document_id` 可选;未传时服务端自动生成。
|
`document_id` 可选;未传时服务端自动生成。
|
||||||
`document_image`、`document_video` 需传入已存在于 `tbl_attachments` 的 `attachments_id`。
|
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||||
|
`document_image`、`document_video` 支持传入多个已存在于 `tbl_attachments` 的 `attachments_id`。
|
||||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
|
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `create`。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1331,7 +1371,9 @@ paths:
|
|||||||
summary: 修改文档
|
summary: 修改文档
|
||||||
description: |
|
description: |
|
||||||
仅允许 `ManagePlatform` 用户访问。
|
仅允许 `ManagePlatform` 用户访问。
|
||||||
按 `document_id` 定位现有文档并更新;若传入 `document_image`、`document_video`,则必须是已存在的 `attachments_id`。
|
按 `document_id` 定位现有文档并更新。
|
||||||
|
`document_title`、`document_type` 为必填;其余字段均允许为空。
|
||||||
|
若传入 `document_image`、`document_video`,则支持多个 `attachments_id`,并会逐一校验是否存在。
|
||||||
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
|
成功后会同步写入一条 `tbl_document_operation_history`,操作类型为 `update`。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
**索引规划 (Indexes):**
|
**索引规划 (Indexes):**
|
||||||
* `CREATE UNIQUE INDEX` 针对 `system_dict_id` (确保业务主键唯一)
|
* `CREATE UNIQUE INDEX` 针对 `system_dict_id` (确保业务主键唯一)
|
||||||
|
* `CREATE UNIQUE INDEX` 针对 `dict_name` (确保词典名称全局唯一,并支持按名称唯一索引查询)
|
||||||
* `CREATE INDEX` 针对 `dict_word_parent_id` (加速父子级联查询)
|
* `CREATE INDEX` 针对 `dict_word_parent_id` (加速父子级联查询)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ const collections = [
|
|||||||
{ name: 'document_id', type: 'text', required: true },
|
{ name: 'document_id', type: 'text', required: true },
|
||||||
{ name: 'document_effect_date', type: 'date' },
|
{ name: 'document_effect_date', type: 'date' },
|
||||||
{ name: 'document_expiry_date', type: 'date' },
|
{ name: 'document_expiry_date', type: 'date' },
|
||||||
{ name: 'document_type', type: 'text' },
|
{ name: 'document_type', type: 'text', required: true },
|
||||||
{ name: 'document_title', type: 'text' },
|
{ name: 'document_title', type: 'text', required: true },
|
||||||
{ name: 'document_subtitle', type: 'text' },
|
{ name: 'document_subtitle', type: 'text' },
|
||||||
{ name: 'document_summary', type: 'text' },
|
{ name: 'document_summary', type: 'text' },
|
||||||
{ name: 'document_content', type: 'text' },
|
{ name: 'document_content', type: 'text' },
|
||||||
@@ -167,6 +167,7 @@ function normalizeFieldList(fields) {
|
|||||||
return (fields || []).map((field) => ({
|
return (fields || []).map((field) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
|
required: !!field.required,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +211,10 @@ async function verifyCollections(targetCollections) {
|
|||||||
const remoteFields = normalizeFieldList(remote.fields);
|
const remoteFields = normalizeFieldList(remote.fields);
|
||||||
const targetFields = normalizeFieldList(target.fields);
|
const targetFields = normalizeFieldList(target.fields);
|
||||||
const remoteFieldMap = new Map(remoteFields.map((field) => [field.name, field.type]));
|
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 missingFields = [];
|
||||||
const mismatchedTypes = [];
|
const mismatchedTypes = [];
|
||||||
|
const mismatchedRequired = [];
|
||||||
|
|
||||||
for (const field of targetFields) {
|
for (const field of targetFields) {
|
||||||
if (!remoteFieldMap.has(field.name)) {
|
if (!remoteFieldMap.has(field.name)) {
|
||||||
@@ -222,6 +225,10 @@ async function verifyCollections(targetCollections) {
|
|||||||
if (remoteFieldMap.get(field.name) !== field.type) {
|
if (remoteFieldMap.get(field.name) !== field.type) {
|
||||||
mismatchedTypes.push(`${field.name}:${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 remoteIndexes = new Set(remote.indexes || []);
|
||||||
@@ -231,7 +238,7 @@ async function verifyCollections(targetCollections) {
|
|||||||
throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`);
|
throw new Error(`${target.name} 类型不匹配,期望 ${target.type},实际 ${remote.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!missingFields.length && !mismatchedTypes.length && !missingIndexes.length) {
|
if (!missingFields.length && !mismatchedTypes.length && !mismatchedRequired.length && !missingIndexes.length) {
|
||||||
console.log(`✅ ${target.name} 校验通过。`);
|
console.log(`✅ ${target.name} 校验通过。`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -243,6 +250,9 @@ async function verifyCollections(targetCollections) {
|
|||||||
if (mismatchedTypes.length) {
|
if (mismatchedTypes.length) {
|
||||||
console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`);
|
console.log(` - 字段类型不匹配: ${mismatchedTypes.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
if (mismatchedRequired.length) {
|
||||||
|
console.log(` - 字段必填属性不匹配: ${mismatchedRequired.join(', ')}`);
|
||||||
|
}
|
||||||
if (missingIndexes.length) {
|
if (missingIndexes.length) {
|
||||||
console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`);
|
console.log(` - 缺失索引: ${missingIndexes.join(' | ')}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user