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:
2026-03-27 13:55:06 +08:00
parent 9feb0bb3a0
commit c43aae783f
9 changed files with 749 additions and 206 deletions

View File

@@ -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 | 关键词,竖线分隔 |

View File

@@ -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 || '',

View File

@@ -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) {
if (!record) { const record = findAttachmentRecordByAttachmentId(attachmentIds[i])
throw createAppError(400, fieldName + ' 对应的附件不存在') if (!record) {
throw createAppError(400, fieldName + ' 中存在不存在的附件:' + attachmentIds[i])
}
} }
} }
@@ -245,10 +313,15 @@ 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]
throw createAppError(400, '附件已被文档引用,无法删除') 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, '附件已被文档引用,无法删除')
}
} }
try { try {
@@ -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 || '')

View File

@@ -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 || [],
} }

View File

@@ -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> </div>
<label for="imageFile">图片附件</label> <div class="file-group">
<input id="imageFile" type="file" /> <div class="file-box">
<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>
<div> <div class="file-box">
<label for="videoFile">视频附件</label> <h3>视频附件</h3>
<input id="videoFile" type="file" /> <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, '&amp;') .replace(/&/g, '&amp;')
@@ -272,13 +395,53 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.replace(/'/g, '&#39;') .replace(/'/g, '&#39;')
} }
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,54 +574,163 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.videoFile.value = '' fields.videoFile.value = ''
} }
function enterCreateMode() {
state.mode = 'create'
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
}
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 = []
fillFormFromItem(target)
updateEditorMode()
renderAttachmentEditors()
setStatus('已进入编辑模式:' + target.document_id, 'success')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function buildMutationPayload(imageAttachments, videoAttachments) {
const source = state.editingSource || {}
return {
document_id: state.mode === 'edit' ? state.editingId : '',
document_title: fields.documentTitle.value.trim(),
document_type: fields.documentType.value.trim(),
document_status: fields.documentStatus.value.trim(),
document_embedding_status: fields.embeddingStatus.value.trim(),
document_effect_date: fields.effectDate.value,
document_expiry_date: fields.expiryDate.value,
document_subtitle: fields.documentSubtitle.value.trim(),
document_summary: fields.documentSummary.value.trim(),
document_content: fields.documentContent.value.trim(),
document_image: imageAttachments.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_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_product_categories: fields.productCategories.value.trim(),
document_application_scenarios: fields.applicationScenarios.value.trim(),
document_hotel_type: fields.hotelType.value.trim(),
document_remark: fields.documentRemark.value.trim(),
}
}
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() { async function submitDocument() {
if (!fields.documentTitle.value.trim()) { if (!fields.documentTitle.value.trim()) {
setStatus('请先填写文档标题。', 'error') setStatus('请先填写文档标题。', 'error')
return return
} }
setStatus('正在上传附件并创建文档...', '') if (!fields.documentType.value.trim()) {
setStatus('请先填写文档类型。', 'error')
return
}
setStatus(state.mode === 'edit' ? '正在保存文档修改...' : '正在上传附件并创建文档...', '')
const uploadedAttachments = []
try { try {
let imageAttachment = null const newImageAttachments = await uploadPendingFiles(state.pendingImageFiles, 'image')
let videoAttachment = null const newVideoAttachments = await uploadPendingFiles(state.pendingVideoFiles, 'video')
const imageFile = fields.imageFile.files && fields.imageFile.files[0] uploadedAttachments.push.apply(uploadedAttachments, newImageAttachments)
const videoFile = fields.videoFile.files && fields.videoFile.files[0] uploadedAttachments.push.apply(uploadedAttachments, newVideoAttachments)
if (imageFile) { const finalImageAttachments = state.currentImageAttachments.concat(newImageAttachments)
imageAttachment = await uploadAttachment(imageFile, 'image') 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()
enterCreateMode()
if (deleteFailed.length) {
setStatus('文档已更新,但以下附件删除失败:' + deleteFailed.join(''), 'error')
return
}
setStatus('文档修改成功。', 'success')
return
} }
if (videoFile) { await requestJson('/document/create', payload)
videoAttachment = await uploadAttachment(videoFile, 'video')
}
await requestJson('/api/document/create', {
document_title: fields.documentTitle.value.trim(),
document_type: fields.documentType.value.trim(),
document_status: fields.documentStatus.value.trim(),
document_embedding_status: fields.embeddingStatus.value.trim(),
document_effect_date: fields.effectDate.value,
document_expiry_date: fields.expiryDate.value,
document_subtitle: fields.documentSubtitle.value.trim(),
document_summary: fields.documentSummary.value.trim(),
document_content: fields.documentContent.value.trim(),
document_image: imageAttachment ? imageAttachment.attachments_id : '',
document_video: videoAttachment ? videoAttachment.attachments_id : '',
document_relation_model: fields.relationModel.value.trim(),
document_keywords: fields.documentKeywords.value.trim(),
document_vector_version: fields.vectorVersion.value.trim(),
document_product_categories: fields.productCategories.value.trim(),
document_application_scenarios: fields.applicationScenarios.value.trim(),
document_hotel_type: fields.hotelType.value.trim(),
document_remark: fields.documentRemark.value.trim(),
})
resetForm()
await loadDocuments() 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>

View File

@@ -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 阶段性归档基线继续维护。

View File

@@ -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:
type: string oneOf:
description: 图片附件的 `attachments_id` - type: string
description: 多个图片附件 id 使用 `|` 分隔
- type: array
items:
type: string
description: 图片附件 id 列表;支持数组或 `|` 分隔字符串
document_video: document_video:
type: string oneOf:
description: 视频附件的 `attachments_id` - type: string
description: 多个视频附件 id 使用 `|` 分隔
- type: array
items:
type: string
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

View File

@@ -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` (加速父子级联查询)
--- ---

View File

@@ -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(' | ')}`);
} }