feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const navItems = [
{
@@ -62,14 +62,6 @@ export const navItems = [
title: '任务规则中心',
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
},
{
id: 'logs',
label: '日志管理',
navHint: '查看 Hermes 调用与系统运行日志',
icon: icons.logs,
title: '日志管理',
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
},
{
id: 'employees',
label: '员工管理',
@@ -78,6 +70,14 @@ export const navItems = [
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
},
{
id: 'logs',
label: '日志管理',
navHint: '查看 Hermes 调用与系统运行日志',
icon: icons.logs,
title: '日志管理',
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
},
{
id: 'settings',
label: '系统设置',
@@ -101,13 +101,34 @@ const viewRouteNames = {
settings: 'app-settings'
}
const routeNameViews = Object.fromEntries(
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
)
routeNameViews['app-request-detail'] = 'requests'
routeNameViews['app-log-detail'] = 'logs'
export function resolveAppViewFromRoute(route) {
const routeName = String(route?.name || '').trim()
if (routeNameViews[routeName]) {
return routeNameViews[routeName]
}
const metaView = String(route?.meta?.appView || '').trim()
return appViews.includes(metaView) ? metaView : 'overview'
}
export function resolveTargetRouteName(view) {
return viewRouteNames[view] || viewRouteNames.overview
}
export function useNavigation() {
const route = useRoute()
const router = useRouter()
const activeView = computed({
get() {
return route.meta.appView || 'overview'
return resolveAppViewFromRoute(route)
},
set(view) {
setView(view)
@@ -119,13 +140,13 @@ export function useNavigation() {
)
function setView(view) {
const targetName = viewRouteNames[view] || viewRouteNames.overview
const targetName = resolveTargetRouteName(view)
if (route.name === targetName) {
return
}
router.push({ name: targetName })
router.push({ name: targetName, params: {}, query: {}, hash: '' })
}
return { activeView, currentView, setView, navItems }

View File

@@ -13,12 +13,12 @@ const EXPENSE_TYPE_LABELS = {
ride_ticket: '乘车',
travel_allowance: '出差补贴',
entertainment: '业务招待费',
office: '办公费',
office: '办公用品费',
meeting: '会务费',
training: '培训费',
hotel: '住宿费',
transport: '交通费',
meal: '费',
meal: '业务招待费',
other: '其他费用'
}

View File

@@ -0,0 +1,486 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSystemState } from './useSystemState.js'
import { fetchSettings, saveSettings } from '../services/settings.js'
import { useToast } from './useToast.js'
import {
isHermesEmployeeSettingsReady
} from '../utils/hermesEmployeeSettingsModel.js'
import {
LOG_LEVELS,
PROVIDER_OPTIONS,
SECTION_DEFINITIONS,
SESSION_RETENTION_OPTIONS,
buildDefaultState,
buildLlmPayload,
buildRenderPayload,
computeSectionStatus,
isModelConfigReady,
isRenderSecretMask,
maskConfiguredModelSecrets,
maskConfiguredRenderSecret,
mergeState,
normalizeValue,
persistSettings,
readStoredSettings
} from '../utils/settingsModelHelper.js'
export function useSettings() {
const { toast } = useToast()
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
const activeSection = ref('profile')
const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null)
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
const providerOptions = PROVIDER_OPTIONS
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
const activeSectionConfig = computed(
() => sections.find((section) => section.id === activeSection.value) || sections[0]
)
function updateBrandPreviewFromState(state) {
updateCompanyProfilePreview({
name: normalizeValue(state.companyForm.displayName),
code: normalizeValue(state.companyForm.companyCode),
adminEmail: normalizeValue(state.adminForm.adminEmail),
logo: state.companyForm.logo
})
}
function applyLoadedSnapshot(snapshot, options = {}) {
const {
mergeDraft = false,
preserveModelApiKeys = false,
preserveAdminPasswords = false,
preserveRenderSecret = false,
preserveMailPassword = false
} = options
const currentState = pageState.value
let nextState = mergeState(buildResolvedDefaults(), snapshot)
if (mergeDraft) {
nextState = mergeState(nextState, readStoredSettings())
}
if (preserveModelApiKeys) {
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
}
if (preserveAdminPasswords) {
nextState.adminForm.newPassword = currentState.adminForm.newPassword
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
}
if (preserveRenderSecret) {
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
}
if (preserveMailPassword) {
nextState.mailForm.password = currentState.mailForm.password
}
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
}
async function loadSettingsSnapshot() {
try {
const snapshot = await fetchSettings()
applyLoadedSnapshot(snapshot, { mergeDraft: true })
} catch (error) {
persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value)
toast(error.message || '无法加载已保存设置,继续使用当前会话草稿。')
}
}
function buildSettingsPayload() {
return {
companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm),
renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm }
}
}
async function persistRemoteSettings(successMessage, options = {}) {
try {
const snapshot = await saveSettings(buildSettingsPayload())
applyLoadedSnapshot(snapshot, options)
toast(successMessage)
return true
} catch (error) {
toast(error.message || '设置保存失败,请稍后重试。')
return false
}
}
function activateSection(sectionId) {
sessionRetentionPickerOpen.value = false
activeSection.value = sectionId
}
function toggleBoolean(formKey, field) {
pageState.value[formKey][field] = !pageState.value[formKey][field]
}
function toggleSessionRetentionPicker() {
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
}
function closeSessionRetentionPicker() {
sessionRetentionPickerOpen.value = false
}
function selectSessionRetentionDays(value) {
pageState.value.sessionForm.conversationRetentionDays = Number(value)
closeSessionRetentionPicker()
}
function handleDocumentPointerDown(event) {
if (!sessionRetentionPickerOpen.value) {
return
}
const target = event.target
if (sessionRetentionPickerRef.value?.contains(target)) {
return
}
closeSessionRetentionPicker()
}
function clearRenderSecretMask() {
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
pageState.value.renderForm.jwtSecret = ''
}
}
async function saveProfileSection() {
const companyForm = pageState.value.companyForm
if (!normalizeValue(companyForm.companyName)) {
toast('请输入企业名称。')
return
}
if (!normalizeValue(companyForm.displayName)) {
toast('请输入系统显示名称。')
return
}
if (!normalizeValue(companyForm.copyright)) {
toast('请输入版权信息。')
return
}
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveAdminSection() {
const adminForm = pageState.value.adminForm
if (!normalizeValue(adminForm.adminAccount)) {
toast('请输入管理员账号。')
return
}
if (!normalizeValue(adminForm.adminEmail)) {
toast('请输入管理员邮箱。')
return
}
if (Number(adminForm.sessionTimeout) < 5) {
toast('会话超时时间不能少于 5 分钟。')
return
}
if (adminForm.newPassword) {
if (adminForm.newPassword.length < 5) {
toast('管理员密码至少需要 5 位。')
return
}
if (adminForm.newPassword !== adminForm.confirmPassword) {
toast('两次输入的管理员密码不一致。')
return
}
}
await persistRemoteSettings('管理员安全设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: false,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
function toggleHermesMaster() {
pageState.value.hermesForm.masterEnabled = !pageState.value.hermesForm.masterEnabled
}
function toggleHermesFlag(field) {
pageState.value.hermesForm[field] = !pageState.value.hermesForm[field]
}
function toggleHermesTask(taskId) {
const schedule = pageState.value.hermesForm.schedules[taskId]
if (!schedule) {
return
}
const enabled = !(pageState.value.hermesForm.capabilities[taskId] && schedule.enabled)
pageState.value.hermesForm.capabilities[taskId] = enabled
schedule.enabled = enabled
}
function updateHermesTaskTime({ taskId, time }) {
const schedule = pageState.value.hermesForm.schedules[taskId]
if (!schedule) {
return
}
schedule.time = time
}
function saveHermesSection() {
if (!isHermesEmployeeSettingsReady(pageState.value.hermesForm)) {
toast('请至少开启一项 Hermes 能力,或关闭总控开关。')
return
}
persistSettings(pageState.value)
toast('数字员工设置已保存。')
}
async function saveSessionSection() {
const sessionForm = pageState.value.sessionForm
const retentionDays = Number(sessionForm.conversationRetentionDays)
if (retentionDays < 1 || retentionDays > 10) {
toast('会话保留天数必须在 1 到 10 天之间。')
return
}
await persistRemoteSettings('会话设置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveLlmSection() {
const llmForm = pageState.value.llmForm
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
]
for (const [label, provider, model, endpoint] of modelConfigs) {
if (!isModelConfigReady(provider, model, endpoint)) {
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
return
}
}
await persistRemoteSettings('模型配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveRenderingSection() {
const renderForm = pageState.value.renderForm
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
toast('启用 ONLYOFFICE 时请输入服务地址。')
return
}
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
return
}
await persistRemoteSettings('文件渲染配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: false,
preserveMailPassword: true
})
}
async function saveLogsSection() {
const logForm = pageState.value.logForm
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
toast('请填写有效的日志级别和留存天数。')
return
}
if (!normalizeValue(logForm.logPath)) {
toast('请输入日志路径。')
return
}
await persistRemoteSettings('日志策略已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveMailSection() {
const mailForm = pageState.value.mailForm
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
toast('请填写有效的 SMTP Host 和端口。')
return
}
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
toast('请填写发件人邮箱和 SMTP 登录账号。')
return
}
await persistRemoteSettings('邮箱配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: false
})
}
async function saveActiveSection() {
if (activeSection.value === 'profile') {
await saveProfileSection()
return
}
if (activeSection.value === 'admin') {
await saveAdminSection()
return
}
if (activeSection.value === 'session') {
await saveSessionSection()
return
}
if (activeSection.value === 'hermes') {
saveHermesSection()
return
}
if (activeSection.value === 'llm') {
await saveLlmSection()
return
}
if (activeSection.value === 'logs') {
await saveLogsSection()
return
}
if (activeSection.value === 'rendering') {
await saveRenderingSection()
return
}
await saveMailSection()
}
onMounted(() => {
if (typeof document !== 'undefined') {
document.addEventListener('pointerdown', handleDocumentPointerDown)
}
loadSettingsSnapshot()
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
}
})
function triggerLogoUpload() {
logoInputRef.value?.click()
}
function handleLogoUpload(event) {
const file = event.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast('请上传图片文件。')
return
}
if (file.size > 2 * 1024 * 1024) {
toast('图片大小不能超过 2MB。')
return
}
const reader = new FileReader()
reader.onload = (e) => {
pageState.value.companyForm.logo = e.target.result
}
reader.readAsDataURL(file)
}
return {
activeSection,
activeSectionConfig,
activateSection,
clearRenderSecretMask,
completedSectionCount,
logLevels,
logoInputRef,
pageState,
providerOptions,
sessionRetentionOptions,
sessionRetentionPickerOpen,
sessionRetentionPickerRef,
saveActiveSection,
sectionStatus,
sections,
selectSessionRetentionDays,
toggleSessionRetentionPicker,
closeSessionRetentionPicker,
toggleBoolean,
toggleHermesFlag,
toggleHermesMaster,
toggleHermesTask,
updateHermesTaskTime,
triggerLogoUpload,
handleLogoUpload
}
}

View File

@@ -383,7 +383,8 @@ const { toast } = useToast()
const companyProfile = computed(() => ({
name: bootstrapState.value.company?.name || '',
code: bootstrapState.value.company?.code || '',
adminEmail: bootstrapState.value.company?.admin_email || ''
adminEmail: bootstrapState.value.company?.admin_email || '',
logo: bootstrapState.value.company?.logo || ''
}))
function updateCompanyProfilePreview(payload = {}) {
@@ -395,7 +396,8 @@ function updateCompanyProfilePreview(payload = {}) {
...currentCompany,
...(payload.name !== undefined ? { name: payload.name } : {}),
...(payload.code !== undefined ? { code: payload.code } : {}),
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {})
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}),
...(payload.logo !== undefined ? { logo: payload.logo } : {})
}
}
}