Browse Source

: 侧边栏导航改造 + 交互体验优化

dev-mcp
wanghanlin 4 weeks ago
parent
commit
76dbd22df2
  1. 103
      src/main/resources/static/components/AuditLogManager.js
  2. 10
      src/main/resources/static/components/FaqManager.js
  3. 99
      src/main/resources/static/components/ModelConfigManager.js
  4. 65
      src/main/resources/static/components/SensitiveWordManager.js
  5. 75
      src/main/resources/static/css/main.css
  6. 202
      src/main/resources/static/js/app.js
  7. 114
      src/main/resources/static/js/store.js

103
src/main/resources/static/components/AuditLogManager.js

@ -0,0 +1,103 @@
/**
* 内容审计日志组件
* 展示敏感词命中记录支持分页浏览
*/
import { listAuditLogs } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📋 内容审计日志</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:16px;">记录所有敏感词命中事件包含输入/输出方向违规内容命中词及处理方式</p>
<!-- 表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>方向</th>
<th>违规内容</th>
<th>命中词</th>
<th>处理方式</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="logs.length === 0"><td colspan="5" style="text-align:center;padding:20px;color:var(--sub);">暂无审计日志</td></tr>
<tr v-for="log in logs" :key="log.id">
<td style="white-space:nowrap;">{{ formatTime(log.createTime) }}</td>
<td style="text-align:center;">
<span :style="{color: log.direction === 'INPUT' ? '#007bff' : '#28a745'}">
{{ log.direction === 'INPUT' ? '输入' : '输出' }}
</span>
</td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="log.originalText">{{ log.originalText }}</td>
<td style="font-size:12px;">{{ formatHitWords(log.hitWords) }}</td>
<td style="text-align:center;">
<span :style="{color: log.actionTaken === 'BLOCK' ? '#dc3545' : log.actionTaken === 'MASK' ? '#ffc107' : '#28a745'}">
{{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadLogs())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadLogs())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
</div>
`,
data() {
return {
logs: [],
loading: false,
page: 1,
pageSize: 30,
total: 0
}
},
mounted() {
this.loadLogs()
},
methods: {
async loadLogs() {
this.loading = true
try {
const res = await listAuditLogs(this.page, this.pageSize)
if (res.success) {
this.logs = res.data?.records || res.data || []
this.total = res.data?.total || 0
} else {
toast('加载审计日志失败: ' + (res.message || '未知错误'), 'error')
}
} catch (e) {
toast('加载审计日志失败: ' + e.message, 'error')
}
this.loading = false
},
formatTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' },
formatHitWords(hw) {
if (!hw) return ''
if (hw.type === 'jsonb' && hw.value) {
try { hw = JSON.parse(hw.value) } catch { return hw.value }
}
if (typeof hw === 'string') { try { hw = JSON.parse(hw) } catch { return hw } }
if (hw && hw.hits && Array.isArray(hw.hits)) {
return hw.hits.map(h => `${h.word || ''}(${h.category || ''})`).join('、')
}
if (Array.isArray(hw)) return hw.map(h => typeof h === 'string' ? h : h.word || '').join('、')
return String(hw)
}
}
}

10
src/main/resources/static/components/FaqManager.js

@ -77,8 +77,8 @@ export default {
</div> </div>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗 -->
<div v-if="showFormDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="showFormDialog = false">
<div style="background:var(--card);border-radius:12px;padding:24px;width:560px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<div v-if="showFormDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:720px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">{{ editingFaq ? '编辑 FAQ' : '添加 FAQ' }}</h4> <h4 style="margin-bottom:16px;">{{ editingFaq ? '编辑 FAQ' : '添加 FAQ' }}</h4>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">问题 *</label> <label style="display:block;margin-bottom:4px;font-size:13px;">问题 *</label>
@ -86,7 +86,7 @@ export default {
</div> </div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">标准答案 *</label> <label style="display:block;margin-bottom:4px;font-size:13px;">标准答案 *</label>
<textarea v-model="form.answer" rows="4" placeholder="输入标准答案" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;"></textarea>
<textarea v-model="form.answer" rows="6" placeholder="输入标准答案" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;"></textarea>
</div> </div>
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">相似问题每行一个</label> <label style="display:block;margin-bottom:4px;font-size:13px;">相似问题每行一个</label>
@ -110,8 +110,8 @@ export default {
</div> </div>
<!-- 批量导入弹窗 --> <!-- 批量导入弹窗 -->
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="showImportDialog = false">
<div style="background:var(--card);border-radius:12px;padding:24px;width:560px;max-width:90vw;">
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:660px;max-width:90vw;">
<h4 style="margin-bottom:16px;">批量导入 FAQ</h4> <h4 style="margin-bottom:16px;">批量导入 FAQ</h4>
<p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组category可选</p> <p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组category可选</p>
<textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea> <textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea>

99
src/main/resources/static/components/ModelConfigManager.js

@ -162,41 +162,39 @@ export default {
</div> </div>
<!-- 编辑/新建弹窗 --> <!-- 编辑/新建弹窗 -->
<div class="modal-overlay" :class="{ active: editModal.visible }" @click.self="closeEditModal">
<div class="modal-box" style="max-width:600px;">
<div class="modal-overlay" :class="{ active: editModal.visible }">
<div class="modal-box" style="max-width:860px;">
<button class="modal-close" @click="closeEditModal">×</button> <button class="modal-close" @click="closeEditModal">×</button>
<h2>{{ editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置' }}</h2> <h2>{{ editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置' }}</h2>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:16px;">
<!-- 配置名称 -->
<div>
<div class="model-form">
<!-- 配置名称跨列 -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">配置名称 <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">配置名称 <span style="color:#dc2626;">*</span></label>
<input type="text" class="input" v-model="editModal.form.name" placeholder="如:生产环境-DeepSeek对话"> <input type="text" class="input" v-model="editModal.form.name" placeholder="如:生产环境-DeepSeek对话">
</div> </div>
<!-- 应用类型 + 提供商 -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">应用类型 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'">
<option v-for="opt in appTypeOptions.slice(1)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">提供商 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.provider" @change="onProviderChange">
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<!-- 应用类型 + 提供商双列 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">应用类型 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'">
<option v-for="opt in appTypeOptions.slice(1)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">提供商 <span style="color:#dc2626;">*</span></label>
<select class="input" v-model="editModal.form.provider" @change="onProviderChange">
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div> </div>
<!-- 提供商提示 -->
<div v-if="providerTip" style="padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;font-size:12px;color:#1e40af;">
<!-- 提供商提示跨列 -->
<div v-if="providerTip" class="model-form-full" style="padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;font-size:12px;color:#1e40af;">
💡 {{ providerTip }} 💡 {{ providerTip }}
</div> </div>
<!-- API Key -->
<div>
<!-- API Key跨列 -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key" <input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key"
@ -207,8 +205,8 @@ export default {
</div> </div>
</div> </div>
<!-- 模型名称 -->
<div>
<!-- 模型名称跨列 -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label>
<input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name" <input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name"
:placeholder="currentProviderDefaultModel || '请输入模型名称'"> :placeholder="currentProviderDefaultModel || '请输入模型名称'">
@ -220,48 +218,46 @@ export default {
v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;"> v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;">
</div> </div>
<!-- 温度 + 最大 Token -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">温度 (Temperature)</label>
<input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7">
</div>
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token </label>
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" max="128000" placeholder="2000">
</div>
<!-- 温度 + 最大 Token双列 -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">温度 (Temperature)</label>
<input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7">
</div> </div>
<!-- Base URL -->
<div> <div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token </label>
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" max="128000" placeholder="2000">
</div>
<!-- Base URL跨列 -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API 基础地址</label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API 基础地址</label>
<input type="text" class="input" v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder"> <input type="text" class="input" v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder">
</div> </div>
<!-- 向量维度 EMBEDDING 类型显示 -->
<div v-if="editModal.form.app_type === 'EMBEDDING'" style="padding:10px 14px;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
<!-- 向量维度 EMBEDDING 类型跨列 -->
<div v-if="editModal.form.app_type === 'EMBEDDING'" class="model-form-full" style="padding:10px 14px;background:#fefce8;border:1px solid #fde68a;border-radius:8px;">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px;">📐 向量维度 <span style="color:#dc2626;">*</span></label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:6px;">📐 向量维度 <span style="color:#dc2626;">*</span></label>
<input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1024" style="max-width:200px;">
<input type="number" class="input" v-model.number="editModal.form.embeddingDimensions" min="1" max="8192" placeholder="1024" style="max-width:240px;">
<div style="font-size:11px;color:#92400e;margin-top:4px;"> <div style="font-size:11px;color:#92400e;margin-top:4px;">
修改维度后需重建向量表<code style="background:#fde68a;padding:1px 4px;border-radius:3px;">DROP TABLE IF EXISTS vector_store CASCADE</code> 修改维度后需重建向量表<code style="background:#fde68a;padding:1px 4px;border-radius:3px;">DROP TABLE IF EXISTS vector_store CASCADE</code>
常用维度千问 text-embedding-v2=1024 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536 常用维度千问 text-embedding-v2=1024 | 豆包 doubao-embedding-text=2048 | OpenAI text-embedding-3-small=1536
</div> </div>
</div> </div>
<!-- 优先级 -->
<!-- 优先级 + 是否激活双列 -->
<div> <div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label>
<input type="number" class="input" v-model.number="editModal.form.priority" min="0" placeholder="0(数值越大越优先)"> <input type="number" class="input" v-model.number="editModal.form.priority" min="0" placeholder="0(数值越大越优先)">
</div> </div>
<!-- 是否激活 -->
<div style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" v-model="editModal.form.is_active" id="editIsActive">
<label for="editIsActive" style="font-size:13px;cursor:pointer;">设为活跃配置同类型只能有一个活跃</label>
<div style="display:flex;align-items:flex-end;padding-bottom:4px;">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
<input type="checkbox" v-model="editModal.form.is_active">
设为活跃配置同类型只能有一个活跃
</label>
</div> </div>
<!-- 描述 -->
<div>
<!-- 描述跨列 -->
<div class="model-form-full">
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label> <label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label>
<textarea class="input" v-model="editModal.form.description" rows="2" placeholder="可选,填写配置用途说明"></textarea> <textarea class="input" v-model="editModal.form.description" rows="2" placeholder="可选,填写配置用途说明"></textarea>
</div> </div>
@ -347,13 +343,6 @@ export default {
} }
}) })
// 监听自定义模型名输入
watch(customModelName, (val) => {
if (editModal.value.form.model_name === '__custom__' && val) {
editModal.value.form.model_name = val
}
})
// ==================== 数据加载 ==================== // ==================== 数据加载 ====================
async function load(p = 1) { async function load(p = 1) {

65
src/main/resources/static/components/SensitiveWordManager.js

@ -2,7 +2,7 @@
* 敏感词管理组件 * 敏感词管理组件
* 支持 CRUD + 批量导入 + 审计日志查看 * 支持 CRUD + 批量导入 + 审计日志查看
*/ */
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords, listAuditLogs } from '../js/api.js'
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords } from '../js/api.js'
import { toast } from '../js/utils.js' import { toast } from '../js/utils.js'
export default { export default {
@ -14,7 +14,6 @@ export default {
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;"> <div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="showAddDialog = true" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 添加敏感词</button> <button @click="showAddDialog = true" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 添加敏感词</button>
<button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button> <button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button>
<button @click="showAuditLog = !showAuditLog" style="padding:6px 14px;background:#17a2b8;color:#fff;border:none;border-radius:6px;cursor:pointer;">📋 审计日志</button>
<input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索敏感词..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" /> <input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索敏感词..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" />
<select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;"> <select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部分类</option> <option value="">全部分类</option>
@ -120,36 +119,6 @@ export default {
</div> </div>
</div> </div>
</div> </div>
<!-- 审计日志面板 -->
<div v-if="showAuditLog" ref="auditLogPanel" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px;">
<h4 style="margin-bottom:12px;">📋 内容审计日志</h4>
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:6px;">时间</th>
<th style="text-align:center;padding:6px;">方向</th>
<th style="text-align:left;padding:6px;">违规内容</th>
<th style="text-align:left;padding:6px;">命中词</th>
<th style="text-align:center;padding:6px;">处理</th>
</tr>
</thead>
<tbody>
<tr v-if="auditLogs.length === 0"><td colspan="5" style="text-align:center;padding:12px;color:#999;">暂无审计日志</td></tr>
<tr v-for="log in auditLogs" :key="log.id" style="border-bottom:1px solid var(--border);">
<td style="padding:6px;white-space:nowrap;">{{ formatTime(log.createTime) }}</td>
<td style="text-align:center;padding:6px;"><span :style="{color: log.direction === 'INPUT' ? '#007bff' : '#28a745'}">{{ log.direction === 'INPUT' ? '输入' : '输出' }}</span></td>
<td style="padding:6px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="log.originalText">{{ log.originalText }}</td>
<td style="padding:6px;font-size:12px;">{{ formatHitWords(log.hitWords) }}</td>
<td style="text-align:center;padding:6px;">
<span :style="{color: log.actionTaken === 'BLOCK' ? '#dc3545' : log.actionTaken === 'MASK' ? '#ffc107' : '#28a745'}">
{{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
`, `,
@ -164,13 +133,11 @@ export default {
filterCategory: '', filterCategory: '',
showAddDialog: false, showAddDialog: false,
showImportDialog: false, showImportDialog: false,
showAuditLog: false,
editingWord: null, editingWord: null,
form: { word: '', category: 'custom', level: 1, remark: '' }, form: { word: '', category: 'custom', level: 1, remark: '' },
importText: '', importText: '',
importCategory: 'custom', importCategory: 'custom',
importLevel: 1, importLevel: 1,
auditLogs: [],
searchTimer: null searchTimer: null
} }
}, },
@ -271,34 +238,6 @@ export default {
}, },
categoryLabel(c) { return { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' }[c] || c }, categoryLabel(c) { return { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' }[c] || c },
categoryColor(c) { return { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' }[c] || '#6c757d' },
formatTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' },
formatHitWords(hw) {
if (!hw) return ''
// PostgreSQL JDBC 返回 JSONB 为 PGobject:{"type":"jsonb","value":"...", "null":false}
if (hw.type === 'jsonb' && hw.value) {
try { hw = JSON.parse(hw.value) } catch { return hw.value }
}
if (typeof hw === 'string') { try { hw = JSON.parse(hw) } catch { return hw } }
// 格式:{"hits":[{"word":"xxx","level":2,"category":"politics"}]}
if (hw && hw.hits && Array.isArray(hw.hits)) {
return hw.hits.map(h => `${h.word || ''}(${h.category || ''})`).join('、')
}
if (Array.isArray(hw)) return hw.map(h => typeof h === 'string' ? h : h.word || '').join('、')
return String(hw)
}
},
watch: {
showAuditLog(val) {
if (val) {
this.loadAuditLogs()
this.$nextTick(() => {
if (this.$refs.auditLogPanel) {
this.$refs.auditLogPanel.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
}
}
categoryColor(c) { return { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' }[c] || '#6c757d' }
} }
} }

75
src/main/resources/static/css/main.css

@ -24,19 +24,51 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.topbar .links { margin-left:auto; display:flex; gap:12px; } .topbar .links { margin-left:auto; display:flex; gap:12px; }
.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; } .topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; }
.topbar .links a:hover { opacity:1; } .topbar .links a:hover { opacity:1; }
.menu-toggle { display:none; align-items:center; justify-content:center; width:36px; height:36px; border:none; background:rgba(255,255,255,.1); color:#fff; border-radius:6px; cursor:pointer; font-size:18px; margin-right:4px; flex:none; }
/* ==================== Tab 导航 ==================== */
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; flex:none; z-index:99; }
.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; }
.tab-btn:hover { color:var(--text); }
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
.tab-icon { font-size:18px; }
/* ==================== 布局主体(侧边栏 + 内容区) ==================== */
.layout-body { display:flex; flex:1; min-height:0; overflow:hidden; }
/* ==================== 侧边栏 ==================== */
.sidebar { width:220px; flex:none; background:#1f2937; color:#e5e7eb; display:flex; flex-direction:column; transition:width .25s cubic-bezier(.4,0,.2,1); overflow:hidden; z-index:50; }
.sidebar.collapsed { width:56px; }
.sidebar-inner { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden; }
.sidebar-nav { flex:1; padding:8px 0; }
/* 菜单项 */
.menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 16px; border:none; background:none; color:#d1d5db; font-size:14px; cursor:pointer; transition:background .15s, color .15s; text-align:left; font-family:inherit; white-space:nowrap; position:relative; }
.menu-item:hover { background:rgba(255,255,255,.08); color:#fff; }
.menu-item.active { background:rgba(255,255,255,.12); color:#fff; font-weight:600; }
.menu-icon { font-size:18px; flex:none; width:24px; text-align:center; }
.child-icon { font-size:15px; }
.menu-label { flex:1; overflow:hidden; text-overflow:ellipsis; }
.menu-arrow { font-size:12px; transition:transform .2s; flex:none; }
.menu-arrow.expanded { transform:rotate(0deg); }
.menu-arrow:not(.expanded) { transform:rotate(-90deg); }
/* 子菜单 */
.menu-child { padding-left:30px; font-size:13px; }
.submenu { overflow:hidden; transition:max-height .25s ease; max-height:0; }
.menu-group.open .submenu { max-height:300px; }
/* 折叠按钮 */
.sidebar-collapse-btn { border:none; background:rgba(255,255,255,.06); color:#9ca3af; padding:10px; cursor:pointer; font-size:12px; text-align:center; transition:background .15s; flex:none; }
.sidebar-collapse-btn:hover { background:rgba(255,255,255,.12); color:#fff; }
.sidebar-collapse-btn span { display:inline-block; transition:transform .25s; }
/* 折叠态:仅显示图标 */
.sidebar.collapsed .menu-label,
.sidebar.collapsed .menu-arrow,
.sidebar.collapsed .submenu { display:none; }
.sidebar.collapsed .menu-item { justify-content:center; padding:12px 0; }
.sidebar.collapsed .menu-icon { width:auto; }
/* ==================== 内容区 ==================== */ /* ==================== 内容区 ==================== */
.main { max-width:1400px; width:100%; margin:0 auto; padding:20px; flex:1; min-height:0; overflow-y:auto; }
/* 聊天 Tab:圣杯布局,内容撑满视口、外层不滚动 */
.main { flex:1; min-width:0; padding:20px; overflow-y:auto; }
/* 聊天:圣杯布局,内容撑满视口、外层不滚动 */
.main.main-chat { overflow:hidden; padding:16px; display:flex; } .main.main-chat { overflow:hidden; padding:16px; display:flex; }
.chat-tab-pane { flex:1; min-width:0; min-height:0; display:flex; }
.main.main-chat .page-pane { flex:1; display:flex; min-height:0; min-width:0; }
.page-pane { min-width:0; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} } @keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
/* ==================== 卡片 ==================== */ /* ==================== 卡片 ==================== */
@ -48,7 +80,7 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; } .input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } .input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; }
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); box-shadow:var(--ring); } .input:focus, .textarea:focus, .select:focus { border-color:var(--primary); box-shadow:var(--ring); }
.input { flex:1; min-width:200px; }
.input { flex:1; min-width:200px; width:100%; }
.textarea { width:100%; resize:vertical; min-height:120px; } .textarea { width:100%; resize:vertical; min-height:120px; }
.select { min-width:160px; } .select { min-width:160px; }
.input-sm { width:120px; flex:none; } .input-sm { width:120px; flex:none; }
@ -126,13 +158,18 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.stat-card .label { font-size:12px; color:var(--muted); margin-top:5px; } .stat-card .label { font-size:12px; color:var(--muted); margin-top:5px; }
/* ==================== 弹窗 ==================== */ /* ==================== 弹窗 ==================== */
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:300; display:none; align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; } .modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; } .modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; }
.modal-box h2 { margin-bottom:16px; font-size:18px; } .modal-box h2 { margin-bottom:16px; font-size:18px; }
.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; } .modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; }
.modal-close:hover { color:var(--text); } .modal-close:hover { color:var(--text); }
/* 模型配置弹窗双栏网格 */
.model-form { display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; margin-top:16px; }
.model-form-full { grid-column:1 / -1; }
@media(max-width:640px) { .model-form { grid-template-columns:1fr; } }
/* ==================== 分类标签 ==================== */ /* ==================== 分类标签 ==================== */
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#f3f4f6; color:var(--text); } .category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#f3f4f6; color:var(--text); }
@ -155,7 +192,19 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.msg-item.msg-system { background:#fafafa; border-left:3px solid var(--muted); } .msg-item.msg-system { background:#fafafa; border-left:3px solid var(--muted); }
/* ==================== 响应式 ==================== */ /* ==================== 响应式 ==================== */
@media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } }
.sidebar-overlay { display:none; }
@media(max-width:768px) {
.sidebar { position:fixed; top:56px; left:0; bottom:0; width:260px; transform:translateX(-100%); transition:transform .3s cubic-bezier(.4,0,.2,1); z-index:200; }
.sidebar.open { transform:translateX(0); }
.sidebar.collapsed { width:260px; }
.sidebar-overlay { display:block; position:fixed; top:56px; left:0; right:0; bottom:0; background:rgba(0,0,0,.4); z-index:199; }
.menu-toggle { display:flex; }
.sidebar-collapse-btn { display:none; }
.main { padding:16px; }
.main.main-chat { padding:12px; }
.stream-compare { grid-template-columns:1fr; }
.stat-grid { grid-template-columns:1fr 1fr; }
}
/* ==================== Chat Panel ==================== */ /* ==================== Chat Panel ==================== */
.chat-shell { display:grid; grid-template-columns:260px minmax(0, 1fr); gap:16px; flex:1; min-width:0; min-height:0; } .chat-shell { display:grid; grid-template-columns:260px minmax(0, 1fr); gap:16px; flex:1; min-width:0; min-height:0; }
@ -209,7 +258,7 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
@media(max-width:860px) { @media(max-width:860px) {
.main.main-chat { overflow-y:auto; display:block; } .main.main-chat { overflow-y:auto; display:block; }
.chat-tab-pane { display:block; }
.page-pane { display:block; }
.chat-shell { grid-template-columns:1fr; height:auto; min-height:0; } .chat-shell { grid-template-columns:1fr; height:auto; min-height:0; }
.chat-sidebar { order:1; overflow:visible; } .chat-sidebar { order:1; overflow:visible; }
.chat-main { order:2; min-height:560px; } .chat-main { order:2; min-height:560px; }

202
src/main/resources/static/js/app.js

@ -1,9 +1,9 @@
/** /**
* Vue 应用入口 * Vue 应用入口
* 组合所有组件管理 Tab 切换和顶层布局
* 侧边栏导航 + 内容区布局数据驱动菜单渲染
*/ */
import { createApp, ref } from 'vue'
import { store } from './store.js'
import { createApp } from 'vue'
import { store, MENU_ITEMS } from './store.js'
// 导入组件 // 导入组件
import ChatPanel from '../components/ChatPanel.js' import ChatPanel from '../components/ChatPanel.js'
@ -19,27 +19,27 @@ import ConversationManager from '../components/ConversationManager.js'
import ModelConfigManager from '../components/ModelConfigManager.js' import ModelConfigManager from '../components/ModelConfigManager.js'
import SensitiveWordManager from '../components/SensitiveWordManager.js' import SensitiveWordManager from '../components/SensitiveWordManager.js'
import FaqManager from '../components/FaqManager.js' import FaqManager from '../components/FaqManager.js'
import AuditLogManager from '../components/AuditLogManager.js'
const app = createApp({ const app = createApp({
setup() { setup() {
const activeTab = ref('chat')
// 切换 Tab,进入知识库 Tab 时自动加载数据
function switchTab(tab) {
activeTab.value = tab
if (tab === 'document') {
store.loadCategories()
store.loadStats()
} else if (tab === 'settings') {
store.loadCategories()
}
const menuItems = MENU_ITEMS
/** 判断父菜单是否高亮(任一子页面激活时高亮父菜单) */
function isMenuActive(parentItem) {
if (!parentItem.children) return store.activePage === parentItem.id
return parentItem.children.some(c => c.id === store.activePage)
} }
return { activeTab, switchTab, store }
return { menuItems, isMenuActive, store }
}, },
template: ` template: `
<!-- 移动端遮罩层 -->
<div class="sidebar-overlay" v-if="store.mobileMenuOpen" @click="store.mobileMenuOpen = false"></div>
<!-- 顶部导航 --> <!-- 顶部导航 -->
<div class="topbar"> <div class="topbar">
<button class="menu-toggle" @click="store.mobileMenuOpen = !store.mobileMenuOpen"></button>
<span class="logo">🤖 Support Bot</span><span class="ver">AI </span> <span class="logo">🤖 Support Bot</span><span class="ver">AI </span>
<span class="links"> <span class="links">
<a href="/sdk/test.html" target="_blank">🧪 SDK 测试面板</a> <a href="/sdk/test.html" target="_blank">🧪 SDK 测试面板</a>
@ -47,54 +47,131 @@ const app = createApp({
</span> </span>
</div> </div>
<!-- Tab 导航 -->
<div class="tabs">
<button :class="['tab-btn', activeTab === 'chat' ? 'active' : '']" @click="switchTab('chat')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')">
<span class="tab-icon">📄</span>
</button>
<button :class="['tab-btn', activeTab === 'conversation' ? 'active' : '']" @click="switchTab('conversation')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'settings' ? 'active' : '']" @click="switchTab('settings')">
<span class="tab-icon"></span>
</button>
</div>
<!-- 布局主体侧边栏 + 内容区 -->
<div class="layout-body">
<!-- 左侧边栏 -->
<aside class="sidebar" :class="{ collapsed: store.sidebarCollapsed && !store.mobileMenuOpen, open: store.mobileMenuOpen }">
<div class="sidebar-inner">
<nav class="sidebar-nav">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单的父级菜单项 -->
<div v-if="item.children" :class="['menu-group', store.expandedMenus.includes(item.id) ? 'open' : '']">
<button
class="menu-item menu-parent"
:class="{ active: isMenuActive(item) }"
@click="store.toggleMenu(item.id)"
:title="store.sidebarCollapsed ? item.label : ''"
>
<span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span>
<span class="menu-arrow" :class="{ expanded: store.expandedMenus.includes(item.id) }"></span>
</button>
<!-- 子菜单项 -->
<div class="submenu" v-show="store.expandedMenus.includes(item.id) && !store.sidebarCollapsed">
<button
v-for="child in item.children"
:key="child.id"
class="menu-item menu-child"
:class="{ active: store.activePage === child.id }"
@click="store.navigateTo(child.id)"
>
<span class="menu-icon child-icon">{{ child.icon }}</span>
<span class="menu-label">{{ child.label }}</span>
</button>
</div>
</div>
<!-- 无子菜单的直接导航项 -->
<button
v-else
class="menu-item"
:class="{ active: store.activePage === item.id }"
@click="store.navigateTo(item.id)"
:title="store.sidebarCollapsed ? item.label : ''"
>
<span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span>
</button>
</template>
</nav>
<!-- 侧边栏折叠按钮 -->
<button class="sidebar-collapse-btn" @click="store.toggleSidebar()">
<span :style="{ transform: store.sidebarCollapsed ? 'rotate(180deg)' : '' }"></span>
</button>
</div>
</aside>
<!-- 右侧内容区 -->
<main class="main" :class="{ 'main-chat': store.activePage === 'chat' }">
<!-- 智能客服对话 -->
<div v-if="store.activePage === 'chat'" class="page-pane" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- 知识库 - 统计概览 -->
<div v-if="store.activePage === 'stats'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
</div>
<!-- 知识库 - 文档管理 -->
<div v-if="store.activePage === 'doc-manage'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-list></doc-list>
<doc-upload></doc-upload>
</div>
<!-- 知识库 - 分类管理 -->
<div v-if="store.activePage === 'category'" class="page-pane" style="animation: fadeIn .3s ease;">
<category-manager></category-manager>
</div>
<!-- 知识库 - 搜索测试 -->
<div v-if="store.activePage === 'search-test'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-search></doc-search>
</div>
<!-- 知识库 - FAQ 管理 -->
<div v-if="store.activePage === 'faq'" class="page-pane" style="animation: fadeIn .3s ease;">
<faq-manager></faq-manager>
</div>
<!-- 会话管理 -->
<div v-if="store.activePage === 'conversation'" class="page-pane" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- 系统设置 - 账号管理 -->
<div v-if="store.activePage === 'account'" class="page-pane" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
</div>
<!-- 系统设置 - 角色管理 -->
<div v-if="store.activePage === 'role'" class="page-pane" style="animation: fadeIn .3s ease;">
<role-manager></role-manager>
</div>
<!-- 系统设置 - 模型配置 -->
<div v-if="store.activePage === 'model-config'" class="page-pane" style="animation: fadeIn .3s ease;">
<model-config-manager></model-config-manager>
</div>
<!-- 系统设置 - 敏感词管理 -->
<div v-if="store.activePage === 'sensitive'" class="page-pane" style="animation: fadeIn .3s ease;">
<sensitive-word-manager></sensitive-word-manager>
</div>
<!-- 系统设置 - 审计日志 -->
<div v-if="store.activePage === 'audit-log'" class="page-pane" style="animation: fadeIn .3s ease;">
<audit-log-manager></audit-log-manager>
</div>
<!-- 内容区 -->
<div class="main" :class="{ 'main-chat': activeTab === 'chat' }">
<!-- Tab 1: 智能客服对话 -->
<div v-if="activeTab === 'chat'" class="chat-tab-pane" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- Tab 2: 知识库文档管理 -->
<div v-if="activeTab === 'document'" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
<doc-search></doc-search>
<category-manager></category-manager>
<doc-list></doc-list>
<doc-upload></doc-upload>
<faq-manager></faq-manager>
</div>
<!-- Tab 3: 会话管理 -->
<div v-if="activeTab === 'conversation'" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- Tab 4: 系统设置 -->
<div v-if="activeTab === 'settings'" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
<role-manager></role-manager>
<model-config-manager></model-config-manager>
<sensitive-word-manager></sensitive-word-manager>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</main>
</div> </div>
<!-- Toast 容器 --> <!-- Toast 容器 -->
@ -116,5 +193,6 @@ app.component('conversation-manager', ConversationManager)
app.component('model-config-manager', ModelConfigManager) app.component('model-config-manager', ModelConfigManager)
app.component('sensitive-word-manager', SensitiveWordManager) app.component('sensitive-word-manager', SensitiveWordManager)
app.component('faq-manager', FaqManager) app.component('faq-manager', FaqManager)
app.component('audit-log-manager', AuditLogManager)
app.mount('#app') app.mount('#app')

114
src/main/resources/static/js/store.js

@ -1,10 +1,49 @@
/** /**
* 共享响应式状态 * 共享响应式状态
* 跨组件共享的分类数据统计数据弹窗状态
* 跨组件共享的分类数据统计数据导航状态弹窗状态
*/ */
import { reactive } from 'vue' import { reactive } from 'vue'
import * as api from './api.js' import * as api from './api.js'
// ==================== 菜单配置 ====================
// 数据驱动菜单渲染,未来增删菜单只改此数组
export const MENU_ITEMS = [
{
id: 'chat',
label: '智能客服对话',
icon: '💬'
},
{
id: 'knowledge',
label: '知识库',
icon: '📚',
children: [
{ id: 'stats', label: '统计概览', icon: '📊' },
{ id: 'doc-manage', label: '文档管理', icon: '📄' },
{ id: 'category', label: '分类管理', icon: '🏷️' },
{ id: 'search-test', label: '搜索测试', icon: '🔍' },
{ id: 'faq', label: 'FAQ 管理', icon: '❓' }
]
},
{
id: 'conversation',
label: '会话管理',
icon: '💬'
},
{
id: 'settings',
label: '系统设置',
icon: '⚙️',
children: [
{ id: 'account', label: '账号管理', icon: '👤' },
{ id: 'role', label: '角色管理', icon: '🎭' },
{ id: 'model-config', label: '模型配置', icon: '🤖' },
{ id: 'sensitive', label: '敏感词管理', icon: '🛡️' },
{ id: 'audit-log', label: '审计日志', icon: '📋' }
]
}
]
export const store = reactive({ export const store = reactive({
// ==================== 分类数据 ==================== // ==================== 分类数据 ====================
categories: [], categories: [],
@ -82,5 +121,78 @@ export const store = reactive({
this.detailModal.visible = false this.detailModal.visible = false
this.detailModal.doc = null this.detailModal.doc = null
this.detailModal.chunks = [] this.detailModal.chunks = []
},
// ==================== 导航状态 ====================
activePage: 'chat',
expandedMenus: ['knowledge'],
sidebarCollapsed: false,
mobileMenuOpen: false,
/**
* 页面跳转切换页面 + 自动展开父菜单 + 关闭移动端菜单 + 触发数据加载
*/
navigateTo(pageId) {
this.activePage = pageId
// 自动展开所属父菜单
for (const item of MENU_ITEMS) {
if (item.children && item.children.some(c => c.id === pageId)) {
if (!this.expandedMenus.includes(item.id)) {
this.expandedMenus.push(item.id)
}
}
}
// 关闭移动端菜单
this.mobileMenuOpen = false
// 页面数据加载钩子
this._onPageEnter(pageId)
},
/**
* 展开/折叠父菜单折叠状态下先展开侧边栏
*/
toggleMenu(menuId) {
if (this.sidebarCollapsed) {
this.sidebarCollapsed = false
if (!this.expandedMenus.includes(menuId)) {
this.expandedMenus.push(menuId)
}
return
}
const idx = this.expandedMenus.indexOf(menuId)
if (idx >= 0) {
this.expandedMenus.splice(idx, 1)
} else {
this.expandedMenus.push(menuId)
}
},
/**
* 侧边栏折叠/展开切换
*/
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
},
/**
* 页面进入时的数据加载钩子
*/
_onPageEnter(pageId) {
switch (pageId) {
case 'stats':
case 'doc-manage':
case 'search-test':
this.loadCategories()
this.loadStats()
break
case 'category':
case 'faq':
this.loadCategories()
break
case 'account':
case 'role':
this.loadCategories()
break
}
} }
}) })
Loading…
Cancel
Save