feat: 添加微信端 API 文档和字典表初始化脚本

- 新增 openapi-wx.yaml 文件,定义微信端接口文档,包括用户统计、token 刷新、微信登录和用户资料更新等接口。
- 新增 pocketbase.dictionary.js 脚本,用于初始化和校验字典表结构,确保与预期一致。
This commit is contained in:
2026-03-27 19:26:25 +08:00
parent c43aae783f
commit eaf282ea24
17 changed files with 2880 additions and 128 deletions

View File

@@ -127,6 +127,7 @@ function normalizeDictionaryItem(item, index) {
enum: String(current.enum),
description: String(current.description),
sortOrder: sortOrderNumber,
image: current.image ? String(current.image) : '',
}
}

View File

@@ -1,5 +1,6 @@
const { createAppError } = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/appError.js`)
const logger = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/utils/logger.js`)
const documentService = require(`${__hooks}/bai_api_pb_hooks/bai_api_shared/services/documentService.js`)
function buildSystemDictId() {
return 'DICT-' + new Date().getTime() + '-' + $security.randomString(6)
@@ -18,18 +19,31 @@ function safeJsonParse(text, fallback) {
function normalizeItemsFromRecord(record) {
const enums = safeJsonParse(record.getString('dict_word_enum'), [])
const descriptions = safeJsonParse(record.getString('dict_word_description'), [])
const images = safeJsonParse(record.getString('dict_word_image'), [])
const sortOrders = safeJsonParse(record.getString('dict_word_sort_order'), [])
const maxLength = Math.max(enums.length, descriptions.length, sortOrders.length)
const maxLength = Math.max(enums.length, descriptions.length, images.length, sortOrders.length)
const items = []
for (let i = 0; i < maxLength; i += 1) {
if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof sortOrders[i] === 'undefined') {
if (typeof enums[i] === 'undefined' && typeof descriptions[i] === 'undefined' && typeof images[i] === 'undefined' && typeof sortOrders[i] === 'undefined') {
continue
}
const imageAttachmentId = typeof images[i] === 'undefined' ? '' : String(images[i] || '')
let imageAttachment = null
if (imageAttachmentId) {
try {
imageAttachment = documentService.getAttachmentDetail(imageAttachmentId)
} catch (_error) {
imageAttachment = null
}
}
items.push({
enum: typeof enums[i] === 'undefined' ? '' : String(enums[i]),
description: typeof descriptions[i] === 'undefined' ? '' : String(descriptions[i]),
image: imageAttachmentId,
imageUrl: imageAttachment ? imageAttachment.attachments_url : '',
imageAttachment: imageAttachment,
sortOrder: Number(sortOrders[i] || 0),
})
}
@@ -69,16 +83,19 @@ function ensureDictionaryNameUnique(dictName, excludeId) {
function fillDictionaryItems(record, items) {
const enums = []
const descriptions = []
const images = []
const sortOrders = []
for (let i = 0; i < items.length; i += 1) {
enums.push(items[i].enum)
descriptions.push(items[i].description)
images.push(items[i].image || '')
sortOrders.push(items[i].sortOrder)
}
record.set('dict_word_enum', JSON.stringify(enums))
record.set('dict_word_description', JSON.stringify(descriptions))
record.set('dict_word_image', JSON.stringify(images))
record.set('dict_word_sort_order', JSON.stringify(sortOrders))
}
@@ -197,4 +214,4 @@ module.exports = {
createDictionary,
updateDictionary,
deleteDictionary,
}
}

View File

@@ -39,6 +39,54 @@ function normalizeDateValue(value) {
throw createAppError(400, '日期字段格式错误')
}
function extractDateOnly(value) {
const text = String(value || '').replace(/^\s+|\s+$/g, '')
const match = text.match(/^\d{4}-\d{2}-\d{2}/)
return match ? match[0] : ''
}
function getTodayDateString() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return year + '-' + month + '-' + day
}
function calculateDocumentStatus(effectDate, expiryDate, fallbackStatus) {
const start = extractDateOnly(effectDate)
const end = extractDateOnly(expiryDate)
const today = getTodayDateString()
if (!start && !end) {
return fallbackStatus === '过期' ? '过期' : '有效'
}
if (start && today < start) {
return '过期'
}
if (end && today > end) {
return '过期'
}
return '有效'
}
function ensureDocumentStatus(record) {
const nextStatus = calculateDocumentStatus(
record.get('document_effect_date'),
record.get('document_expiry_date'),
record.getString('document_status')
)
if (record.getString('document_status') !== nextStatus) {
record.set('document_status', nextStatus)
try {
$app.save(record)
} catch (_err) {}
}
return nextStatus
}
function normalizeNumberValue(value, fieldName) {
if (value === '' || value === null || typeof value === 'undefined') {
return 0
@@ -147,6 +195,7 @@ function resolveAttachmentList(value) {
}
function exportDocumentRecord(record) {
const documentStatus = ensureDocumentStatus(record)
const imageAttachmentList = resolveAttachmentList(record.getString('document_image'))
const videoAttachmentList = resolveAttachmentList(record.getString('document_video'))
const firstImageAttachment = imageAttachmentList.attachments.length ? imageAttachmentList.attachments[0] : null
@@ -180,7 +229,7 @@ function exportDocumentRecord(record) {
document_share_count: record.get('document_share_count'),
document_download_count: record.get('document_download_count'),
document_favorite_count: record.get('document_favorite_count'),
document_status: record.getString('document_status'),
document_status: documentStatus,
document_embedding_status: record.getString('document_embedding_status'),
document_embedding_error: record.getString('document_embedding_error'),
document_embedding_lasttime: String(record.get('document_embedding_lasttime') || ''),
@@ -394,10 +443,13 @@ function createDocument(userOpenid, payload) {
return $app.runInTransaction(function (txApp) {
const collection = txApp.findCollectionByNameOrId('tbl_document')
const record = new Record(collection)
const effectDateValue = normalizeDateValue(payload.document_effect_date)
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status)
record.set('document_id', targetDocumentId)
record.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
record.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
record.set('document_effect_date', effectDateValue)
record.set('document_expiry_date', expiryDateValue)
record.set('document_type', payload.document_type || '')
record.set('document_title', payload.document_title || '')
record.set('document_subtitle', payload.document_subtitle || '')
@@ -411,7 +463,7 @@ function createDocument(userOpenid, payload) {
record.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
record.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
record.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
record.set('document_status', payload.document_status || '')
record.set('document_status', documentStatus)
record.set('document_embedding_status', payload.document_embedding_status || '')
record.set('document_embedding_error', payload.document_embedding_error || '')
record.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))
@@ -446,9 +498,12 @@ function updateDocument(userOpenid, payload) {
return $app.runInTransaction(function (txApp) {
const target = txApp.findRecordById('tbl_document', record.id)
const effectDateValue = normalizeDateValue(payload.document_effect_date)
const expiryDateValue = normalizeDateValue(payload.document_expiry_date)
const documentStatus = calculateDocumentStatus(effectDateValue, expiryDateValue, payload.document_status)
target.set('document_effect_date', normalizeDateValue(payload.document_effect_date))
target.set('document_expiry_date', normalizeDateValue(payload.document_expiry_date))
target.set('document_effect_date', effectDateValue)
target.set('document_expiry_date', expiryDateValue)
target.set('document_type', payload.document_type || '')
target.set('document_title', payload.document_title || '')
target.set('document_subtitle', payload.document_subtitle || '')
@@ -461,7 +516,7 @@ function updateDocument(userOpenid, payload) {
target.set('document_share_count', normalizeOptionalNumberValue(payload.document_share_count, 'document_share_count'))
target.set('document_download_count', normalizeOptionalNumberValue(payload.document_download_count, 'document_download_count'))
target.set('document_favorite_count', normalizeOptionalNumberValue(payload.document_favorite_count, 'document_favorite_count'))
target.set('document_status', payload.document_status || '')
target.set('document_status', documentStatus)
target.set('document_embedding_status', payload.document_embedding_status || '')
target.set('document_embedding_error', payload.document_embedding_error || '')
target.set('document_embedding_lasttime', normalizeDateValue(payload.document_embedding_lasttime))

View File

@@ -19,6 +19,10 @@ function buildUserId() {
return 'U' + date + suffix
}
function buildUserConversId() {
return 'UC-' + new Date().getTime() + '-' + $security.randomString(6)
}
function buildGuid() {
return $security.randomString(8) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(4) + '-' + $security.randomString(12)
}
@@ -186,7 +190,17 @@ function ensureUserId(record) {
}
}
function ensureUsersConversId(record) {
if (!record.getString('users_convers_id')) {
record.set('users_convers_id', buildUserConversId())
}
}
function saveAuthUserRecord(record) {
ensureUserId(record)
ensureUsersConversId(record)
ensureAuthIdentity(record)
try {
$app.save(record)
} catch (err) {
@@ -227,11 +241,9 @@ function authenticateWechatUser(payload) {
const collection = $app.findCollectionByNameOrId('tbl_auth_users')
const record = new Record(collection)
record.set('openid', openid)
ensureUserId(record)
record.set('users_idtype', WECHAT_ID_TYPE)
record.set('users_type', GUEST_USER_TYPE)
record.set('users_auth_type', 0)
ensureAuthIdentity(record)
saveAuthUserRecord(record)
const user = enrichUser(record)
@@ -278,7 +290,6 @@ function registerPlatformUser(payload) {
const record = new Record(collection)
record.set('openid', platformOpenid)
ensureUserId(record)
record.set('users_idtype', MANAGE_PLATFORM_ID_TYPE)
record.set('users_name', payload.users_name)
record.set('users_phone', payload.users_phone)

View File

@@ -17,18 +17,18 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
.container { max-width: 1240px; margin: 0 auto; padding: 32px 20px 60px; }
.container { max-width: 1440px; margin: 0 auto; padding: 24px 14px 40px; }
.topbar, .panel, .modal-card { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); }
.topbar { border-radius: 24px; padding: 24px; margin-bottom: 24px; }
.topbar { border-radius: 20px; padding: 18px; margin-bottom: 14px; }
.topbar h1 { margin: 0 0 8px; font-size: 30px; }
.topbar p { margin: 0; color: #4b5563; line-height: 1.7; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 18px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.btn { border: none; border-radius: 12px; padding: 10px 16px; font-weight: 600; cursor: pointer; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-secondary { background: #e0e7ff; color: #1e3a8a; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-light { background: #f8fafc; color: #334155; border: 1px solid #dbe3f0; }
.panel { border-radius: 24px; padding: 24px; }
.panel { border-radius: 20px; padding: 18px; }
.toolbar { display: grid; grid-template-columns: 1.3fr 1fr 1fr auto auto; gap: 12px; margin-bottom: 18px; }
input, textarea, select { width: 100%; border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px 12px; font-size: 14px; background: #fff; }
textarea { min-height: 88px; resize: vertical; }
@@ -47,9 +47,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
.status.success { color: #15803d; }
.status.error { color: #b91c1c; }
.auth-box { display: grid; grid-template-columns: 1fr auto; gap: 12px; margin-top: 18px; }
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.4); padding: 20px; }
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.4); padding: 14px; }
.modal.show { display: flex; }
.modal-card { width: min(920px, 100%); border-radius: 24px; padding: 24px; max-height: 90vh; overflow: auto; }
.modal-card { width: min(1104px, 100%); border-radius: 20px; padding: 18px; max-height: 90vh; overflow: auto; }
.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.modal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.full { grid-column: 1 / -1; }
@@ -57,6 +57,16 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
.item-table table { border-radius: 0; }
.modal-actions { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.empty { text-align: center; padding: 32px 16px; color: #64748b; }
.thumb { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; border: 1px solid #dbe3f0; background: #fff; }
.thumb-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.thumb-meta { font-size: 12px; color: #64748b; word-break: break-all; }
.enum-preview-item { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.enum-preview-more { margin-top: 8px; display: none; }
.enum-preview-toggle { margin-top: 6px; }
.image-upload-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; margin-top: 10px; }
.image-upload-row input[type="file"] { width: 100%; min-width: 0; }
.icon-btn { min-width: 36px; padding: 6px 10px; line-height: 1; font-size: 18px; }
.drop-tip { color: #64748b; font-size: 12px; margin-top: 6px; }
@media (max-width: 960px) {
.toolbar, .auth-box, .modal-grid { grid-template-columns: 1fr; }
table, thead, tbody, th, td, tr { display: block; }
@@ -71,10 +81,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
<div class="container">
<section class="topbar">
<h1>字典管理</h1>
<p>页面仅允许 ManagePlatform 用户操作。当前请求会自动使用登录页保存到 localStorage 的 token。</p>
<div class="actions">
<a class="btn btn-light" href="/pb/manage">返回主页</a>
<a class="btn btn-light" href="/pb/manage/login">登录页</a>
<button class="btn btn-primary" id="createBtn" type="button">新增字典</button>
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
</div>
@@ -114,7 +122,6 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
<div class="modal-header">
<div>
<h2 id="modalTitle" style="margin:0;">新增字典</h2>
<div class="muted">三个聚合字段将以 JSON 字符串形式保存,并在读取时自动还原为 items。</div>
</div>
<button class="btn btn-light" id="closeModalBtn" type="button">关闭</button>
</div>
@@ -140,8 +147,8 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
<table>
<thead>
<tr>
<th>枚举值</th>
<th>描述</th>
<th>图片</th>
<th>排序</th>
<th>操作</th>
</tr>
@@ -165,11 +172,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
<script>
const API_BASE = '/pb/api'
const tokenKey = 'pb_manage_token'
const enumChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const state = {
list: [],
mode: 'create',
editingOriginalName: '',
items: [],
enumSeed: '',
enumCounter: 1,
expandedPreviewKey: '',
}
const statusEl = document.getElementById('status')
@@ -224,6 +235,38 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
return data.data
}
async function uploadAttachment(file, label) {
const token = getToken()
if (!token) {
localStorage.removeItem('pb_manage_logged_in')
window.location.replace('/pb/manage/login')
throw new Error('登录状态已失效,请重新登录')
}
const form = new FormData()
form.append('attachments_link', file)
form.append('attachments_filename', file.name || '')
form.append('attachments_filetype', file.type || '')
form.append('attachments_size', String(file.size || 0))
form.append('attachments_status', 'active')
form.append('attachments_remark', 'dictionary-manage:' + label)
const res = await fetch(API_BASE + '/attachment/upload', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
},
body: form,
})
const data = await res.json()
if (!res.ok || !data || data.code >= 400) {
throw new Error((data && data.msg) || '上传图片失败')
}
return data.data
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
@@ -233,11 +276,86 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
.replace(/'/g, '&#39;')
}
function renderItemsPreview(items) {
function randomEnumSeed() {
const first = enumChars[Math.floor(Math.random() * enumChars.length)]
const second = enumChars[Math.floor(Math.random() * enumChars.length)]
return first + second
}
function ensureEnumSeed() {
if (!state.enumSeed) {
state.enumSeed = randomEnumSeed()
}
}
function nextAutoEnum(usedSet) {
ensureEnumSeed()
let guard = 0
while (guard < 10000) {
const candidate = state.enumSeed + String(state.enumCounter)
state.enumCounter += 1
if (!usedSet.has(candidate)) {
return candidate
}
guard += 1
}
state.enumSeed = randomEnumSeed()
state.enumCounter = 1
return nextAutoEnum(usedSet)
}
function normalizeItemEnums() {
const used = new Set()
state.items = state.items.map(function (item) {
const current = {
enum: String((item && item.enum) || '').trim(),
description: String((item && item.description) || ''),
image: (item && item.image) || '',
imageUrl: (item && item.imageUrl) || '',
imageAttachment: (item && item.imageAttachment) || null,
sortOrder: Number((item && item.sortOrder) || 0),
}
if (!current.enum || used.has(current.enum)) {
current.enum = nextAutoEnum(used)
}
used.add(current.enum)
if (!Number.isFinite(current.sortOrder) || current.sortOrder <= 0) {
current.sortOrder = used.size
}
return current
})
}
function renderEnumPreviewItem(item) {
const desc = escapeHtml(item && item.description ? item.description : '(无描述)')
const imageHtml = item && item.imageUrl
? '<img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" />'
: '<span class="muted">无图</span>'
return '<div class="enum-preview-item"><span>' + desc + '</span>' + imageHtml + '</div>'
}
function renderItemsPreview(items, previewKey, isExpanded) {
if (!items || !items.length) return '<span class="muted">无</span>'
return items.map(function (item) {
return '<div><strong>' + escapeHtml(item.enum) + '</strong> → ' + escapeHtml(item.description) + '(排序 ' + escapeHtml(item.sortOrder) + '</div>'
const first = renderEnumPreviewItem(items[0])
if (items.length === 1) {
return first
}
const rest = items.slice(1).map(function (item) {
return renderEnumPreviewItem(item)
}).join('')
const hiddenCount = items.length - 1
const moreStyle = isExpanded ? 'display:block;' : 'display:none;'
const toggleText = isExpanded ? '收起' : ('展开其余 ' + hiddenCount + ' 项')
return first
+ '<div class="enum-preview-more" style="' + moreStyle + '">' + rest + '</div>'
+ '<button class="btn btn-light enum-preview-toggle" type="button" onclick="window.__toggleEnumPreview(\\'' + previewKey + '\\')">' + toggleText + '</button>'
}
function renderTable(list) {
@@ -246,14 +364,15 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
return
}
tableBody.innerHTML = list.map(function (item) {
tableBody.innerHTML = list.map(function (item, index) {
const enabledClass = item.dict_word_is_enabled ? 'badge badge-on' : 'badge badge-off'
const enabledText = item.dict_word_is_enabled ? '启用' : '禁用'
const previewKey = String(index)
return '<tr>'
+ '<td data-label="字典名称"><input class="inline-input" data-field="dict_name" data-name="' + escapeHtml(item.dict_name) + '" value="' + escapeHtml(item.dict_name) + '" /></td>'
+ '<td data-label="启用"><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="枚举项">' + renderItemsPreview(item.items) + '</td>'
+ '<td data-label="枚举项">' + renderItemsPreview(item.items, previewKey, state.expandedPreviewKey === previewKey) + '</td>'
+ '<td data-label="创建时间"><span class="muted">' + escapeHtml(item.created) + '</span></td>'
+ '<td data-label="操作">'
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;">'
@@ -269,13 +388,25 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
function openModal(mode, record) {
state.mode = mode
state.editingOriginalName = record ? record.dict_name : ''
state.enumSeed = randomEnumSeed()
state.enumCounter = 1
modalTitle.textContent = mode === 'create' ? '新增字典' : '编辑枚举项'
dictNameInput.value = record ? record.dict_name : ''
enabledInput.value = record && !record.dict_word_is_enabled ? 'false' : 'true'
remarkInput.value = record ? (record.dict_word_remark || '') : ''
state.items = record && Array.isArray(record.items) && record.items.length
? record.items.map(function (item) { return { enum: item.enum, description: item.description, sortOrder: item.sortOrder } })
: [{ enum: '', description: '', sortOrder: 1 }]
? record.items.map(function (item) {
return {
enum: item.enum,
description: item.description,
image: item.image || '',
imageUrl: item.imageUrl || '',
imageAttachment: item.imageAttachment || null,
sortOrder: item.sortOrder,
}
})
: [{ enum: '', description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: 1 }]
normalizeItemEnums()
renderItemsEditor()
editorModal.classList.add('show')
}
@@ -284,36 +415,110 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
editorModal.classList.remove('show')
}
function scrollEditorModalToBottom() {
const modalCard = editorModal.querySelector('.modal-card')
if (!modalCard) {
return
}
const doScroll = function () {
modalCard.scrollTo({
top: modalCard.scrollHeight,
behavior: 'smooth',
})
}
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(function () {
requestAnimationFrame(doScroll)
})
} else {
setTimeout(doScroll, 40)
}
// Fallback: handle heavy DOM updates that render slightly later.
setTimeout(doScroll, 120)
}
function renderItemsEditor() {
itemsBody.innerHTML = state.items.map(function (item, index) {
const imageCell = item.imageUrl
? '<div class="thumb-row"><img class="thumb" src="' + escapeHtml(item.imageUrl) + '" alt="" /><div class="thumb-meta">' + escapeHtml(item.image || '') + '</div></div>'
: '<div class="muted">未上传图片</div>'
return '<tr>'
+ '<td><input data-item-field="enum" data-index="' + index + '" value="' + escapeHtml(item.enum) + '" /></td>'
+ '<td><input data-item-field="description" data-index="' + index + '" value="' + escapeHtml(item.description) + '" /></td>'
+ '<td ondragover="window.__allowItemDrop(event)" ondrop="window.__dropItemImage(' + index + ', event)">'
+ imageCell
+ '<div class="image-upload-row">'
+ '<input type="file" accept="image/*" onchange="window.__uploadItemImage(' + index + ', this)" />'
+ '<button class="btn btn-light icon-btn" type="button" title="删除图片" onclick="window.__clearItemImage(' + index + ')">🗑️</button>'
+ '</div>'
+ '<div class="drop-tip">支持拖拽图片到本区域上传</div>'
+ '</td>'
+ '<td><input type="number" data-item-field="sortOrder" data-index="' + index + '" value="' + escapeHtml(item.sortOrder) + '" /></td>'
+ '<td><button class="btn btn-danger" type="button" onclick="window.__removeItem(' + index + ')">删除</button></td>'
+ '</tr>'
}).join('')
}
async function setItemImageFromFile(index, file) {
if (!file) {
return
}
syncItemsStateFromEditor()
if (!state.items[index]) {
return
}
setStatus('正在上传字典项图片...', '')
try {
const attachment = await uploadAttachment(file, 'dict-item-' + (index + 1))
state.items[index].image = attachment.attachments_id
state.items[index].imageUrl = attachment.attachments_url || ''
state.items[index].imageAttachment = attachment
renderItemsEditor()
setStatus('字典项图片上传成功。', 'success')
} catch (err) {
setStatus(err.message || '字典项图片上传失败', 'error')
}
}
function collectItemsFromEditor() {
const rows = Array.from(itemsBody.querySelectorAll('tr'))
return rows.map(function (row, index) {
const enumInput = row.querySelector('[data-item-field="enum"]')
const descriptionInput = row.querySelector('[data-item-field="description"]')
const sortOrderInput = row.querySelector('[data-item-field="sortOrder"]')
return {
enum: enumInput.value.trim(),
enum: state.items[index] ? String(state.items[index].enum || '') : '',
description: descriptionInput.value.trim(),
image: state.items[index] ? (state.items[index].image || '') : '',
imageUrl: state.items[index] ? (state.items[index].imageUrl || '') : '',
imageAttachment: state.items[index] ? (state.items[index].imageAttachment || null) : null,
sortOrder: Number(sortOrderInput.value || index + 1),
}
})
}
function syncItemsStateFromEditor() {
const rows = itemsBody.querySelectorAll('tr')
if (!rows.length) {
return
}
state.items = collectItemsFromEditor()
}
async function uploadItemImage(index, inputEl) {
const file = inputEl && inputEl.files && inputEl.files[0]
await setItemImageFromFile(index, file)
}
async function loadList() {
setStatus('正在查询字典列表...', '')
try {
const data = await request(API_BASE + '/dictionary/list', { keyword: keywordInput.value.trim() })
state.list = data.items || []
state.expandedPreviewKey = ''
renderTable(state.list)
setStatus('查询成功,共 ' + state.list.length + ' 条。', 'success')
} catch (err) {
@@ -332,6 +537,7 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
try {
const data = await request(API_BASE + '/dictionary/detail', { dict_name: dictName })
state.list = [data]
state.expandedPreviewKey = ''
renderTable(state.list)
setStatus('查询详情成功。', 'success')
} catch (err) {
@@ -340,7 +546,9 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
}
async function saveModalRecord() {
const items = collectItemsFromEditor()
syncItemsStateFromEditor()
normalizeItemEnums()
const items = state.items
const payload = {
dict_name: dictNameInput.value.trim(),
original_dict_name: state.editingOriginalName,
@@ -420,11 +628,38 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
window.__saveInline = saveInline
window.__deleteRow = deleteRow
window.__toggleEnumPreview = function (previewKey) {
state.expandedPreviewKey = state.expandedPreviewKey === previewKey ? '' : previewKey
renderTable(state.list)
}
window.__uploadItemImage = uploadItemImage
window.__allowItemDrop = function (event) {
event.preventDefault()
}
window.__dropItemImage = function (index, event) {
event.preventDefault()
const files = event.dataTransfer && event.dataTransfer.files
const file = files && files[0]
setItemImageFromFile(index, file)
}
window.__clearItemImage = function (index) {
syncItemsStateFromEditor()
if (!state.items[index]) {
return
}
state.items[index].image = ''
state.items[index].imageUrl = ''
state.items[index].imageAttachment = null
renderItemsEditor()
setStatus('已清空该枚举项图片。', 'success')
}
window.__removeItem = function (index) {
syncItemsStateFromEditor()
state.items.splice(index, 1)
if (!state.items.length) {
state.items.push({ enum: '', description: '', sortOrder: 1 })
state.items.push({ enum: '', description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: 1 })
}
normalizeItemEnums()
renderItemsEditor()
}
@@ -441,8 +676,11 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
document.getElementById('closeModalBtn').addEventListener('click', closeModal)
document.getElementById('cancelBtn').addEventListener('click', closeModal)
document.getElementById('addItemBtn').addEventListener('click', function () {
state.items.push({ enum: '', description: '', sortOrder: state.items.length + 1 })
syncItemsStateFromEditor()
const used = new Set(state.items.map(function (item) { return String(item.enum || '') }))
state.items.push({ enum: nextAutoEnum(used), description: '', image: '', imageUrl: '', imageAttachment: null, sortOrder: state.items.length + 1 })
renderItemsEditor()
scrollEditorModalToBottom()
})
document.getElementById('saveBtn').addEventListener('click', saveModalRecord)
document.getElementById('logoutBtn').addEventListener('click', function () {
@@ -452,9 +690,11 @@ routerAdd('GET', '/manage/dictionary-manage', function (e) {
localStorage.removeItem('pb_manage_login_time')
window.location.replace('/pb/manage/login')
})
loadList()
</script>
</body>
</html>`
return e.html(200, html)
})
})

View File

@@ -17,9 +17,9 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #eef6ff 0%, #f8fafc 100%); color: #1f2937; }
.container { max-width: 1280px; margin: 0 auto; padding: 32px 20px 64px; }
.panel { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); border-radius: 24px; padding: 24px; }
.panel + .panel { margin-top: 24px; }
.container { max-width: 1440px; margin: 0 auto; padding: 24px 14px 40px; }
.panel { background: rgba(255,255,255,0.96); border: 1px solid #e5e7eb; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); border-radius: 20px; padding: 18px; }
.panel + .panel { margin-top: 14px; }
h1, h2 { margin-top: 0; }
p { color: #4b5563; line-height: 1.7; }
.actions, .form-actions, .toolbar, .table-actions, .file-actions { display: flex; flex-wrap: wrap; gap: 12px; }
@@ -48,6 +48,16 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.editor-banner { display: inline-flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 13px; font-weight: 700; }
.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; }
.option-box { border: 1px solid #dbe3f0; border-radius: 16px; background: #f8fbff; padding: 14px; }
.option-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.option-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: #fff; border: 1px solid #e5e7eb; min-height: 48px; }
.option-item input[type="checkbox"] { width: auto; margin: 0; }
.option-item span { display: block; word-break: break-word; }
.selection-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.selection-tag { display: inline-flex; align-items: center; padding: 4px 10px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 12px; font-weight: 700; }
.choice-switch { display: inline-flex; gap: 8px; padding: 6px; border-radius: 14px; border: 1px solid #dbe3f0; background: #f8fbff; }
.choice-switch button { border: 1px solid transparent; background: transparent; color: #475569; padding: 9px 18px; border-radius: 10px; font-weight: 700; cursor: pointer; }
.choice-switch button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.dropzone { margin-top: 8px; border: 2px dashed #bfdbfe; border-radius: 16px; background: #ffffff; padding: 16px; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; cursor: pointer; }
.dropzone.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; }
@@ -60,6 +70,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
.file-meta { color: #64748b; font-size: 12px; margin-top: 6px; word-break: break-all; }
@media (max-width: 960px) {
.grid, .file-group { grid-template-columns: 1fr; }
.option-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { margin-bottom: 14px; border: 1px solid #e5e7eb; border-radius: 16px; overflow: hidden; }
@@ -72,10 +83,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<div class="container">
<section class="panel">
<h1>文档管理</h1>
<p>页面会通过 <code>/pb/api/*</code> 调用 PocketBase hooks。图片和视频都支持多选上传编辑已有文档时会先回显当前已绑定的多图片、多视频并支持从文档中移除或继续追加附件。</p>
<div class="actions">
<a class="btn btn-light" href="/pb/manage">返回主页</a>
<a class="btn btn-light" href="/pb/manage/login">登录页</a>
<button class="btn btn-light" id="reloadBtn" type="button">刷新列表</button>
<button class="btn btn-light" id="createModeBtn" type="button">新建模式</button>
<button class="btn btn-danger" id="logoutBtn" type="button">退出登录</button>
@@ -87,7 +96,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<div class="toolbar" style="justify-content:space-between;align-items:center;">
<div>
<h2 id="formTitle">新增文档</h2>
<div class="muted">只有文档标题和文档类型为必填,其余字段允许为空。</div>
</div>
<div class="editor-banner" id="editorMode">当前模式:新建</div>
</div>
@@ -97,16 +105,31 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<input id="documentTitle" placeholder="请输入文档标题" />
</div>
<div>
<label for="documentType">文档类型</label>
<input id="documentType" placeholder="例如:说明书、新闻、常见故障" />
<label for="embeddingStatus">嵌入状态</label>
<input id="embeddingStatus" placeholder="可为空" />
</div>
<div class="full">
<label for="documentSubtitle">副标题</label>
<input id="documentSubtitle" placeholder="可选" />
</div>
<div>
<label for="documentTypeSource">文档类型</label>
<div class="option-box">
<select id="documentTypeSource">
<option value="">请选择数据来源字典</option>
</select>
<div class="option-list" id="documentTypeOptions"></div>
<div class="selection-tags" id="documentTypeTags"></div>
</div>
</div>
<div>
<label for="documentStatus">文档状态</label>
<input id="documentStatus" placeholder="可为空" />
</div>
<div>
<label for="embeddingStatus">嵌入状态</label>
<input id="embeddingStatus" placeholder="可为空" />
<input id="documentStatus" type="hidden" value="有效" />
<div class="choice-switch" id="documentStatusSwitch">
<button type="button" data-status-value="有效" class="active">有效</button>
<button type="button" data-status-value="过期">过期</button>
</div>
<div class="hint">系统会根据生效日期和到期日期自动切换状态;都不填写时默认有效。</div>
</div>
<div>
<label for="effectDate">生效日期</label>
@@ -116,10 +139,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<label for="expiryDate">到期日期</label>
<input id="expiryDate" type="date" />
</div>
<div class="full">
<label for="documentSubtitle">副标题</label>
<input id="documentSubtitle" placeholder="可选" />
</div>
<div class="full">
<label for="documentSummary">摘要</label>
<textarea id="documentSummary" placeholder="请输入文档摘要"></textarea>
@@ -137,21 +156,36 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<input id="vectorVersion" placeholder="可选" />
</div>
<div>
<label for="documentKeywords">关键词</label>
<input id="documentKeywords" placeholder="多个值用 | 分隔" />
<div class="hint">例如:安装|配置|排障</div>
<label>关键词</label>
<div class="option-box">
<div class="hint">可不选,也可多选。</div>
<div class="option-list" id="documentKeywordsOptions"></div>
<div class="selection-tags" id="documentKeywordsTags"></div>
</div>
</div>
<div>
<label for="productCategories">适用产品类别</label>
<input id="productCategories" placeholder="多个值用 | 分隔" />
<label>产品关联文档</label>
<div class="option-box">
<div class="hint">可不选,也可多选。</div>
<div class="option-list" id="productCategoriesOptions"></div>
<div class="selection-tags" id="productCategoriesTags"></div>
</div>
</div>
<div>
<label for="applicationScenarios">适用场景</label>
<input id="applicationScenarios" placeholder="多个值用 | 分隔" />
<label>筛选依据</label>
<div class="option-box">
<div class="hint">可不选,也可多选。</div>
<div class="option-list" id="applicationScenariosOptions"></div>
<div class="selection-tags" id="applicationScenariosTags"></div>
</div>
</div>
<div>
<label for="hotelType">适用酒店类型</label>
<input id="hotelType" placeholder="多个值用 | 分隔" />
<label>适用场景</label>
<div class="option-box">
<div class="hint">可不选,也可多选。</div>
<div class="option-list" id="hotelTypeOptions"></div>
<div class="selection-tags" id="hotelTypeTags"></div>
</div>
</div>
<div class="full">
<label for="documentRemark">备注</label>
@@ -164,7 +198,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<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>
@@ -175,7 +208,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<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>
@@ -195,7 +227,6 @@ routerAdd('GET', '/manage/document-manage', function (e) {
<table>
<thead>
<tr>
<th>document_id</th>
<th>标题</th>
<th>类型/状态</th>
<th>附件链接</th>
@@ -224,9 +255,20 @@ routerAdd('GET', '/manage/document-manage', function (e) {
const videoPendingListEl = document.getElementById('videoPendingList')
const imageDropzoneEl = document.getElementById('imageDropzone')
const videoDropzoneEl = document.getElementById('videoDropzone')
const documentTypeSourceEl = document.getElementById('documentTypeSource')
const documentTypeOptionsEl = document.getElementById('documentTypeOptions')
const documentTypeTagsEl = document.getElementById('documentTypeTags')
const documentStatusSwitchEl = document.getElementById('documentStatusSwitch')
const documentKeywordsOptionsEl = document.getElementById('documentKeywordsOptions')
const documentKeywordsTagsEl = document.getElementById('documentKeywordsTags')
const productCategoriesOptionsEl = document.getElementById('productCategoriesOptions')
const productCategoriesTagsEl = document.getElementById('productCategoriesTags')
const applicationScenariosOptionsEl = document.getElementById('applicationScenariosOptions')
const applicationScenariosTagsEl = document.getElementById('applicationScenariosTags')
const hotelTypeOptionsEl = document.getElementById('hotelTypeOptions')
const hotelTypeTagsEl = document.getElementById('hotelTypeTags')
const fields = {
documentTitle: document.getElementById('documentTitle'),
documentType: document.getElementById('documentType'),
documentStatus: document.getElementById('documentStatus'),
embeddingStatus: document.getElementById('embeddingStatus'),
effectDate: document.getElementById('effectDate'),
@@ -236,14 +278,32 @@ routerAdd('GET', '/manage/document-manage', function (e) {
documentContent: document.getElementById('documentContent'),
relationModel: document.getElementById('relationModel'),
vectorVersion: document.getElementById('vectorVersion'),
documentKeywords: document.getElementById('documentKeywords'),
productCategories: document.getElementById('productCategories'),
applicationScenarios: document.getElementById('applicationScenarios'),
hotelType: document.getElementById('hotelType'),
documentRemark: document.getElementById('documentRemark'),
imageFile: document.getElementById('imageFile'),
videoFile: document.getElementById('videoFile'),
}
const dictionaryFieldConfig = {
documentKeywords: {
dictName: '文档-关键词',
optionsEl: documentKeywordsOptionsEl,
tagsEl: documentKeywordsTagsEl,
},
productCategories: {
dictName: '文档-产品关联文档',
optionsEl: productCategoriesOptionsEl,
tagsEl: productCategoriesTagsEl,
},
applicationScenarios: {
dictName: '文档-筛选依据',
optionsEl: applicationScenariosOptionsEl,
tagsEl: applicationScenariosTagsEl,
},
hotelType: {
dictName: '文档-适用场景',
optionsEl: hotelTypeOptionsEl,
tagsEl: hotelTypeTagsEl,
},
}
const state = {
list: [],
mode: 'create',
@@ -254,6 +314,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
pendingImageFiles: [],
pendingVideoFiles: [],
removedAttachmentIds: [],
dictionaries: [],
dictionariesByName: {},
dictionariesById: {},
selections: {
documentTypeSource: '',
documentTypeValues: [],
documentKeywords: [],
productCategories: [],
applicationScenarios: [],
hotelType: [],
},
}
function setStatus(message, type) {
@@ -275,6 +346,46 @@ routerAdd('GET', '/manage/document-manage', function (e) {
return match ? match[0] : ''
}
function getTodayDateString() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return year + '-' + month + '-' + day
}
function calculateDocumentStatus(effectDate, expiryDate, fallbackStatus) {
const start = toDateInputValue(effectDate)
const end = toDateInputValue(expiryDate)
const today = getTodayDateString()
if (!start && !end) {
return fallbackStatus === '过期' ? '过期' : '有效'
}
if (start && today < start) {
return '过期'
}
if (end && today > end) {
return '过期'
}
return '有效'
}
function updateDocumentStatusSwitch() {
const buttons = documentStatusSwitchEl ? documentStatusSwitchEl.querySelectorAll('[data-status-value]') : []
const currentValue = fields.documentStatus.value || '有效'
for (let i = 0; i < buttons.length; i += 1) {
const button = buttons[i]
const isActive = button.getAttribute('data-status-value') === currentValue
button.classList.toggle('active', isActive)
}
}
function syncDocumentStatus(preferredStatus) {
fields.documentStatus.value = calculateDocumentStatus(fields.effectDate.value, fields.expiryDate.value, preferredStatus || fields.documentStatus.value || '有效')
updateDocumentStatusSwitch()
}
function normalizeAttachmentList(attachments, ids, urls) {
const attachmentArray = Array.isArray(attachments) ? attachments : []
if (attachmentArray.length) {
@@ -304,6 +415,154 @@ routerAdd('GET', '/manage/document-manage', function (e) {
})
}
function splitPipeValue(value) {
return String(value || '')
.split('|')
.map(function (item) { return String(item || '').trim() })
.filter(function (item) { return !!item })
}
function joinPipeValue(values) {
return Array.from(new Set((values || []).map(function (item) {
return String(item || '').trim()
}).filter(function (item) {
return !!item
}))).join('|')
}
function findDictionaryByName(dictName) {
return state.dictionariesByName[dictName] || null
}
function getDocumentTypeSourceDictionary() {
const sourceId = String(state.selections.documentTypeSource || '')
return sourceId ? (state.dictionariesById[sourceId] || null) : null
}
function buildDocumentTypeStorageValue() {
const sourceDict = getDocumentTypeSourceDictionary()
if (!sourceDict) {
return ''
}
return joinPipeValue(state.selections.documentTypeValues.map(function (enumValue) {
return sourceDict.system_dict_id + '@' + enumValue
}))
}
function updateSelection(fieldName, value, checked) {
const target = state.selections[fieldName] || []
const next = target.filter(function (item) {
return item !== value
})
if (checked) {
next.push(value)
}
state.selections[fieldName] = next
renderDictionarySelectors()
}
function renderSelectionTags(tagsEl, values, labelsMap) {
if (!values.length) {
tagsEl.innerHTML = '<span class="muted">未选择</span>'
return
}
tagsEl.innerHTML = values.map(function (value) {
return '<span class="selection-tag">' + escapeHtml(labelsMap[value] || value) + '</span>'
}).join('')
}
function formatDocumentTypeDisplay(value) {
const parts = splitPipeValue(value)
if (!parts.length) {
return ''
}
return parts.map(function (part) {
const separatorIndex = part.indexOf('@')
if (separatorIndex === -1) {
return part
}
const sourceId = part.slice(0, separatorIndex)
const enumValue = part.slice(separatorIndex + 1)
const dict = state.dictionariesById[sourceId]
if (!dict || !Array.isArray(dict.items)) {
return enumValue
}
const matched = dict.items.find(function (item) {
return String(item.enum || '') === enumValue
})
return matched ? (matched.description || matched.enum || enumValue) : enumValue
}).join(' | ')
}
function renderOptionList(optionsEl, selectedValues, items, fieldName) {
if (!items || !items.length) {
optionsEl.innerHTML = '<div class="muted">暂无可选项</div>'
return {}
}
const labelsMap = {}
optionsEl.innerHTML = items.map(function (item) {
const optionValue = String(item.enum || '')
const checked = selectedValues.indexOf(optionValue) !== -1
labelsMap[optionValue] = item.description || item.enum || optionValue
return '<label class="option-item">'
+ '<input type="checkbox" data-selection-field="' + fieldName + '" value="' + escapeHtml(optionValue) + '"' + (checked ? ' checked' : '') + ' />'
+ '<span>' + escapeHtml(item.description || item.enum || optionValue) + '</span>'
+ '</label>'
}).join('')
return labelsMap
}
function renderDocumentTypeSourceOptions() {
const currentValue = String(state.selections.documentTypeSource || '')
documentTypeSourceEl.innerHTML = ['<option value="">请选择数据来源字典</option>']
.concat(state.dictionaries.map(function (dict) {
return '<option value="' + escapeHtml(dict.system_dict_id) + '"' + (currentValue === dict.system_dict_id ? ' selected' : '') + '>' + escapeHtml(dict.dict_name) + '</option>'
}))
.join('')
}
function renderDictionarySelectors() {
renderDocumentTypeSourceOptions()
const sourceDict = getDocumentTypeSourceDictionary()
const sourceItems = sourceDict && Array.isArray(sourceDict.items) ? sourceDict.items : []
const documentTypeLabels = renderOptionList(documentTypeOptionsEl, state.selections.documentTypeValues, sourceItems, 'documentTypeValues')
renderSelectionTags(documentTypeTagsEl, state.selections.documentTypeValues, documentTypeLabels)
Object.keys(dictionaryFieldConfig).forEach(function (fieldName) {
const config = dictionaryFieldConfig[fieldName]
const dict = findDictionaryByName(config.dictName)
const items = dict && Array.isArray(dict.items) ? dict.items : []
const labelsMap = renderOptionList(config.optionsEl, state.selections[fieldName], items, fieldName)
renderSelectionTags(config.tagsEl, state.selections[fieldName], labelsMap)
})
}
async function loadDictionaries() {
const data = await requestJson('/dictionary/list', {})
state.dictionaries = Array.isArray(data.items) ? data.items : []
state.dictionariesByName = {}
state.dictionariesById = {}
for (let i = 0; i < state.dictionaries.length; i += 1) {
const item = state.dictionaries[i]
state.dictionariesByName[item.dict_name] = item
state.dictionariesById[item.system_dict_id] = item
}
renderDictionarySelectors()
}
function updateEditorMode() {
if (state.mode === 'edit') {
formTitleEl.textContent = '编辑文档'
@@ -345,6 +604,36 @@ routerAdd('GET', '/manage/document-manage', function (e) {
return data.data
}
async function parseJsonSafe(res) {
const contentType = String(res.headers.get('content-type') || '').toLowerCase()
const rawText = await res.text()
const isJson = contentType.indexOf('application/json') !== -1
if (!rawText) {
return {
json: null,
text: '',
isJson: false,
}
}
if (isJson) {
try {
return {
json: JSON.parse(rawText),
text: rawText,
isJson: true,
}
} catch (_error) {}
}
return {
json: null,
text: rawText,
isJson: false,
}
}
async function uploadAttachment(file, label) {
const token = getToken()
if (!token) {
@@ -368,8 +657,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
body: form,
})
const data = await res.json()
const parsed = await parseJsonSafe(res)
const data = parsed.json
if (!res.ok || !data || data.code >= 400) {
if (res.status === 413) {
throw new Error('上传' + label + '失败:文件已超过当前网关允许的请求体大小。当前附件字段已放宽到约 4GB但线上反向代理也需要同步放开到相应体积。')
}
if (!parsed.isJson && parsed.text) {
throw new Error('上传' + label + '失败:服务端返回了非 JSON 响应,通常表示网关或反向代理提前拦截了上传请求。')
}
throw new Error((data && data.msg) || ('上传' + label + '失败'))
}
@@ -451,15 +749,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function renderTable() {
if (!state.list.length) {
tableBody.innerHTML = '<tr><td colspan="6" class="empty">暂无文档数据。</td></tr>'
tableBody.innerHTML = '<tr><td colspan="5" class="empty">暂无文档数据。</td></tr>'
return
}
tableBody.innerHTML = state.list.map(function (item) {
return '<tr>'
+ '<td data-label="document_id"><div>' + escapeHtml(item.document_id) + '</div><div class="muted">owner: ' + escapeHtml(item.document_owner) + '</div></td>'
+ '<td data-label="标题"><div><strong>' + escapeHtml(item.document_title) + '</strong></div><div class="muted">' + escapeHtml(item.document_subtitle) + '</div></td>'
+ '<td data-label="类型/状态"><div>' + escapeHtml(item.document_type) + '</div><div class="muted">' + escapeHtml(item.document_status) + ' / ' + escapeHtml(item.document_embedding_status) + '</div></td>'
+ '<td data-label="标题"><div><strong>' + escapeHtml(item.document_title) + '</strong></div><div class="muted">' + escapeHtml(item.document_subtitle) + '</div><div class="muted">owner: ' + escapeHtml(item.document_owner) + '</div></td>'
+ '<td data-label="类型/状态"><div>' + escapeHtml(formatDocumentTypeDisplay(item.document_type) || item.document_type) + '</div><div class="muted">' + escapeHtml(item.document_status) + ' / ' + escapeHtml(item.document_embedding_status) + '</div></td>'
+ '<td data-label="附件链接">' + renderLinks(item) + '</td>'
+ '<td data-label="更新时间"><span class="muted">' + escapeHtml(item.updated) + '</span></td>'
+ '<td data-label="操作"><div class="table-actions">'
@@ -555,8 +852,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function resetForm() {
fields.documentTitle.value = ''
fields.documentType.value = ''
fields.documentStatus.value = ''
fields.documentStatus.value = '有效'
fields.embeddingStatus.value = ''
fields.effectDate.value = ''
fields.expiryDate.value = ''
@@ -565,13 +861,17 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentContent.value = ''
fields.relationModel.value = ''
fields.vectorVersion.value = ''
fields.documentKeywords.value = ''
fields.productCategories.value = ''
fields.applicationScenarios.value = ''
fields.hotelType.value = ''
fields.documentRemark.value = ''
fields.imageFile.value = ''
fields.videoFile.value = ''
state.selections.documentTypeSource = ''
state.selections.documentTypeValues = []
state.selections.documentKeywords = []
state.selections.productCategories = []
state.selections.applicationScenarios = []
state.selections.hotelType = []
renderDictionarySelectors()
syncDocumentStatus('有效')
}
function enterCreateMode() {
@@ -590,8 +890,7 @@ routerAdd('GET', '/manage/document-manage', function (e) {
function fillFormFromItem(item) {
fields.documentTitle.value = item.document_title || ''
fields.documentType.value = item.document_type || ''
fields.documentStatus.value = item.document_status || ''
fields.documentStatus.value = item.document_status || '有效'
fields.embeddingStatus.value = item.document_embedding_status || ''
fields.effectDate.value = toDateInputValue(item.document_effect_date)
fields.expiryDate.value = toDateInputValue(item.document_expiry_date)
@@ -600,13 +899,27 @@ routerAdd('GET', '/manage/document-manage', function (e) {
fields.documentContent.value = item.document_content || ''
fields.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 = ''
const documentTypeParts = splitPipeValue(item.document_type)
const firstDocumentType = documentTypeParts.length ? documentTypeParts[0] : ''
const sourceId = firstDocumentType.indexOf('@') !== -1 ? firstDocumentType.split('@')[0] : ''
state.selections.documentTypeSource = sourceId
state.selections.documentTypeValues = documentTypeParts
.map(function (part) {
return part.indexOf('@') !== -1 ? part.split('@').slice(1).join('@') : ''
})
.filter(function (part) {
return !!part
})
state.selections.documentKeywords = splitPipeValue(item.document_keywords)
state.selections.productCategories = splitPipeValue(item.document_product_categories)
state.selections.applicationScenarios = splitPipeValue(item.document_application_scenarios)
state.selections.hotelType = splitPipeValue(item.document_hotel_type)
renderDictionarySelectors()
syncDocumentStatus(item.document_status || '有效')
}
function enterEditMode(documentId) {
@@ -639,8 +952,8 @@ routerAdd('GET', '/manage/document-manage', function (e) {
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_type: buildDocumentTypeStorageValue(),
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,
@@ -650,16 +963,16 @@ routerAdd('GET', '/manage/document-manage', function (e) {
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_keywords: joinPipeValue(state.selections.documentKeywords),
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_product_categories: joinPipeValue(state.selections.productCategories),
document_application_scenarios: joinPipeValue(state.selections.applicationScenarios),
document_hotel_type: joinPipeValue(state.selections.hotelType),
document_remark: fields.documentRemark.value.trim(),
}
}
@@ -685,12 +998,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
}
async function submitDocument() {
syncDocumentStatus()
if (!fields.documentTitle.value.trim()) {
setStatus('请先填写文档标题。', 'error')
return
}
if (!fields.documentType.value.trim()) {
if (!buildDocumentTypeStorageValue()) {
setStatus('请先填写文档类型。', 'error')
return
}
@@ -760,6 +1075,39 @@ routerAdd('GET', '/manage/document-manage', function (e) {
window.__removePendingAttachment = removePendingAttachment
window.__removeCurrentAttachment = removeCurrentAttachment
documentTypeSourceEl.addEventListener('change', function (event) {
state.selections.documentTypeSource = event.target.value || ''
state.selections.documentTypeValues = []
renderDictionarySelectors()
})
documentStatusSwitchEl.addEventListener('click', function (event) {
const target = event.target
if (!target || !target.getAttribute) {
return
}
const nextValue = target.getAttribute('data-status-value')
if (!nextValue) {
return
}
fields.documentStatus.value = nextValue
syncDocumentStatus(nextValue)
})
document.addEventListener('change', function (event) {
const target = event.target
if (!target || !target.getAttribute) {
return
}
const fieldName = target.getAttribute('data-selection-field')
if (!fieldName) {
return
}
updateSelection(fieldName, target.value, !!target.checked)
})
document.getElementById('reloadBtn').addEventListener('click', loadDocuments)
document.getElementById('createModeBtn').addEventListener('click', function () {
enterCreateMode()
@@ -790,6 +1138,12 @@ routerAdd('GET', '/manage/document-manage', function (e) {
appendPendingFiles('image', event.target.files)
fields.imageFile.value = ''
})
fields.effectDate.addEventListener('change', function () {
syncDocumentStatus()
})
fields.expiryDate.addEventListener('change', function () {
syncDocumentStatus()
})
fields.videoFile.addEventListener('change', function (event) {
appendPendingFiles('video', event.target.files)
fields.videoFile.value = ''
@@ -798,7 +1152,14 @@ routerAdd('GET', '/manage/document-manage', function (e) {
bindDropzone(videoDropzoneEl, fields.videoFile, 'video')
enterCreateMode()
loadDocuments()
;(async function initPage() {
try {
await loadDictionaries()
} catch (err) {
setStatus(err.message || '加载字典选项失败', 'error')
}
await loadDocuments()
})()
</script>
</body>
</html>`

View File

@@ -16,14 +16,14 @@ routerAdd('GET', '/manage', function (e) {
</script>
<style>
body { margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(180deg, #f3f6fb 0%, #eef2ff 100%); color: #1f2937; }
.wrap { max-width: 760px; margin: 0 auto; padding: 48px 20px; }
.hero { background: #ffffff; border-radius: 24px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 36px; border: 1px solid #e5e7eb; }
h1 { margin: 0 0 20px; font-size: 32px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 18px; padding: 22px; text-align: center; }
.card h2 { margin: 0 0 14px; font-size: 20px; }
.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 14px; }
.hero { background: #ffffff; border-radius: 20px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.08); padding: 22px; border: 1px solid #e5e7eb; }
h1 { margin: 0 0 14px; font-size: 30px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.card { background: #f8fafc; border: 1px solid #dbe3f0; border-radius: 16px; padding: 16px; text-align: center; }
.card h2 { margin: 0 0 8px; font-size: 19px; }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 16px; border-radius: 12px; text-decoration: none; background: #2563eb; color: #fff; font-weight: 600; margin-top: 12px; }
.actions { margin-top: 24px; display: flex; justify-content: flex-start; }
.actions { margin-top: 14px; display: flex; justify-content: flex-start; }
</style>
</head>
<body>

View File

@@ -22,15 +22,17 @@ function renderLoginPage(e) {
color: #1f2937;
}
.wrap {
max-width: 420px;
max-width: 1440px;
margin: 0 auto;
padding: 72px 20px;
padding: 34px 14px;
}
.card {
max-width: 420px;
margin: 0 auto;
background: #fff;
border: 1px solid #dbe3f0;
border-radius: 18px;
padding: 28px;
padding: 22px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
}
h1 { margin-top: 0; }

View File

@@ -204,7 +204,7 @@
- `POST /pb/api/dictionary/list`
- 支持按 `dict_name` 模糊搜索
- 返回字典全量信息,并将 `dict_word_enum``dict_word_description``dict_word_sort_order` 组装为 `items`
- 返回字典全量信息,并将 `dict_word_enum``dict_word_description``dict_word_image``dict_word_sort_order` 组装为 `items`
- `POST /pb/api/dictionary/detail`
-`dict_name` 查询单条字典
- `POST /pb/api/dictionary/create`
@@ -216,8 +216,9 @@
说明:
- `dict_word_enum``dict_word_description``dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
- 查询时统一聚合为:`items: [{ enum, description, sortOrder }]`
- `dict_word_enum``dict_word_description``dict_word_image``dict_word_sort_order` 已统一改为 JSON 字符串持久化,其中 `dict_word_sort_order` 已改为 `text` 类型。
- 字典项图片需先调用 `/pb/api/attachment/upload` 上传,再把返回的 `attachments_id` 写入 `dict_word_image` 对应位置。
- 查询时统一聚合为:`items: [{ enum, description, image, imageUrl, imageAttachment, sortOrder }]`
### 附件管理接口
@@ -267,11 +268,16 @@
- `document_id` 可不传,由服务端自动生成
- `document_title``document_type` 为必填;其余字段均允许为空
- `document_image``document_video` 支持传入多个已存在的 `attachments_id`
- `document_type` 前端从单个字典来源中多选枚举值,最终按 `system_dict_id@dict_word_enum|...` 保存
- `document_keywords``document_product_categories``document_application_scenarios``document_hotel_type` 统一从固定字典多选并按 `|` 保存
- 其中 `document_product_categories` 改为从 `文档-产品关联文档` 读取,`document_application_scenarios` 改为从 `文档-筛选依据` 读取,`document_hotel_type` 改为从 `文档-适用场景` 读取
- `document_status` 仅保留 `有效` / `过期` 两种状态,并由生效日期与到期日期自动计算
- 成功后会写入一条文档操作历史,类型为 `create`
- `POST /pb/api/document/update`
-`document_id` 更新文档
- `document_title``document_type` 为必填;其余字段均允许为空
- 若传入附件字段,则会校验多个 `attachments_id` 是否都存在
- 多选字段的持久化格式与新增接口一致
- 成功后会写入一条文档操作历史,类型为 `update`
- `POST /pb/api/document/delete`
-`document_id` 真删除文档
@@ -325,6 +331,8 @@
- 指定字典查询
- 行内编辑基础字段
- 弹窗编辑枚举项
- 为每个枚举项单独上传图片,并保存对应 `attachments_id`
- 回显字典项图片缩略图与文件流链接
- 新增 / 删除字典
- 返回主页
- 文档管理页支持:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,419 @@
openapi: 3.1.0
info:
title: BAI PocketBase WeChat API
version: 1.0.0-wx
description: |
面向微信端的小程序接口文档。
本文档只包含微信登录、微信资料完善,以及微信端会共用的系统接口。
license:
name: Proprietary
identifier: LicenseRef-Proprietary
servers:
- url: https://bai-api.blv-oa.com
description: 生产环境
- url: http://localhost:8090
description: PocketBase 本地环境
tags:
- name: 系统
description: 微信端共用系统接口
- name: 微信认证
description: 微信登录、资料完善与 token 刷新接口
paths:
/pb/api/system/users-count:
post:
operationId: postSystemUsersCount
security: []
tags:
- 系统
summary: 查询用户总数
description: 统计 `tbl_auth_users` 集合中的记录总数。
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/UsersCountResponse'
'400':
description: 请求参数错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: 服务端错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/pb/api/system/refresh-token:
post:
operationId: postSystemRefreshToken
security:
- bearerAuth: []
- {}
tags:
- 系统
summary: 刷新认证 token
description: |
当当前 `Authorization` 仍有效时,直接基于当前 auth 用户续签。
当 token 失效时,可传入 `users_wx_code` 走微信 code 重新签发。
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/SystemRefreshTokenRequest'
responses:
'200':
description: 刷新成功
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshTokenResponse'
'400':
description: 参数错误或微信 code 换取失败
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: token 无效,且未提供有效的 `users_wx_code`
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: 当前用户不存在
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'415':
description: 请求体不是 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 请求过于频繁
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: 服务端错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/pb/api/wechat/login:
post:
operationId: postWechatLogin
security: []
tags:
- 微信认证
summary: 微信登录或首次注册
description: |
使用微信 `users_wx_code` 换取微信 openid。
若 `tbl_auth_users` 中不存在对应用户,则自动创建新 auth 用户并返回 token。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WechatLoginRequest'
responses:
'200':
description: 登录或注册成功
content:
application/json:
schema:
$ref: '#/components/schemas/AuthSuccessResponse'
'400':
description: 参数错误或保存用户失败
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: 认证失败
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'415':
description: 请求体不是 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 请求过于频繁
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: 服务端错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/pb/api/wechat/profile:
post:
operationId: postWechatProfile
security:
- bearerAuth: []
tags:
- 微信认证
summary: 更新微信用户资料
description: |
基于当前 `Authorization` 对应的 auth 用户更新昵称、手机号和头像。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WechatProfileRequest'
responses:
'200':
description: 更新成功
content:
application/json:
schema:
$ref: '#/components/schemas/WechatProfileResponse'
'400':
description: 参数错误、手机号已被占用或资料更新失败
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: token 无效或缺少 openid
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'415':
description: 请求体不是 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: 请求过于频繁
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: 服务端错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
ApiResponseBase:
type: object
required:
- code
- msg
- data
properties:
code:
type: integer
example: 200
msg:
type: string
example: 操作成功
data:
type: object
additionalProperties: true
ErrorResponse:
type: object
required:
- code
- msg
- data
properties:
code:
type: integer
example: 400
msg:
type: string
example: 请求失败
data:
type: object
additionalProperties: true
CompanyInfo:
anyOf:
- type: object
additionalProperties: true
- type: 'null'
UserInfo:
type: object
properties:
pb_id:
type: string
users_convers_id:
type: string
users_id:
type: string
users_idtype:
type: string
enum:
- WeChat
- ManagePlatform
users_id_number:
type: string
users_type:
type: string
users_name:
type: string
users_status:
type:
- string
- number
users_rank_level:
type:
- number
- integer
users_auth_type:
type:
- number
- integer
users_phone:
type: string
users_phone_masked:
type: string
users_level:
type: string
users_picture:
type: string
openid:
type: string
description: 全平台统一身份标识
company_id:
type: string
users_parent_id:
type: string
users_promo_code:
type: string
usergroups_id:
type: string
company:
$ref: '#/components/schemas/CompanyInfo'
created:
type: string
updated:
type: string
WechatLoginRequest:
type: object
required:
- users_wx_code
properties:
users_wx_code:
type: string
description: 微信小程序登录临时凭证 code
example: 0a1b2c3d4e5f6g
WechatProfileRequest:
type: object
required:
- users_name
- users_phone_code
- users_picture
properties:
users_name:
type: string
example: 张三
users_phone_code:
type: string
example: 2b7d9f2e3c4a5b6d7e8f
users_picture:
type: string
example: https://example.com/avatar.png
SystemRefreshTokenRequest:
type: object
properties:
users_wx_code:
type:
- string
- 'null'
description: |
可选。
当前 token 失效时,可通过该 code 重新签发 token。
example: 0a1b2c3d4e5f6g
AuthSuccessData:
type: object
properties:
status:
type: string
enum:
- register_success
- login_success
is_info_complete:
type: boolean
user:
$ref: '#/components/schemas/UserInfo'
AuthSuccessResponse:
allOf:
- $ref: '#/components/schemas/ApiResponseBase'
- type: object
required:
- token
properties:
data:
$ref: '#/components/schemas/AuthSuccessData'
token:
type: string
description: PocketBase 原生 auth token
WechatProfileResponseData:
type: object
properties:
status:
type: string
enum:
- update_success
user:
$ref: '#/components/schemas/UserInfo'
WechatProfileResponse:
allOf:
- $ref: '#/components/schemas/ApiResponseBase'
- type: object
properties:
data:
$ref: '#/components/schemas/WechatProfileResponseData'
RefreshTokenResponse:
allOf:
- $ref: '#/components/schemas/ApiResponseBase'
- type: object
required:
- token
properties:
data:
type: object
additionalProperties: true
example: {}
token:
type: string
description: 新签发的 PocketBase 原生 auth token
UsersCountData:
type: object
properties:
total_users:
type: integer
example: 128
UsersCountResponse:
allOf:
- $ref: '#/components/schemas/ApiResponseBase'
- type: object
properties:
data:
$ref: '#/components/schemas/UsersCountData'

View File

@@ -292,6 +292,17 @@ components:
description:
type: string
example: 启用
image:
type: string
description: 对应图片附件的 `attachments_id`,允许为空
example: ATT-1743037200000-abc123
imageUrl:
type: string
description: 根据 `image -> tbl_attachments` 自动解析出的图片文件流链接
imageAttachment:
allOf:
- $ref: '#/components/schemas/AttachmentRecord'
nullable: true
sortOrder:
type: integer
example: 1
@@ -355,6 +366,7 @@ components:
items:
type: array
minItems: 1
description: 每项会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order`
items:
$ref: '#/components/schemas/DictionaryItem'
DictionaryDeleteRequest:
@@ -457,6 +469,7 @@ components:
type: string
document_type:
type: string
description: 多选时按 `system_dict_id@dict_word_enum|...` 保存
document_title:
type: string
document_subtitle:
@@ -520,7 +533,7 @@ components:
type: string
document_keywords:
type: string
description: 多值字段,使用 `|` 分隔
description: 固定字典多选字段,使用 `|` 分隔
document_share_count:
type: number
document_download_count:
@@ -529,6 +542,7 @@ components:
type: number
document_status:
type: string
description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`
document_embedding_status:
type: string
document_embedding_error:
@@ -539,13 +553,13 @@ components:
type: string
document_product_categories:
type: string
description: 多值字段,使用 `|` 分隔
description: 固定字典多选字段,使用 `|` 分隔
document_application_scenarios:
type: string
description: 多值字段,使用 `|` 分隔
description: 固定字典多选字段,使用 `|` 分隔
document_hotel_type:
type: string
description: 多值字段,使用 `|` 分隔
description: 固定字典多选字段,使用 `|` 分隔
document_remark:
type: string
created:
@@ -564,6 +578,7 @@ components:
example: active
document_type:
type: string
description: 支持按存储值过滤;多选时格式为 `system_dict_id@dict_word_enum|...`
example: 说明书
DocumentDetailRequest:
type: object
@@ -590,6 +605,7 @@ components:
example: 2027-03-27
document_type:
type: string
description: 必填;前端显示为字典项描述,存库时按 `system_dict_id@dict_word_enum|...` 保存
document_title:
type: string
document_subtitle:
@@ -618,7 +634,7 @@ components:
type: string
document_keywords:
type: string
description: 多值字段,使用 `|` 分隔
description: 从 `文档-关键词` 字典多选后使用 `|` 分隔保存
document_share_count:
type: number
document_download_count:
@@ -627,6 +643,7 @@ components:
type: number
document_status:
type: string
description: 文档状态,仅允许 `有效` 或 `过期`,由系统根据生效日期和到期日期自动计算;当两者都为空时默认 `有效`
document_embedding_status:
type: string
document_embedding_error:
@@ -637,13 +654,13 @@ components:
type: string
document_product_categories:
type: string
description: 多值字段,使用 `|` 分隔
description: 从 `文档-产品关联文档` 字典多选后使用 `|` 分隔保存
document_application_scenarios:
type: string
description: 多值字段,使用 `|` 分隔
description: 从 `文档-筛选依据` 字典多选后使用 `|` 分隔保存
document_hotel_type:
type: string
description: 多值字段,使用 `|` 分隔
description: 从 `文档-适用场景` 字典多选后使用 `|` 分隔保存
document_remark:
type: string
DocumentDeleteRequest:
@@ -1005,7 +1022,7 @@ paths:
description: |
仅允许 `ManagePlatform` 用户访问。
`system_dict_id` 由服务端自动生成;`dict_name` 必须唯一;
`items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_sort_order` 个字段。
`items` 会分别序列化写入 `dict_word_enum`、`dict_word_description`、`dict_word_image`、`dict_word_sort_order` 个字段。
requestBody:
required: true
content: