2 Commits
27d17e2af8
...
d6be720965
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
d6be720965 |
按 ui-ux.md 规范重设计「系统配置」页
|
1 week ago |
|
|
61b0ae5639 |
feat(sensitive-word): 敏感词管理页布局与交互优化
- 工具栏新增级别筛选、统计提示,表格新增备注/更新时间列 - 状态列改为 t-switch 一键启用/禁用(失败自动回滚) - 编辑弹窗加宽并新增启用开关、保存 loading、必填校验 - 批量导入提示成功/跳过重复数,删除确认带词内容 - 后端 list 接口新增 level 级别过滤参数 |
1 week ago |
6 changed files with 1075 additions and 116 deletions
-
5frontend/src/api/sensitive-word.ts
-
239frontend/src/views/SensitiveWordManager.vue
-
702frontend/src/views/SystemConfigManager.vue
-
231frontend/ui-ux.md
-
5src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
-
9src/main/java/com/wok/supportbot/service/SensitiveWordService.java
@ -1,104 +1,257 @@ |
|||
<template> |
|||
<t-card title="🛡️ 敏感词管理" :bordered="false"> |
|||
<p class="desc-text">维护敏感词库,「警告」级别仅脱敏处理、「拦截」级别阻断回复;命中记录见「内容审计日志」。</p> |
|||
|
|||
<div class="toolbar"> |
|||
<t-button theme="primary" size="small" @click="showAddDialog = true">+ 添加敏感词</t-button> |
|||
<t-button theme="primary" size="small" @click="openCreate">+ 添加敏感词</t-button> |
|||
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button> |
|||
<div style="flex:1;" /> |
|||
<t-input v-model="searchKeyword" placeholder="搜索敏感词..." size="small" style="width:180px;" clearable @change="debouncedSearch" /> |
|||
<t-select v-model="filterCategory" :options="catOpts" placeholder="全部分类" size="small" style="width:100px;" @change="loadList" /> |
|||
<t-input v-model="searchKeyword" placeholder="搜索敏感词..." clearable size="small" style="width:220px;" @change="debouncedSearch" /> |
|||
<t-select v-model="filterCategory" :options="categoryFilterOpts" placeholder="全部分类" clearable size="small" style="width:130px;" @change="onFilterChange" /> |
|||
<t-select v-model="filterLevel" :options="levelFilterOpts" placeholder="全部级别" clearable size="small" style="width:120px;" @change="onFilterChange" /> |
|||
<span class="total-hint">共 {{ total }} 个</span> |
|||
</div> |
|||
|
|||
<t-table :data="words" :columns="columns" row-key="id" :loading="loading" |
|||
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"> |
|||
<template #category="{ row }"> |
|||
<t-tag size="small" :style="{background:catColor(row.category)}" variant="light">{{ catLabel(row.category) }}</t-tag> |
|||
<t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag> |
|||
</template> |
|||
<template #level="{ row }"> |
|||
<span :style="{ color: row.level >= 2 ? 'var(--td-error-color)' : 'var(--td-warning-color)' }">{{ row.level >= 2 ? '拦截' : '警告' }}</span> |
|||
</template> |
|||
<template #isActive="{ row }"> |
|||
<t-switch v-model="row._active" size="small" @change="toggleActive(row)" /> |
|||
</template> |
|||
<template #level="{ row }"><span :style="{color:row.level>=2?'var(--td-error-color)':'var(--td-warning-color)'}">{{ row.level>=2?'拦截':'警告' }}</span></template> |
|||
<template #isActive="{ row }"><span>{{ row.isActive?'✅':'❌' }}</span></template> |
|||
<template #op="{ row }"> |
|||
<t-space :size="4"> |
|||
<t-button size="small" variant="text" @click="editWord(row)">编辑</t-button> |
|||
<t-button size="small" variant="text" theme="danger" @click="removeWord(row.id)">删除</t-button> |
|||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button> |
|||
<t-button size="small" variant="text" theme="danger" @click="doDelete(row)">删除</t-button> |
|||
</t-space> |
|||
</template> |
|||
</t-table> |
|||
|
|||
<!-- 新增/编辑弹窗 --> |
|||
<t-dialog v-model:visible="dialogVisible" :header="editingWord ? '编辑敏感词' : '添加敏感词'" width="420px" :footer="false" @close="closeDialog"> |
|||
<t-dialog v-model:visible="showFormDialog" :header="editingId ? '编辑敏感词' : '添加敏感词'" width="480px" :footer="false"> |
|||
<t-form label-align="top"> |
|||
<t-form-item label="敏感词"><t-input v-model="form.word" placeholder="输入敏感词" /></t-form-item> |
|||
<t-form-item label="分类"><t-select v-model="form.category" :options="catOpts2" /></t-form-item> |
|||
<t-form-item label="敏感词 *"><t-input v-model="form.word" placeholder="输入敏感词" /></t-form-item> |
|||
<t-form-item label="分类"><t-select v-model="form.category" :options="categoryOpts" /></t-form-item> |
|||
<t-form-item label="级别"><t-select v-model="form.level" :options="levelOpts" /></t-form-item> |
|||
<t-form-item label="是否启用"><t-switch v-model="form.isActive" :label="['启用', '停用']" /></t-form-item> |
|||
<t-form-item label="备注"><t-input v-model="form.remark" placeholder="可选备注" /></t-form-item> |
|||
</t-form> |
|||
<div class="dialog-footer"> |
|||
<t-button variant="outline" @click="closeDialog">取消</t-button> |
|||
<t-button theme="primary" @click="saveWord" :disabled="!form.word">保存</t-button> |
|||
<t-button variant="outline" @click="showFormDialog = false">取消</t-button> |
|||
<t-button theme="primary" :loading="saving" :disabled="!form.word.trim()" @click="doSave">保存</t-button> |
|||
</div> |
|||
</t-dialog> |
|||
|
|||
<!-- 批量导入弹窗 --> |
|||
<t-dialog v-model:visible="showImportDialog" header="批量导入敏感词" width="500px" :footer="false"> |
|||
<p class="desc-text">每行一个敏感词,重复词将自动跳过。</p> |
|||
<t-textarea v-model="importText" :autosize="{ minRows: 6, maxRows: 10 }" placeholder="每行一个敏感词" /> |
|||
<t-row :gutter="16" style="margin-top:12px;"> |
|||
<t-col :span="6"><t-select v-model="importCategory" :options="catOpts2" placeholder="分类" /></t-col> |
|||
<t-col :span="6"><t-select v-model="importCategory" :options="categoryOpts" placeholder="分类" /></t-col> |
|||
<t-col :span="6"><t-select v-model="importLevel" :options="levelOpts" placeholder="级别" /></t-col> |
|||
</t-row> |
|||
<div class="dialog-footer"> |
|||
<t-button variant="outline" @click="showImportDialog = false">取消</t-button> |
|||
<t-button theme="primary" @click="doImport" :disabled="!importText.trim()">导入</t-button> |
|||
<t-button theme="primary" :loading="importing" :disabled="!importText.trim()" @click="doImport">导入</t-button> |
|||
</div> |
|||
</t-dialog> |
|||
</t-card> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted } from 'vue' |
|||
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords } from '@/api/sensitive-word' |
|||
import { ref, onMounted } from 'vue' |
|||
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords, toggleSensitiveWord } from '@/api/sensitive-word' |
|||
import { toast } from '@/utils/toast' |
|||
import { formatDate } from '@/utils/format' |
|||
import { useConfirm } from '@/composables/useConfirm' |
|||
import { useDebounce } from '@/composables/useDebounce' |
|||
|
|||
const { confirm } = useConfirm() |
|||
const { debounce } = useDebounce() |
|||
|
|||
const words = ref<any[]>([]); const loading=ref(false); const page=ref(1); const pageSize=ref(20); const total=ref(0) |
|||
const searchKeyword=ref(''); const filterCategory=ref('') |
|||
const showAddDialog=ref(false); const showImportDialog=ref(false); const editingWord=ref<any>(null) |
|||
const form=ref({word:'',category:'custom',level:1,remark:''}) |
|||
const importText=ref(''); const importCategory=ref('custom'); const importLevel=ref(1) |
|||
// ===== 分类 / 级别常量 ===== |
|||
const categoryOpts = [ |
|||
{ label: '政治', value: 'politics' }, |
|||
{ label: '色情', value: 'porn' }, |
|||
{ label: '辱骂', value: 'abuse' }, |
|||
{ label: '自定义', value: 'custom' }, |
|||
] |
|||
const levelOpts = [ |
|||
{ label: '警告(脱敏处理)', value: 1 }, |
|||
{ label: '拦截(阻断回复)', value: 2 }, |
|||
] |
|||
const CATEGORY_LABEL: Record<string, string> = { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' } |
|||
const CATEGORY_COLOR: Record<string, string> = { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' } |
|||
|
|||
// 工具栏筛选选项(含「全部」) |
|||
const categoryFilterOpts = [{ label: '全部分类', value: '' }, ...categoryOpts] |
|||
const levelFilterOpts = [{ label: '全部级别', value: '' }, ...levelOpts] |
|||
|
|||
// ===== 列表状态 ===== |
|||
const words = ref<any[]>([]) |
|||
const loading = ref(false) |
|||
const page = ref(1) |
|||
const pageSize = ref(20) |
|||
const total = ref(0) |
|||
const searchKeyword = ref('') |
|||
const filterCategory = ref('') |
|||
const filterLevel = ref<number | ''>('') |
|||
|
|||
// ===== 弹窗状态 ===== |
|||
const showFormDialog = ref(false) |
|||
const showImportDialog = ref(false) |
|||
const editingId = ref<string | null>(null) |
|||
const saving = ref(false) |
|||
const importing = ref(false) |
|||
const form = ref({ word: '', category: 'custom', level: 1, isActive: true, remark: '' }) |
|||
const importText = ref('') |
|||
const importCategory = ref('custom') |
|||
const importLevel = ref(1) |
|||
|
|||
const columns = [ |
|||
{colKey:'word',title:'敏感词',width:160},{colKey:'category',title:'分类',width:80}, |
|||
{colKey:'level',title:'级别',width:60},{colKey:'isActive',title:'状态',width:60},{colKey:'op',title:'操作',width:120}, |
|||
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true }, |
|||
{ colKey: 'category', title: '分类', width: 100 }, |
|||
{ colKey: 'level', title: '级别', width: 80 }, |
|||
{ colKey: 'isActive', title: '状态', width: 70 }, |
|||
{ colKey: 'remark', title: '备注', width: 140, ellipsis: true }, |
|||
{ colKey: 'updateTime', title: '更新时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.updateTime) }, |
|||
{ colKey: 'op', title: '操作', width: 120 }, |
|||
] |
|||
|
|||
const dialogVisible = computed({get:()=>showAddDialog.value||!!editingWord.value,set:(v)=>{if(!v)closeDialog()}}) |
|||
onMounted(() => loadList()) |
|||
|
|||
async function loadList() { |
|||
loading.value = true |
|||
try { |
|||
const level = filterLevel.value ? Number(filterLevel.value) : undefined |
|||
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level) |
|||
if (r.success) { |
|||
const list = r.data?.records || r.data || [] |
|||
words.value = list.map((w: any) => ({ ...w, _active: w.isActive })) |
|||
total.value = r.data?.total || r.total || 0 |
|||
} |
|||
} catch (e: any) { |
|||
toast('加载失败:' + e.message, 'error') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const catOpts=[{label:'全部分类',value:''},{label:'政治',value:'politics'},{label:'色情',value:'porn'},{label:'辱骂',value:'abuse'},{label:'自定义',value:'custom'}] |
|||
const catOpts2=catOpts.filter(c=>c.value!=='') |
|||
const levelOpts=[{label:'警告(脱敏处理)',value:1},{label:'拦截(阻断回复)',value:2}] |
|||
function onPageChange(info: { current: number; pageSize: number }) { |
|||
page.value = info.current |
|||
pageSize.value = info.pageSize |
|||
loadList() |
|||
} |
|||
|
|||
onMounted(()=>loadList()) |
|||
function onFilterChange() { |
|||
page.value = 1 |
|||
loadList() |
|||
} |
|||
|
|||
async function loadList(){loading.value=true |
|||
try{const r=await listSensitiveWords(page.value,pageSize.value,searchKeyword.value||undefined,filterCategory.value||undefined) |
|||
if(r.success){words.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}} |
|||
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()} |
|||
const debouncedSearch = debounce(() => { page.value = 1; loadList() }) |
|||
|
|||
function editWord(w:any){editingWord.value=w;form.value={word:w.word,category:w.category,level:w.level,remark:w.remark||''}} |
|||
function closeDialog(){showAddDialog.value=false;editingWord.value=null;form.value={word:'',category:'custom',level:1,remark:''}} |
|||
function openCreate() { |
|||
editingId.value = null |
|||
form.value = { word: '', category: 'custom', level: 1, isActive: true, remark: '' } |
|||
showFormDialog.value = true |
|||
} |
|||
|
|||
function openEdit(w: any) { |
|||
editingId.value = w.id |
|||
form.value = { word: w.word || '', category: w.category || 'custom', level: w.level || 1, isActive: w.isActive !== false, remark: w.remark || '' } |
|||
showFormDialog.value = true |
|||
} |
|||
|
|||
async function doSave() { |
|||
const f = form.value |
|||
const word = f.word.trim() |
|||
if (!word) { |
|||
toast('请填写敏感词', 'error') |
|||
return |
|||
} |
|||
saving.value = true |
|||
try { |
|||
const d = { word, category: f.category, level: f.level, isActive: f.isActive, remark: f.remark } |
|||
let r: any |
|||
if (editingId.value) r = await updateSensitiveWord(editingId.value, d) |
|||
else r = await createSensitiveWord(d) |
|||
if (r.success) { |
|||
toast(editingId.value ? '修改成功' : '添加成功', 'success') |
|||
showFormDialog.value = false |
|||
loadList() |
|||
} else { |
|||
toast(r.message || '操作失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('操作失败:' + e.message, 'error') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function saveWord(){try{if(editingWord.value){const r=await updateSensitiveWord(editingWord.value.id,form.value);if(r.success)toast('修改成功','success')}else{const r=await createSensitiveWord(form.value);if(r.success)toast('添加成功','success');else toast(r.message||'添加失败','error')}closeDialog();loadList()}catch(e:any){toast('操作失败:'+e.message,'error')}} |
|||
async function doDelete(w: any) { |
|||
if (!await confirm('确认删除敏感词「' + (w.word || w.id) + '」?')) return |
|||
try { |
|||
const r = await deleteSensitiveWord(w.id) |
|||
if (r.success) { |
|||
toast('删除成功', 'success') |
|||
loadList() |
|||
} else { |
|||
toast(r.message || '删除失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function removeWord(id:string){if(!await confirm('确认删除?'))return |
|||
try{const r=await deleteSensitiveWord(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}} |
|||
async function toggleActive(w: any) { |
|||
const target = !w.isActive |
|||
try { |
|||
const r = await toggleSensitiveWord(w.id, target) |
|||
if (r.success) { |
|||
w.isActive = target |
|||
w._active = target |
|||
toast(target ? '已启用' : '已禁用', 'success') |
|||
} else { |
|||
w._active = w.isActive |
|||
toast(r.message || '操作失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
w._active = w.isActive |
|||
toast('操作失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function doImport(){const ws=importText.value.split('\n').map(s=>s.trim()).filter(Boolean);if(!ws.length)return toast('请输入敏感词','error') |
|||
try{const r=await batchImportSensitiveWords({words:ws,category:importCategory.value,level:importLevel.value});if(r.success){toast(`成功导入 ${r.data||ws.length} 条`,'success');showImportDialog.value=false;importText.value='';loadList()}else toast(r.message||'导入失败','error')}catch(e:any){toast('导入失败:'+e.message,'error')}} |
|||
async function doImport() { |
|||
const ws = importText.value.split('\n').map(s => s.trim()).filter(Boolean) |
|||
if (!ws.length) { |
|||
toast('请输入敏感词', 'error') |
|||
return |
|||
} |
|||
importing.value = true |
|||
try { |
|||
const r = await batchImportSensitiveWords({ words: ws, category: importCategory.value, level: importLevel.value }) |
|||
if (r.success) { |
|||
const imported = r.data?.imported ?? ws.length |
|||
const skipped = (r.data?.total ?? ws.length) - imported |
|||
toast(skipped > 0 ? `成功导入 ${imported} 条,跳过重复 ${skipped} 条` : `成功导入 ${imported} 条`, 'success') |
|||
showImportDialog.value = false |
|||
importText.value = '' |
|||
loadList() |
|||
} else { |
|||
toast(r.message || '导入失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('导入失败:' + e.message, 'error') |
|||
} finally { |
|||
importing.value = false |
|||
} |
|||
} |
|||
|
|||
function catLabel(c:string){return{politics:'政治',porn:'色情',abuse:'辱骂',custom:'自定义'}[c]||c} |
|||
function catColor(c:string){return{politics:'#dc3545',porn:'#e83e8c',abuse:'#fd7e14',custom:'#6c757d'}[c]||'#6c757d'} |
|||
function catLabel(c: string) { return CATEGORY_LABEL[c] || c } |
|||
function catColor(c: string) { return CATEGORY_COLOR[c] || '#6c757d' } |
|||
</script> |
|||
<style scoped></style> |
|||
@ -1,94 +1,658 @@ |
|||
<template> |
|||
<t-card title="🔧 系统配置" :bordered="false"> |
|||
<t-card :bordered="false" class="config-page-card"> |
|||
<template #title> |
|||
<span class="card-title"> |
|||
<SettingIcon /> |
|||
<span>系统配置</span> |
|||
</span> |
|||
</template> |
|||
<p class="desc-text">管理系统级配置项,修改后实时生效(SDK 窗口刷新即拉取最新内容)。</p> |
|||
|
|||
<div v-if="!editing"> |
|||
<div class="toolbar"> |
|||
<t-button theme="primary" size="small" @click="startAdd">+ 新建配置</t-button> |
|||
<t-button variant="outline" size="small" @click="load">🔄 刷新</t-button> |
|||
</div> |
|||
<t-loading v-if="loading" text="加载中..." /> |
|||
<t-empty v-else-if="!configs.length" description="暂无配置项" /> |
|||
<div v-else> |
|||
<div v-for="item in configs" :key="item.configKey" class="config-item" @click="edit(item)"> |
|||
<div class="config-left"> |
|||
<div class="config-key">{{ item.configKey }}</div> |
|||
<div class="config-desc">{{ item.description || '无描述' }}</div> |
|||
</div> |
|||
<span class="config-time">{{ item.updateTime ? formatDate(item.updateTime) : '' }}</span> |
|||
<t-button size="small" variant="text" theme="danger" @click.stop="deleteItem(item)">🗑 删除</t-button> |
|||
<div class="config-shell"> |
|||
<!-- ===== 左侧:配置列表 ===== --> |
|||
<aside class="config-sidebar"> |
|||
<div class="sidebar-header"> |
|||
<span class="sidebar-title">配置项</span> |
|||
<t-space :size="6"> |
|||
<t-button theme="primary" size="small" @click="startCreate"> |
|||
<template #icon><AddIcon /></template> |
|||
新增 |
|||
</t-button> |
|||
<t-button variant="outline" size="small" @click="handleRefresh"> |
|||
<template #icon><RefreshIcon /></template> |
|||
</t-button> |
|||
</t-space> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-else> |
|||
<div class="edit-header"> |
|||
<t-button variant="outline" size="small" @click="cancelEdit">← 返回列表</t-button> |
|||
<span class="edit-title">{{ isAdd ? '新建' : '编辑' }} {{ editingKey }}</span> |
|||
</div> |
|||
<t-row :gutter="16" style="margin-bottom:12px;"> |
|||
<t-col :span="6"> |
|||
<t-form-item label="配置键 *"><t-input v-model="form.configKey" :disabled="!isAdd" :placeholder="isAdd?'如 disclaimer、welcome_text':''" /></t-form-item> |
|||
</t-col> |
|||
<t-col :span="6"><t-form-item label="描述说明"><t-input v-model="form.description" placeholder="配置用途说明" /></t-form-item></t-col> |
|||
</t-row> |
|||
<t-row :gutter="16"> |
|||
<t-col :span="6"> |
|||
<t-form-item label="HTML 内容"> |
|||
<t-textarea v-model="form.configValue" :autosize="{minRows:10,maxRows:20}" placeholder="输入 HTML 内容..." /> |
|||
</t-form-item> |
|||
</t-col> |
|||
<t-col :span="6"> |
|||
<t-form-item label="👁 实时预览"> |
|||
<div class="preview-box"> |
|||
<div v-html="form.configValue" /> |
|||
<div v-if="!form.configValue" class="preview-empty">输入内容后实时显示效果</div> |
|||
<div class="sidebar-search"> |
|||
<t-input |
|||
v-model="searchKeyword" |
|||
size="small" |
|||
placeholder="搜索配置项..." |
|||
clearable |
|||
> |
|||
<template #prefix-icon><SearchIcon /></template> |
|||
</t-input> |
|||
</div> |
|||
|
|||
<t-loading size="small" :loading="loading" class="sidebar-body"> |
|||
<t-empty v-if="!filteredConfigs.length" :description="configs.length ? '无匹配配置项' : '暂无配置项'" /> |
|||
<div v-else class="config-items"> |
|||
<div |
|||
v-for="item in filteredConfigs" |
|||
:key="item.configKey" |
|||
:class="['config-item', selectedKey === item.configKey && mode !== 'view' && 'active']" |
|||
@click="selectConfig(item)" |
|||
tabindex="0" |
|||
role="button" |
|||
:aria-label="'选择配置 ' + item.configKey" |
|||
@keydown.enter="selectConfig(item)" |
|||
@keydown.space.prevent="selectConfig(item)" |
|||
> |
|||
<div class="config-item-main"> |
|||
<strong class="config-item-key">{{ displayName(item) }}</strong> |
|||
<t-tag size="small" :theme="typeMeta(item.configKey).theme" variant="light"> |
|||
{{ typeMeta(item.configKey).label }} |
|||
</t-tag> |
|||
</div> |
|||
<div class="config-item-desc" :title="item.description || ''">{{ item.description || '—' }}</div> |
|||
</div> |
|||
</div> |
|||
</t-loading> |
|||
</aside> |
|||
|
|||
<!-- ===== 右侧:编辑区 ===== --> |
|||
<section class="config-detail"> |
|||
<!-- 配置已被删除或不存在 --> |
|||
<t-empty v-if="mode === 'edit' && !selectedConfig" description="该配置已被删除,请选择其他配置项" /> |
|||
|
|||
<!-- 未选中且非新建 --> |
|||
<t-empty v-else-if="mode === 'view'" description="请选择左侧配置项,或点击「新增」创建配置" /> |
|||
|
|||
<!-- 新建 / 编辑 --> |
|||
<template v-else> |
|||
<div class="detail-header"> |
|||
<div class="detail-title-row"> |
|||
<h3 class="detail-title">{{ mode === 'create' ? '新建配置' : displayName(selectedConfig) }}</h3> |
|||
<t-tag v-if="mode === 'edit'" size="small" :theme="formTypeMeta.theme" variant="light"> |
|||
{{ formTypeMeta.label }} |
|||
</t-tag> |
|||
</div> |
|||
</t-form-item> |
|||
</t-col> |
|||
</t-row> |
|||
<div style="display:flex;gap:8px;margin-top:16px;"> |
|||
<t-button theme="primary" @click="save" :loading="saving">💾 保存</t-button> |
|||
<t-button variant="outline" @click="cancelEdit">取消</t-button> |
|||
</div> |
|||
<t-button |
|||
v-if="mode === 'edit' && selectedConfig" |
|||
variant="outline" |
|||
theme="danger" |
|||
size="small" |
|||
:loading="deleting" |
|||
@click="handleDelete" |
|||
> |
|||
<template #icon><DeleteIcon /></template> |
|||
删除配置 |
|||
</t-button> |
|||
</div> |
|||
|
|||
<t-form label-align="top" class="detail-form"> |
|||
<t-row :gutter="16"> |
|||
<t-col :span="6" :xs="12"> |
|||
<t-form-item label="配置键 *"> |
|||
<t-input |
|||
v-model="form.configKey" |
|||
:disabled="mode === 'edit' || saving" |
|||
placeholder="如 disclaimer、welcome_text" |
|||
/> |
|||
</t-form-item> |
|||
</t-col> |
|||
<t-col :span="6" :xs="12"> |
|||
<t-form-item label="描述说明"> |
|||
<t-input v-model="form.description" :disabled="saving" placeholder="配置用途说明" /> |
|||
</t-form-item> |
|||
</t-col> |
|||
</t-row> |
|||
|
|||
<!-- 预设键快速选择(仅新建,且存在未创建的预设键) --> |
|||
<t-form-item v-if="mode === 'create' && presetOptions.length" label="预设键快速选择"> |
|||
<t-select |
|||
v-model="presetPick" |
|||
:options="presetOptions" |
|||
placeholder="选择预设配置项(自动带入键名与类型)" |
|||
filterable |
|||
clearable |
|||
:disabled="saving" |
|||
@change="applyPreset" |
|||
/> |
|||
</t-form-item> |
|||
|
|||
<!-- HTML 类型:编辑器 + 实时预览 --> |
|||
<t-form-item v-if="formType === 'html'" label="HTML 内容"> |
|||
<t-row :gutter="16" class="editor-row"> |
|||
<t-col :span="6" :xs="12"> |
|||
<t-textarea |
|||
v-model="form.configValue" |
|||
:disabled="saving" |
|||
:autosize="{ minRows: 10, maxRows: 20 }" |
|||
placeholder="输入 HTML 内容..." |
|||
/> |
|||
</t-col> |
|||
<t-col :span="6" :xs="12"> |
|||
<div class="preview-label">实时预览</div> |
|||
<div class="preview-box"> |
|||
<div v-if="form.configValue" v-html="sanitizedPreview" /> |
|||
<div v-else class="preview-empty">输入内容后实时显示效果</div> |
|||
</div> |
|||
</t-col> |
|||
</t-row> |
|||
</t-form-item> |
|||
|
|||
<!-- 文本类型 --> |
|||
<t-form-item v-else-if="formType === 'text'" label="内容"> |
|||
<t-textarea |
|||
v-model="form.configValue" |
|||
:disabled="saving" |
|||
:autosize="{ minRows: 8, maxRows: 16 }" |
|||
placeholder="输入内容..." |
|||
/> |
|||
</t-form-item> |
|||
|
|||
<!-- 布尔类型 --> |
|||
<t-form-item v-else label="状态"> |
|||
<t-switch v-model="form.checked" :disabled="saving" :label="['启用', '停用']" /> |
|||
</t-form-item> |
|||
</t-form> |
|||
|
|||
<div class="detail-footer"> |
|||
<t-button variant="outline" size="small" :disabled="saving" @click="handleCancel">取消</t-button> |
|||
<t-button theme="primary" size="small" :loading="saving" @click="handleSubmit"> |
|||
<template #icon><CheckIcon /></template> |
|||
保存 |
|||
</t-button> |
|||
</div> |
|||
</template> |
|||
</section> |
|||
</div> |
|||
</t-card> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue' |
|||
import { ref, reactive, computed, onMounted, watch } from 'vue' |
|||
import { SettingIcon, AddIcon, RefreshIcon, SearchIcon, DeleteIcon, CheckIcon } from 'tdesign-icons-vue-next' |
|||
import DOMPurify from 'dompurify' |
|||
import { listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '@/api/system-config' |
|||
import { toast } from '@/utils/toast' |
|||
import { formatDate } from '@/utils/format' |
|||
import { useConfirm } from '@/composables/useConfirm' |
|||
|
|||
const { confirm } = useConfirm() |
|||
|
|||
const configs = ref<any[]>([]); const loading = ref(false); const saving = ref(false) |
|||
const editing = ref(false); const isAdd = ref(false); const editingKey = ref('') |
|||
const editingItem = ref<any>({}); const form = ref({configKey:'',configValue:'',description:''}) |
|||
// ==================== 类型预设目录 ==================== |
|||
type ConfigType = 'html' | 'text' | 'boolean' |
|||
|
|||
interface ConfigPreset { |
|||
type: ConfigType |
|||
label: string |
|||
desc: string |
|||
} |
|||
|
|||
const CONFIG_PRESETS: Record<string, ConfigPreset> = { |
|||
disclaimer: { type: 'html', label: '保密声明', desc: 'SDK 聊天窗口底部的保密声明' }, |
|||
ai_system_prompt: { type: 'text', label: 'AI 全局系统提示词', desc: 'AI 对话全局系统提示词(为空则不注入)' }, |
|||
suggestion_enabled: { type: 'boolean', label: 'AI 推荐问题开关', desc: 'AI 推荐问题功能开关' }, |
|||
suggestion_prompt: { type: 'text', label: 'AI 推荐问题 Prompt', desc: 'AI 推荐问题 Prompt 模板' }, |
|||
} |
|||
|
|||
const TYPE_META: Record<ConfigType, { label: string; theme: string }> = { |
|||
html: { label: 'HTML', theme: 'primary' }, |
|||
text: { label: '文本', theme: 'default' }, |
|||
boolean: { label: '开关', theme: 'success' }, |
|||
} |
|||
|
|||
function resolveType(key: string): ConfigType { |
|||
return CONFIG_PRESETS[key]?.type ?? 'html' |
|||
} |
|||
|
|||
// ==================== 状态 ==================== |
|||
const configs = ref<any[]>([]) |
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const deleting = ref(false) |
|||
const searchKeyword = ref('') |
|||
|
|||
const mode = ref<'view' | 'create' | 'edit'>('view') |
|||
const selectedKey = ref<string | null>(null) |
|||
const presetPick = ref<string>('') |
|||
|
|||
const form = reactive({ |
|||
configKey: '', |
|||
description: '', |
|||
configValue: '', |
|||
checked: true, |
|||
}) |
|||
|
|||
// ==================== 脏检测快照 ==================== |
|||
const snapshot = ref(takeSnapshot()) |
|||
|
|||
function takeSnapshot(): string { |
|||
return JSON.stringify({ |
|||
configKey: form.configKey, |
|||
description: form.description, |
|||
configValue: form.configValue, |
|||
checked: form.checked, |
|||
}) |
|||
} |
|||
|
|||
const isDirty = computed(() => takeSnapshot() !== snapshot.value) |
|||
|
|||
function resetForm(): void { |
|||
form.configKey = '' |
|||
form.description = '' |
|||
form.configValue = '' |
|||
form.checked = true |
|||
snapshot.value = takeSnapshot() |
|||
} |
|||
|
|||
function setFormFromConfig(item: any): void { |
|||
form.configKey = item.configKey || '' |
|||
form.description = item.description || '' |
|||
form.configValue = item.configValue || '' |
|||
form.checked = item.configValue === 'true' |
|||
snapshot.value = takeSnapshot() |
|||
} |
|||
|
|||
// ==================== 计算属性 ==================== |
|||
const selectedConfig = computed(() => configs.value.find(c => c.configKey === selectedKey.value) ?? null) |
|||
|
|||
const filteredConfigs = computed(() => { |
|||
const kw = searchKeyword.value.trim().toLowerCase() |
|||
if (!kw) return configs.value |
|||
return configs.value.filter(c => |
|||
(c.configKey || '').toLowerCase().includes(kw) |
|||
|| (c.description || '').toLowerCase().includes(kw) |
|||
|| (displayName(c) || '').toLowerCase().includes(kw), |
|||
) |
|||
}) |
|||
|
|||
const formType = computed<ConfigType>(() => resolveType(form.configKey.trim())) |
|||
const formTypeMeta = computed(() => TYPE_META[formType.value]) |
|||
const sanitizedPreview = computed(() => DOMPurify.sanitize(form.configValue || '')) |
|||
|
|||
/** 未创建、可快速选择的预设键 */ |
|||
const presetOptions = computed(() => { |
|||
const existing = new Set(configs.value.map(c => c.configKey)) |
|||
return Object.entries(CONFIG_PRESETS) |
|||
.filter(([key]) => !existing.has(key)) |
|||
.map(([key, preset]) => ({ label: `${preset.label}(${key})`, value: key })) |
|||
}) |
|||
|
|||
function typeMeta(key: string) { |
|||
return TYPE_META[resolveType(key)] |
|||
} |
|||
|
|||
function displayName(item: any): string { |
|||
const key = item?.configKey || '' |
|||
return CONFIG_PRESETS[key]?.label ?? key |
|||
} |
|||
|
|||
// ==================== 初始化 ==================== |
|||
onMounted(() => load()) |
|||
|
|||
load() |
|||
// 监听 selectedConfig 变化:被他人删除时自动回退 |
|||
watch(selectedConfig, (cfg) => { |
|||
if (!cfg && mode.value === 'edit') { |
|||
mode.value = 'view' |
|||
selectedKey.value = null |
|||
resetForm() |
|||
} |
|||
}) |
|||
|
|||
async function load(){loading.value=true |
|||
try{const r=await listSystemConfigs();if(r.success)configs.value=r.data||[]}catch(e:any){toast(e.message||'加载失败','error')}finally{loading.value=false}} |
|||
// ==================== 列表加载 ==================== |
|||
async function load(): Promise<void> { |
|||
loading.value = true |
|||
try { |
|||
const r = await listSystemConfigs() |
|||
if (r.success) { |
|||
configs.value = r.data || [] |
|||
// 同步表单(仅当选中项在异步期间未更改) |
|||
if (selectedKey.value && mode.value === 'edit') { |
|||
const fresh = configs.value.find(x => x.configKey === selectedKey.value) |
|||
if (fresh) setFormFromConfig(fresh) |
|||
} |
|||
} else { |
|||
toast('加载配置列表失败:' + (r.message || '未知错误'), 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('加载失败:' + e.message, 'error') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
function edit(item:any){isAdd.value=false;editingKey.value=item.configKey;editingItem.value=item;form.value.configKey=item.configKey;form.value.configValue=item.configValue||'';form.value.description=item.description||'';editing.value=true} |
|||
function startAdd(){isAdd.value=true;editingKey.value='';editingItem.value={};form.value.configKey='';form.value.configValue='';form.value.description='';editing.value=true} |
|||
function cancelEdit(){editing.value=false;isAdd.value=false;editingKey.value='';editingItem.value={};form.value.configKey='';form.value.configValue='';form.value.description=''} |
|||
// ==================== 刷新(带脏检测守卫) ==================== |
|||
async function handleRefresh(): Promise<void> { |
|||
if (!(await guardDirty('刷新'))) return |
|||
await load() |
|||
} |
|||
|
|||
async function save(){if(saving.value)return |
|||
if(isAdd.value){const k=(form.value.configKey||'').trim();if(!k){toast('请输入配置键','error');return};if(!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k)){toast('配置键格式不合法','error');return};editingKey.value=k} |
|||
saving.value=true |
|||
try{const r=await updateSystemConfig(editingKey.value,form.value.configValue,form.value.description||editingItem.value.description||'') |
|||
if(r.success){toast('保存成功','success');cancelEdit();load()}else toast(r.message||'保存失败','error')}catch(e:any){toast(e.message||'保存失败','error')}finally{saving.value=false}} |
|||
async function guardDirty(action: string = '切换'): Promise<boolean> { |
|||
if (!isDirty.value) return true |
|||
return await confirm(`${action}会丢失未保存的修改`, '确定放弃当前修改吗?') |
|||
} |
|||
|
|||
async function deleteItem(item:any){if(!await confirm(`确定删除「${item.configKey}」?`))return |
|||
try{const r=await deleteSystemConfig(item.configKey);if(r.success){toast('删除成功','success');load()}else toast(r.message||'删除失败','error')}catch(e:any){toast(e.message||'删除失败','error')}} |
|||
// ==================== 选择 / 新建 ==================== |
|||
async function selectConfig(item: any): Promise<void> { |
|||
if (selectedKey.value === item.configKey && mode.value === 'edit') return |
|||
if (!(await guardDirty('切换配置'))) return |
|||
mode.value = 'edit' |
|||
selectedKey.value = item.configKey |
|||
setFormFromConfig(item) |
|||
} |
|||
|
|||
async function startCreate(): Promise<void> { |
|||
if (!(await guardDirty('新建配置'))) return |
|||
mode.value = 'create' |
|||
selectedKey.value = null |
|||
presetPick.value = '' |
|||
resetForm() |
|||
} |
|||
|
|||
function applyPreset(key: string): void { |
|||
const preset = CONFIG_PRESETS[key] |
|||
if (!preset) return |
|||
form.configKey = key |
|||
form.description = preset.desc |
|||
} |
|||
|
|||
// ==================== 取消 ==================== |
|||
function handleCancel(): void { |
|||
if (mode.value === 'create') { |
|||
mode.value = 'view' |
|||
selectedKey.value = null |
|||
resetForm() |
|||
} else if (mode.value === 'edit') { |
|||
if (selectedConfig.value) { |
|||
setFormFromConfig(selectedConfig.value) |
|||
} else { |
|||
mode.value = 'view' |
|||
selectedKey.value = null |
|||
resetForm() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ==================== 保存 ==================== |
|||
async function handleSubmit(): Promise<void> { |
|||
if (saving.value) return |
|||
|
|||
const key = form.configKey.trim() |
|||
if (!key) { |
|||
toast('请输入配置键', 'error') |
|||
return |
|||
} |
|||
if (mode.value === 'create' && !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) { |
|||
toast('配置键格式不合法(需以字母开头,仅含字母/数字/下划线/短横线)', 'error') |
|||
return |
|||
} |
|||
|
|||
const value = formType.value === 'boolean' ? (form.checked ? 'true' : 'false') : form.configValue |
|||
const description = form.description.trim() |
|||
|
|||
saving.value = true |
|||
try { |
|||
const r = await updateSystemConfig(key, value, description) |
|||
if (r.success) { |
|||
toast('保存成功', 'success') |
|||
selectedKey.value = key |
|||
mode.value = 'edit' |
|||
await load() |
|||
} else { |
|||
toast(r.message || '保存失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('保存失败:' + e.message, 'error') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除 ==================== |
|||
async function handleDelete(): Promise<void> { |
|||
const item = selectedConfig.value |
|||
if (!item) return |
|||
if (!(await confirm(`确定删除配置「${item.configKey}」?`, '删除后不可恢复,SDK 端将回退到默认内容。'))) return |
|||
deleting.value = true |
|||
try { |
|||
const r = await deleteSystemConfig(item.configKey) |
|||
if (r.success) { |
|||
toast('删除成功', 'success') |
|||
selectedKey.value = null |
|||
mode.value = 'view' |
|||
resetForm() |
|||
await load() |
|||
} else { |
|||
toast(r.message || '删除失败', 'error') |
|||
} |
|||
} catch (e: any) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} finally { |
|||
deleting.value = false |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.config-item{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid #e7e7e7;border-radius:8px;cursor:pointer;margin-bottom:8px;transition:border-color .2s;}.config-item:hover{border-color:var(--td-brand-color);} |
|||
.config-left{flex:1;min-width:0;}.config-key{font-weight:600;font-size:14px;margin-bottom:2px;}.config-desc{font-size:12px;color:#999;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.config-time{font-size:12px;color:#999;margin-left:16px;white-space:nowrap;} |
|||
.edit-header{display:flex;align-items:center;gap:8px;margin-bottom:12px;}.edit-title{font-weight:600;font-size:15px;} |
|||
.preview-box{border:1px solid #e7e7e7;border-radius:8px;padding:12px;min-height:200px;background:#f9fafb;overflow:auto;}.preview-empty{color:#ccc;font-style:italic;} |
|||
/* ===== 页级留白 ===== */ |
|||
.config-page-card { |
|||
margin: 16px; |
|||
} |
|||
|
|||
.card-title { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
/* ===== 整体分栏 ===== */ |
|||
.config-shell { |
|||
display: flex; |
|||
gap: 16px; |
|||
height: calc(100vh - 220px); |
|||
min-height: 520px; |
|||
} |
|||
|
|||
/* ===== 左侧列表 ===== */ |
|||
.config-sidebar { |
|||
width: 260px; |
|||
flex-shrink: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
background: var(--td-bg-color-page); |
|||
border: 1px solid var(--td-border-level-1-color); |
|||
border-radius: 10px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.sidebar-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 14px 16px 12px; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
} |
|||
|
|||
.sidebar-title { |
|||
font-size: 13px; |
|||
font-weight: 600; |
|||
color: var(--td-text-color-secondary); |
|||
} |
|||
|
|||
.sidebar-search { |
|||
padding: 10px 12px; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
} |
|||
|
|||
.sidebar-body { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 10px 12px; |
|||
} |
|||
|
|||
.config-items { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 6px; |
|||
} |
|||
|
|||
.config-item { |
|||
padding: 10px 12px; |
|||
border-radius: 8px; |
|||
border: 1px solid transparent; |
|||
cursor: pointer; |
|||
background: var(--td-bg-color-container); |
|||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; |
|||
} |
|||
|
|||
.config-item:hover { |
|||
background: var(--td-bg-color-component); |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|||
} |
|||
|
|||
.config-item:focus-visible { |
|||
outline: 2px solid var(--td-brand-color); |
|||
outline-offset: -2px; |
|||
} |
|||
|
|||
.config-item.active { |
|||
background: var(--td-brand-color-light); |
|||
border-color: var(--td-brand-color); |
|||
} |
|||
|
|||
.config-item-main { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
margin-bottom: 3px; |
|||
} |
|||
|
|||
.config-item-key { |
|||
flex: 1; |
|||
min-width: 0; |
|||
font-size: 13px; |
|||
color: var(--td-text-color-primary); |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.config-item-desc { |
|||
font-size: 12px; |
|||
color: var(--td-text-color-placeholder); |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
/* ===== 右侧编辑区 ===== */ |
|||
.config-detail { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
background: var(--td-bg-color-container); |
|||
border: 1px solid var(--td-border-level-1-color); |
|||
border-radius: 10px; |
|||
padding: 20px 24px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.detail-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
flex-shrink: 0; |
|||
margin-bottom: 20px; |
|||
padding-bottom: 14px; |
|||
border-bottom: 1px solid var(--td-border-level-1-color); |
|||
} |
|||
|
|||
.detail-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.detail-title { |
|||
margin: 0; |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: var(--td-text-color-primary); |
|||
} |
|||
|
|||
.detail-form { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.editor-row { |
|||
width: 100%; |
|||
} |
|||
|
|||
.detail-footer { |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
gap: 10px; |
|||
flex-shrink: 0; |
|||
margin-top: 24px; |
|||
padding-top: 14px; |
|||
border-top: 1px solid var(--td-border-level-1-color); |
|||
} |
|||
|
|||
/* ===== 实时预览 ===== */ |
|||
.preview-label { |
|||
font-size: 12px; |
|||
color: var(--td-text-color-secondary); |
|||
margin-bottom: 6px; |
|||
} |
|||
|
|||
.preview-box { |
|||
border: 1px solid var(--td-border-level-1-color); |
|||
border-radius: 8px; |
|||
padding: 12px; |
|||
min-height: 220px; |
|||
background: var(--td-bg-color-page); |
|||
overflow: auto; |
|||
} |
|||
|
|||
.preview-empty { |
|||
color: var(--td-text-color-placeholder); |
|||
font-style: italic; |
|||
} |
|||
|
|||
/* ===== 响应式 ===== */ |
|||
@media (max-width: 1024px) { |
|||
.config-sidebar { |
|||
width: 220px; |
|||
} |
|||
.config-detail { |
|||
padding: 16px 20px; |
|||
} |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.config-page-card { |
|||
margin: 12px; |
|||
} |
|||
.config-shell { |
|||
flex-direction: column; |
|||
height: auto; |
|||
} |
|||
.config-sidebar { |
|||
width: auto; |
|||
max-height: 260px; |
|||
} |
|||
.config-detail { |
|||
overflow: visible; |
|||
min-height: 420px; |
|||
} |
|||
.detail-form { |
|||
overflow: visible; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,231 @@ |
|||
--- |
|||
title: 项目技术栈与开发规范 |
|||
created: '2026-06-03' |
|||
tags: |
|||
- guide |
|||
- config |
|||
- work |
|||
- standard |
|||
summary: 本文定义项目技术栈选型、z-index层级规范及响应式布局要求 |
|||
cover: /assets/default-cover.jpg |
|||
updated: '2026-07-20' |
|||
--- |
|||
## 技术栈规范 |
|||
|
|||
| 类别 | 技术选型 | 说明 | |
|||
| ---------- | ----------------------------- | ---------------------------------------------------------------------- | |
|||
| 前端框架 | Vue 3 + JavaScript | 不使用 TypeScript | |
|||
| 构建工具 | Vite + pnpm | 包管理统一使用 pnpm | |
|||
| UI 框架 | tdesign-vue-next | 严格使用第三方组件,尽可能少使用原始标签 | |
|||
| 图标库 | tdesign-icons-vue-next | 所有 icon 统一使用 tdesign-icons-vue-next | |
|||
| 状态管理 | Pinia | 全局状态集中管理 | |
|||
| 代码规范 | eslint + @antfu/eslint-config | 统一代码风格 | |
|||
| 工具库 | xe-utils | 对象、函数、数组、数字、字符串、URL、Web、计算、判空、数据处理统一使用 | |
|||
| 日期处理 | dayjs | 所有日期相关操作统一使用 | |
|||
| Hooks 工具 | vue-hooks-plus | 复用 Vue 组合式交互逻辑、异步状态、DOM 事件、虚拟列表等 hooks | |
|||
|
|||
## 层级规范 |
|||
|
|||
| 层级 | 语义 | 使用场景 | |
|||
| ---: | -------------- | ------------------------------------------ | |
|||
| 0 | base | 普通页面内容 | |
|||
| 10 | raised | 卡片 hover、轻微浮起元素 | |
|||
| 100 | sticky | sticky 表头、吸顶筛选栏 | |
|||
| 200 | local-floating | 页面局部浮动按钮、局部工具栏 | |
|||
| 300 | fixed-nav | 顶部导航、移动端底部导航 | |
|||
| 400 | dropdown | Dropdown、Select、DatePicker、Autocomplete | |
|||
| 500 | popover | Popover、Tooltip、HoverCard | |
|||
| 800 | overlay-local | 页面局部遮罩、局部 loading | |
|||
| 1000 | overlay | 全局遮罩 | |
|||
| 1100 | drawer | Drawer、侧边抽屉 | |
|||
| 1200 | modal | Modal、Dialog | |
|||
| 1300 | modal-floating | Modal 内部 Dropdown、Popover、Tooltip | |
|||
| 1400 | toast | Toast、全局通知 | |
|||
| 1500 | global-loading | 全屏 Loading、页面阻断加载 | |
|||
| 2000 | onboarding | 新手引导、产品引导遮罩 | |
|||
| 3000 | system | 系统级弹窗、强制升级、维护提示 | |
|||
| 9000 | emergency | 特殊兜底层,必须注释说明 | |
|||
| 9999 | max | 最高优先级,仅限极特殊场景 | |
|||
|
|||
- 多数场景使用 Tailwind `z-{number}` 直接对应(`z-100`、`z-300`、`z-400`、`z-500`、`z-1200`、`z-1300`)。 |
|||
- 1000 之后是 4 位数,Tailwind 默认不提供,使用 `z-[1000]` / `z-[1300]` 等任意值语法。 |
|||
- 0 ~ 500 用 `z-` 原生档位;不随意使用 `z-[9999]`。 |
|||
|
|||
## Scroll 滚动规范 |
|||
|
|||
| 类别 | 规范 | 说明 | |
|||
| ------------ | ----------------------------- | ----------------------------------------------------------------- | |
|||
| 滚动容器 | 单一主滚动区域 | 应用层只允许一个主滚动容器,禁止 body、main、组件多层嵌套滚动 | |
|||
| 页面滚动 | Router 统一管理 | 禁止组件内部直接调用 window.scrollTo,统一通过路由滚动策略处理 | |
|||
| 滚动恢复 | 支持页面级 scroll restoration | 列表页返回时恢复原滚动位置,普通页面进入默认滚动到顶部 | |
|||
| 滚动区域 | 明确 scroll-root | Layout 层定义主滚动容器,业务组件不得随意创建页面滚动区域 | |
|||
| 局部滚动 | scroll-section | 大列表、日志、代码区域等允许局部滚动,但必须明确高度和边界 | |
|||
| 弹窗滚动 | Modal / Drawer 独立滚动 | 弹窗内部内容滚动,不影响 body 滚动状态 | |
|||
| Body 锁定 | Overlay 打开时禁止背景滚动 | Modal、Drawer、全屏遮罩打开时锁定 body,关闭后恢复 | |
|||
| Sticky | 必须绑定正确滚动容器 | sticky 元素必须位于对应 scroll container 内,避免定位失效 | |
|||
| 表格滚动 | VXE Table 统一处理 | 表格内部横向滚动,禁止页面整体横向滚动 | |
|||
| 表格固定 | 使用组件能力 | VXE Table 固定表头、固定列必须使用组件配置,不手写定位实现 | |
|||
| 大数据列表 | 必须虚拟滚动 | 超过指定数据量的大列表必须使用虚拟列表优化性能 | |
|||
| 滚动事件 | Hook 统一封装 | 禁止页面散落 addEventListener,统一通过 useScroll 等 hooks 管理 | |
|||
| 滚动监听 | 必须节流优化 | scroll 事件必须使用 throttle / requestAnimationFrame 降低性能消耗 | |
|||
| 滚动动画 | 局部启用 smooth | 仅允许锚点跳转、返回顶部使用 smooth,禁止全局开启 | |
|||
| 减少动画 | 支持 prefers-reduced-motion | 用户开启减少动画模式时关闭滚动动画效果 | |
|||
| 锚点定位 | 统一 scrollToAnchor | 禁止业务组件直接调用 scrollIntoView,统一封装滚动方法 | |
|||
| Sticky 偏移 | 使用 scroll-margin-top | 页面存在固定导航时,锚点定位必须考虑顶部遮挡问题 | |
|||
| 返回顶部 | local-floating 层级 | 返回顶部按钮使用 z-200,超过滚动阈值后显示 | |
|||
| 横向滚动 | 禁止页面级横向滚动 | 页面禁止 overflow-x 滚动,特殊区域必须局部控制 | |
|||
| 移动端滚动 | 适配 Touch 滚动 | 支持 iOS / Android 惯性滚动,避免滚动卡顿 | |
|||
| 滚动高度稳定 | 避免 CLS | Skeleton、图片、异步内容必须保持高度稳定,避免滚动位置跳动 | |
|||
| 数据刷新 | 保留滚动上下文 | 刷新数据时保持当前滚动位置,不因重新渲染导致页面跳顶部 | |
|||
| 请求失败 | 保持用户上下文 | 请求失败进入错误态时保持当前滚动位置和已有数据 | |
|||
| 图片加载 | 预留尺寸 | 图片必须设置宽高或 aspect-ratio,避免加载造成页面跳动 | |
|||
| 虚拟列表 | 统一封装 | 虚拟滚动能力通过 hooks / 组件提供,禁止业务重复实现 | |
|||
| 滚动状态 | 禁止随意持久化 | 不允许直接 localStorage 保存 scrollTop,统一由滚动管理器维护 | |
|||
| 滚动性能 | 监控长任务 | 大量滚动计算必须避免阻塞主线程,必要时接入 Performance 监控 | |
|||
| 无障碍 | 支持键盘滚动 | 页面滚动区域必须保持键盘访问能力和 focus 可见性 | |
|||
| 滚动恢复清理 | 路由离开自动处理 | 页面卸载时清理滚动监听、缓存状态、事件绑定 | |
|||
|
|||
## 布局 |
|||
|
|||
固定视口应用壳 + 圣杯布局 + 局部滚动 |
|||
┌──────────────── 固定顶部导航 ────────────────┐ |
|||
|
|||
│ 平台切换、全局操作、应用状态 │ |
|||
├──────────────┬──────────────────┬────────────┤ |
|||
│ 左侧栏 │ 主内容区 │ 右侧栏 │ |
|||
│ │ │ │ |
|||
│ 配置列表 │ 配置编辑区 │ 环境检测 │ |
|||
│ 平台导航 │ 独立局部滚动 │ 生成结果 │ |
|||
│ 快捷操作 │ │ 状态信息 │ |
|||
│ │ │ │ |
|||
├──────────────┴──────────────────┴────────────┤ |
|||
│ 固定状态栏 │ |
|||
└─────────────────────────────────────────────┘ |
|||
|
|||
## 响应式要求 |
|||
|
|||
- 支持移动端。 |
|||
- 支持平板端。 |
|||
- 页面必须使用响应式布局。 |
|||
|
|||
## API 层标准化 |
|||
|
|||
| 类别 | 规范 | 说明 | |
|||
| ------------ | ---------------------- | ---------------------------------------- | |
|||
| 请求封装 | Axios 二次封装 | 统一 baseURL、timeout、headers、错误处理 | |
|||
| API 结构 | 按模块拆分 | `api/modules/*.api.js`,禁止写在组件内 | |
|||
| 请求拦截 | Token + Error 统一处理 | 自动注入 token、统一处理 401/500 | |
|||
| 响应结构 | 标准化返回 | `{ code, data, message }` 统一格式 | |
|||
| 错误处理 | 全局错误中心 | Toast + 日志 + 可选上报 | |
|||
| 取消请求 | AbortController | 页面切换自动取消未完成请求 | |
|||
| 并发控制 | 防重复请求 | 相同 key 请求去重 | |
|||
| Loading 管理 | 请求级 + 页面级 | 避免手动控制 loading 状态 | |
|||
|
|||
--- |
|||
|
|||
## Skeleton / Loading 规范 |
|||
|
|||
| 场景 | 优先方案 | 说明 | |
|||
| ---------- | ---------------------------- | ------------------------------------------------ | |
|||
| 首屏加载 | 页面骨架屏 | 模拟真实页面结构,避免白屏和布局跳动 | |
|||
| 模块加载 | 局部骨架屏 | 卡片、列表、表格、详情区按实际内容占位 | |
|||
| 数据刷新 | 保留旧数据 + 局部 Loading | 不清空页面,避免用户失去上下文 | |
|||
| 表单提交 | 按钮 Loading + 禁用提交按钮 | 防重复提交,提交中保留表单内容 | |
|||
| 表格加载 | VXE Table loading / 表格骨架 | 表头固定,行高稳定,禁止整页遮罩替代表格加载 | |
|||
| 路由切换 | 页面级 Loading | 只在跨页面等待明显时使用,优先展示目标页骨架 | |
|||
| 阻断型任务 | 全局 Loading | 仅限鉴权初始化、应用启动、强制等待等不可交互场景 | |
|||
|
|||
- 骨架屏必须贴近真实布局:标题、头像、卡片、表格行、按钮区域要按最终尺寸占位,禁止使用一整块灰色矩形糊弄。 |
|||
- 骨架屏风格必须匹配 Animal Island:圆角、柔和底色、轻微点状或软边框质感;禁止使用生硬的 Tailwind 默认灰阶风格。 |
|||
- 骨架元素必须有稳定宽高、`min-height` 或固定行高,加载完成前后不能造成明显 CLS。 |
|||
- 首次进入页面优先使用骨架屏;只有小面积异步操作、按钮提交、短时请求才使用 spinner / loading icon。 |
|||
- 局部 Loading 使用层级 `z-800`;全屏 Loading 使用 `z-[1500]`,不得随意使用 `z-[9999]`。 |
|||
- 加载超过 3 秒必须展示明确状态文案;超过 8 秒必须提供重试、取消或返回入口。 |
|||
- 请求失败后必须进入错误态,数据为空必须进入空态,不能让 loading 无限停留。 |
|||
- 页面级异步状态应由 composable、Pinia 或 API SDK 统一管理;禁止在多个组件里散落重复的 `isLoading` 和手写请求状态。 |
|||
- 对刷新类请求,应优先保留已有数据并展示局部刷新状态;只有首次无数据时才用完整骨架屏。 |
|||
- 动效必须克制,遵守 `prefers-reduced-motion`;骨架 shimmer 不得过亮、过快或大面积闪烁。 |
|||
- Loading 文案使用业务语义,如“正在生成配置”“正在同步状态”;禁止只有“Loading...”且无上下文。 |
|||
|
|||
## 测试体系规范 |
|||
|
|||
| 类别 | 工具 | 说明 | |
|||
| --------- | -------------------------- | ---------------------------------- | |
|||
| 单元测试 | Vitest | 逻辑函数、工具库必须覆盖 | |
|||
| 组件测试 | Vue Test Utils | 关键 UI 组件行为测试 | |
|||
| E2E 测试 | Playwright | 核心业务流程(登录/下单/操作链路) | |
|||
| Mock 数据 | MSW / Vitest mock | API 层隔离测试 | |
|||
| 覆盖率 | Vitest coverage | 核心模块 ≥ 80% | |
|||
| 测试范围 | 分级测试 | utils > service > component > flow | |
|||
| CI 集成 | GitHub Actions / GitLab CI | 每次 PR 必跑测试 | |
|||
| 回归策略 | 关键路径回归 | 核心业务必须 e2e 覆盖 | |
|||
|
|||
--- |
|||
|
|||
## 规范约束体系 |
|||
|
|||
| 类别 | 规范 | 说明 | |
|||
| ----------- | ----------------------------- | ---------------------------- | |
|||
| 代码风格 | eslint + @antfu/eslint-config | 全项目统一风格 | |
|||
| 组件规范 | 三层结构 | Base / Layout / Business | |
|||
| 命名规范 | 语义化命名 | 禁止 a/b/c、data1 这种变量 | |
|||
| 引用规范 | 禁止跨 feature 引用 | feature 之间不能直接依赖 | |
|||
| import 顺序 | 规范化排序 | 外部库 → 内部模块 → 相对路径 | |
|||
| 目录约束 | feature-first | 按业务模块组织代码 | |
|||
| JSDoc | JS 类型补强 | 无 TS 场景必须写 JSDoc | |
|||
|
|||
--- |
|||
|
|||
## 性能 & 监控兜底 |
|||
|
|||
| 类别 | 方案 | 说明 | |
|||
| -------- | -------------------- | ---------------------------- | |
|||
| 路由优化 | Vue Router lazy load | 页面级懒加载 | |
|||
| 代码拆分 | Vite manualChunks | vendor / core / feature 分包 | |
|||
| 静态资源 | 图片懒加载 | IntersectionObserver | |
|||
| 列表优化 | 虚拟列表 | 大数据列表必须使用 | |
|||
| 缓存策略 | HTTP + localStorage | 关键数据缓存 | |
|||
| 错误监控 | Sentry / 自建日志 | JS 错误 + API 错误上报 | |
|||
| 性能监控 | Web Vitals | LCP / FID / CLS 监控 | |
|||
| 请求监控 | API 耗时统计 | slow request 记录 | |
|||
| 白屏兜底 | loading + fallback | 首屏异常兜底页面 | |
|||
| 全局异常 | errorHandler | Vue + Promise 统一捕获 | |
|||
| 资源压缩 | gzip / brotli | Vite compression 插件 | |
|||
| 降级策略 | feature flag | 异常时关闭非核心功能 | |
|||
|
|||
--- |
|||
|
|||
## 目录结构规范(Feature First) |
|||
|
|||
| 层级 | 目录 | 说明 | |
|||
| ------ | ---------------- | ------------------------------------------- | |
|||
| 核心层 | `src/core` | 项目级能力(request、router、store 初始化) | |
|||
| 通用层 | `src/shared` | 纯工具 / 通用组件 / hooks | |
|||
| 业务层 | `src/features` | 按业务模块拆分(核心规范) | |
|||
| UI层 | `src/components` | 全局通用组件 | |
|||
| 布局层 | `src/layouts` | 页面布局结构 | |
|||
| 页面层 | `src/pages` | 路由入口页面 | |
|||
| API层 | `src/api` | API SDK(模块化) | |
|||
| 状态层 | `src/stores` | Pinia store | |
|||
| 资源层 | `src/assets` | 图片 / 样式 / 字体 | |
|||
| 配置层 | `src/config` | 环境配置、常量 | |
|||
|
|||
--- |
|||
|
|||
## ENV 文件规范 |
|||
|
|||
| 类别 | 规范 | 说明 | |
|||
| -------- | ------------------- | ---------------------------------------------------------- | |
|||
| 环境文件 | `.env` 分层管理 | `.env / .env.development / .env.staging / .env.production` | |
|||
| 变量前缀 | `VITE_` 必须前缀 | Vite 仅暴露 `VITE_` 开头变量 | |
|||
| 命名规范 | 大写 + 下划线 | 如 `VITE_API_BASE_URL` | |
|||
| 使用方式 | 统一 env 封装访问 | 禁止直接使用 `import.meta.env` | |
|||
| 环境区分 | mode 控制 | `vite --mode staging` | |
|||
| mock控制 | env 控制 mock 开关 | `VITE_MOCK=true/false` | |
|||
| API绑定 | baseURL 由 env 控制 | 不允许写死 API 地址 | |
|||
|
|||
| 类别 | 规范 | 说明 | |
|||
| ------ | -------------------- | ------------------- | |
|||
| API层 | request 统一读取 env | baseURL 从 env 注入 | |
|||
| 配置层 | `src/config/env.js` | 统一封装 env 读取 | |
|||
| 业务层 | 禁止直接读取 env | 必须通过 config 层 | |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue