|
|
@ -2,88 +2,538 @@ |
|
|
<t-card title="🎭 客服角色管理" :bordered="false"> |
|
|
<t-card title="🎭 客服角色管理" :bordered="false"> |
|
|
<p class="desc-text">为角色设定人设并授权可检索的知识库分类。对话页只能选择这里配置好的角色,检索范围由服务端按角色强制限定。</p> |
|
|
<p class="desc-text">为角色设定人设并授权可检索的知识库分类。对话页只能选择这里配置好的角色,检索范围由服务端按角色强制限定。</p> |
|
|
|
|
|
|
|
|
<!-- 新增/编辑表单 --> |
|
|
|
|
|
<div class="card-box form-bar"> |
|
|
|
|
|
<t-row :gutter="12"> |
|
|
|
|
|
<t-col :span="4"><t-input v-model="form.name" placeholder="角色名称 *" size="small" /></t-col> |
|
|
|
|
|
<t-col :span="4"><t-input v-model="form.description" placeholder="职责描述(可选)" size="small" /></t-col> |
|
|
|
|
|
<t-col :span="2"><t-checkbox v-model="form.enabled">启用</t-checkbox></t-col> |
|
|
|
|
|
</t-row> |
|
|
|
|
|
<t-textarea v-model="form.prompt" :autosize="{minRows:2,maxRows:3}" placeholder="角色人设/风格提示词" style="margin-top:8px;" /> |
|
|
|
|
|
<t-space style="margin-top:8px;"> |
|
|
|
|
|
<t-button theme="success" size="small" @click="submit">{{ editingId?'保存修改':'新增角色' }}</t-button> |
|
|
|
|
|
<t-button v-show="editingId" variant="outline" size="small" @click="resetForm">取消编辑</t-button> |
|
|
|
|
|
<t-button variant="outline" size="small" @click="reload">刷新</t-button> |
|
|
|
|
|
|
|
|
<div class="role-shell"> |
|
|
|
|
|
<!-- ===== 左侧:角色列表 ===== --> |
|
|
|
|
|
<aside class="role-sidebar"> |
|
|
|
|
|
<div class="sidebar-header"> |
|
|
|
|
|
<span class="sidebar-title">角色列表</span> |
|
|
|
|
|
<t-space :size="6"> |
|
|
|
|
|
<t-button theme="primary" size="small" @click="startCreate">+ 新增</t-button> |
|
|
|
|
|
<t-button variant="outline" size="small" @click="handleRefresh">刷新</t-button> |
|
|
</t-space> |
|
|
</t-space> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<!-- 角色列表 --> |
|
|
|
|
|
|
|
|
<t-loading size="small" :loading="loading" class="sidebar-body"> |
|
|
<t-empty v-if="!roles.length" description="暂无角色" /> |
|
|
<t-empty v-if="!roles.length" description="暂无角色" /> |
|
|
<div v-else> |
|
|
|
|
|
<div v-for="role in roles" :key="role.id" class="role-card" :style="{opacity:role.enabled?'1':'.6'}"> |
|
|
|
|
|
<div class="role-header"> |
|
|
|
|
|
<div> |
|
|
|
|
|
<strong class="role-name">{{ role.name }}</strong> |
|
|
|
|
|
<t-tag v-if="!role.enabled" size="small" theme="danger" variant="light" style="margin-left:6px;">已停用</t-tag> |
|
|
|
|
|
<div class="role-desc">{{ role.description || '—' }}</div> |
|
|
|
|
|
|
|
|
<div v-else class="role-items"> |
|
|
|
|
|
<div |
|
|
|
|
|
v-for="role in roles" |
|
|
|
|
|
:key="role.id" |
|
|
|
|
|
:class="['role-item', selectedId === role.id && 'active']" |
|
|
|
|
|
@click="selectRole(role)" |
|
|
|
|
|
tabindex="0" |
|
|
|
|
|
role="button" |
|
|
|
|
|
:aria-label="'选择角色 ' + role.name" |
|
|
|
|
|
@keydown.enter="selectRole(role)" |
|
|
|
|
|
@keydown.space.prevent="selectRole(role)" |
|
|
|
|
|
> |
|
|
|
|
|
<div class="role-item-main"> |
|
|
|
|
|
<strong class="role-item-name">{{ role.name }}</strong> |
|
|
|
|
|
<t-tag size="small" :theme="role.enabled ? 'success' : 'default'" variant="light"> |
|
|
|
|
|
{{ role.enabled ? '启用' : '停用' }} |
|
|
|
|
|
</t-tag> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="role-item-desc" :title="role.description || ''">{{ role.description || '—' }}</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
</t-loading> |
|
|
|
|
|
</aside> |
|
|
|
|
|
|
|
|
|
|
|
<!-- ===== 右侧:编辑区 ===== --> |
|
|
|
|
|
<section class="role-detail"> |
|
|
|
|
|
<!-- 角色已被删除或不存在 --> |
|
|
|
|
|
<t-empty v-if="mode === 'edit' && !selectedRole" 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' ? '新增角色' : selectedRole?.name }}</h3> |
|
|
|
|
|
<t-tag |
|
|
|
|
|
v-if="mode === 'edit' && selectedRole" |
|
|
|
|
|
size="small" |
|
|
|
|
|
:theme="selectedRole.enabled ? 'success' : 'default'" |
|
|
|
|
|
variant="light" |
|
|
|
|
|
> |
|
|
|
|
|
{{ selectedRole.enabled ? '启用' : '停用' }} |
|
|
|
|
|
</t-tag> |
|
|
</div> |
|
|
</div> |
|
|
<t-space :size="4"> |
|
|
|
|
|
<t-button size="small" variant="text" @click="startEdit(role)">编辑</t-button> |
|
|
|
|
|
<t-button size="small" variant="text" theme="danger" @click="remove(role)">删除</t-button> |
|
|
|
|
|
|
|
|
<t-space :size="6"> |
|
|
|
|
|
<t-button |
|
|
|
|
|
v-if="mode === 'edit' && selectedRole" |
|
|
|
|
|
variant="outline" |
|
|
|
|
|
theme="danger" |
|
|
|
|
|
size="small" |
|
|
|
|
|
:loading="deleting" |
|
|
|
|
|
@click="handleDelete" |
|
|
|
|
|
> |
|
|
|
|
|
删除角色 |
|
|
|
|
|
</t-button> |
|
|
</t-space> |
|
|
</t-space> |
|
|
</div> |
|
|
</div> |
|
|
<div v-if="role.prompt" class="role-prompt">人设:{{ role.prompt }}</div> |
|
|
|
|
|
<div class="role-categories"> |
|
|
|
|
|
<span class="cat-label">可检索知识库:</span> |
|
|
|
|
|
<t-checkbox v-for="c in categoryStore.categories" :key="c.id" |
|
|
|
|
|
:checked="role.categoryIds.includes(String(c.id))" |
|
|
|
|
|
@change="toggleCategory(role, String(c.id), ($event as any))"> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<t-form label-align="top" class="detail-form"> |
|
|
|
|
|
<t-row :gutter="16"> |
|
|
|
|
|
<t-col :span="8"> |
|
|
|
|
|
<t-form-item label="角色名称 *"> |
|
|
|
|
|
<t-input v-model="form.name" :disabled="saving" placeholder="请输入角色名称" /> |
|
|
|
|
|
</t-form-item> |
|
|
|
|
|
</t-col> |
|
|
|
|
|
<t-col :span="4"> |
|
|
|
|
|
<t-form-item label="状态"> |
|
|
|
|
|
<t-switch v-model="form.enabled" :disabled="saving" :label="['启用', '停用']" /> |
|
|
|
|
|
</t-form-item> |
|
|
|
|
|
</t-col> |
|
|
|
|
|
</t-row> |
|
|
|
|
|
|
|
|
|
|
|
<t-form-item label="职责描述"> |
|
|
|
|
|
<t-input v-model="form.description" :disabled="saving" placeholder="职责描述(可选)" /> |
|
|
|
|
|
</t-form-item> |
|
|
|
|
|
|
|
|
|
|
|
<t-form-item label="人设 / 风格提示词"> |
|
|
|
|
|
<t-textarea |
|
|
|
|
|
v-model="form.prompt" |
|
|
|
|
|
:disabled="saving" |
|
|
|
|
|
:autosize="{ minRows: 4, maxRows: 8 }" |
|
|
|
|
|
placeholder="为角色设定人设、风格与回答约束,该提示词将注入到 AI 系统 Prompt 中" |
|
|
|
|
|
/> |
|
|
|
|
|
</t-form-item> |
|
|
|
|
|
|
|
|
|
|
|
<t-form-item label="可检索知识库授权"> |
|
|
|
|
|
<t-checkbox-group v-if="categoryStore.categories.length" v-model="form.categoryIds"> |
|
|
|
|
|
<t-checkbox |
|
|
|
|
|
v-for="c in categoryStore.categories" |
|
|
|
|
|
:key="c.id" |
|
|
|
|
|
:value="String(c.id)" |
|
|
|
|
|
:disabled="saving" |
|
|
|
|
|
> |
|
|
{{ c.name }} |
|
|
{{ c.name }} |
|
|
</t-checkbox> |
|
|
</t-checkbox> |
|
|
<span v-if="!categoryStore.categories.length" class="cat-hint">暂无知识库分类</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
</t-checkbox-group> |
|
|
|
|
|
<span v-else class="desc-text">暂无知识库分类,请先在知识库管理中创建分类</span> |
|
|
|
|
|
</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">保存角色</t-button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
</template> |
|
|
|
|
|
</section> |
|
|
</div> |
|
|
</div> |
|
|
</t-card> |
|
|
</t-card> |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<script setup lang="ts"> |
|
|
<script setup lang="ts"> |
|
|
import { ref, reactive, onMounted } from 'vue' |
|
|
|
|
|
|
|
|
import { ref, reactive, computed, onMounted, watch } from 'vue' |
|
|
import { useCategoryStore } from '@/stores/category' |
|
|
import { useCategoryStore } from '@/stores/category' |
|
|
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role' |
|
|
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role' |
|
|
import { toast } from '@/utils/toast' |
|
|
import { toast } from '@/utils/toast' |
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
import { useConfirm } from '@/composables/useConfirm' |
|
|
|
|
|
|
|
|
const { confirm } = useConfirm() |
|
|
const { confirm } = useConfirm() |
|
|
|
|
|
|
|
|
const categoryStore = useCategoryStore() |
|
|
const categoryStore = useCategoryStore() |
|
|
|
|
|
|
|
|
|
|
|
// ---- 数据状态 ---- |
|
|
const roles = ref<any[]>([]) |
|
|
const roles = ref<any[]>([]) |
|
|
const editingId = ref('') |
|
|
|
|
|
const form = reactive({name:'',description:'',prompt:'',enabled:true}) |
|
|
|
|
|
|
|
|
const loading = ref(false) |
|
|
|
|
|
const saving = ref(false) |
|
|
|
|
|
const deleting = ref(false) |
|
|
|
|
|
|
|
|
|
|
|
const mode = ref<'view' | 'create' | 'edit'>('view') |
|
|
|
|
|
const selectedId = ref<string | null>(null) |
|
|
|
|
|
|
|
|
|
|
|
const form = reactive({ |
|
|
|
|
|
name: '', |
|
|
|
|
|
description: '', |
|
|
|
|
|
prompt: '', |
|
|
|
|
|
enabled: true, |
|
|
|
|
|
categoryIds: [] as string[], |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// ---- 脏检测快照(ref 保证响应式隔离) ---- |
|
|
|
|
|
const snapshot = ref(takeSnapshot()) |
|
|
|
|
|
|
|
|
|
|
|
function takeSnapshot(): string { |
|
|
|
|
|
return JSON.stringify({ |
|
|
|
|
|
name: form.name, |
|
|
|
|
|
description: form.description, |
|
|
|
|
|
prompt: form.prompt, |
|
|
|
|
|
enabled: form.enabled, |
|
|
|
|
|
categoryIds: [...form.categoryIds].sort(), |
|
|
|
|
|
}) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function resetForm(): void { |
|
|
|
|
|
form.name = '' |
|
|
|
|
|
form.description = '' |
|
|
|
|
|
form.prompt = '' |
|
|
|
|
|
form.enabled = true |
|
|
|
|
|
form.categoryIds = [] |
|
|
|
|
|
snapshot.value = takeSnapshot() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function setFormFromRole(role: any): void { |
|
|
|
|
|
form.name = role.name || '' |
|
|
|
|
|
form.description = role.description || '' |
|
|
|
|
|
form.prompt = role.prompt || '' |
|
|
|
|
|
form.enabled = role.enabled !== false |
|
|
|
|
|
form.categoryIds = [...(role.categoryIds || [])] |
|
|
|
|
|
snapshot.value = takeSnapshot() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const isDirty = computed(() => takeSnapshot() !== snapshot.value) |
|
|
|
|
|
|
|
|
|
|
|
// ---- 计算属性 ---- |
|
|
|
|
|
const selectedRole = computed(() => roles.value.find(r => r.id === selectedId.value) ?? null) |
|
|
|
|
|
|
|
|
|
|
|
// ---- 初始化 ---- |
|
|
|
|
|
onMounted(() => { |
|
|
|
|
|
reload() |
|
|
|
|
|
categoryStore.loadCategories() |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// ---- 监听 selectedRole 变化:被他人删除时自动回退 ---- |
|
|
|
|
|
watch(selectedRole, (role) => { |
|
|
|
|
|
if (!role && mode.value === 'edit') { |
|
|
|
|
|
mode.value = 'view' |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
resetForm() |
|
|
|
|
|
} |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// ---- 列表加载 ---- |
|
|
|
|
|
async function reload(): Promise<void> { |
|
|
|
|
|
const currentSelectedId = selectedId.value |
|
|
|
|
|
loading.value = true |
|
|
|
|
|
try { |
|
|
|
|
|
const r = await getAllRoles() |
|
|
|
|
|
if (r.success) { |
|
|
|
|
|
roles.value = (r.data || []).map((ro: any) => ({ |
|
|
|
|
|
...ro, |
|
|
|
|
|
categoryIds: (ro.categoryIds || []).map(String), |
|
|
|
|
|
})) |
|
|
|
|
|
// 同步表单(仅当选中的角色未在异步期间被更改) |
|
|
|
|
|
if (currentSelectedId && selectedId.value === currentSelectedId && mode.value === 'edit') { |
|
|
|
|
|
const fresh = roles.value.find(x => x.id === currentSelectedId) |
|
|
|
|
|
if (fresh) { |
|
|
|
|
|
setFormFromRole(fresh) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} else { |
|
|
|
|
|
toast('加载角色列表失败:' + (r.message || '未知错误'), 'error') |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e: any) { |
|
|
|
|
|
toast('加载失败:' + e.message, 'error') |
|
|
|
|
|
} finally { |
|
|
|
|
|
loading.value = false |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// ---- 刷新(带脏检测守卫) ---- |
|
|
|
|
|
async function handleRefresh(): Promise<void> { |
|
|
|
|
|
if (!(await guardDirty('刷新'))) return |
|
|
|
|
|
await reload() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
onMounted(()=>{reload();categoryStore.loadCategories()}) |
|
|
|
|
|
|
|
|
// ---- 脏检测守卫 ---- |
|
|
|
|
|
async function guardDirty(action: string = '切换'): Promise<boolean> { |
|
|
|
|
|
if (!isDirty.value) return true |
|
|
|
|
|
return await confirm(`${action}会丢失未保存的修改`, '确定放弃当前修改吗?') |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function reload(){try{const r=await getAllRoles();if(r.success)roles.value=(r.data||[]).map((ro:any)=>({...ro,categoryIds:(ro.categoryIds||[]).map(String)}))}catch(e:any){toast('加载失败:'+e.message,'error')}} |
|
|
|
|
|
|
|
|
// ---- 选择 / 新建 ---- |
|
|
|
|
|
async function startCreate(): Promise<void> { |
|
|
|
|
|
if (!(await guardDirty('新建角色'))) return |
|
|
|
|
|
mode.value = 'create' |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
resetForm() |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
function startEdit(role:any){editingId.value=role.id;form.name=role.name;form.description=role.description||'';form.prompt=role.prompt||'';form.enabled=role.enabled} |
|
|
|
|
|
function resetForm(){editingId.value='';form.name='';form.description='';form.prompt='';form.enabled=true} |
|
|
|
|
|
|
|
|
async function selectRole(role: any): Promise<void> { |
|
|
|
|
|
if (selectedId.value === role.id) return |
|
|
|
|
|
if (!(await guardDirty('切换角色'))) return |
|
|
|
|
|
mode.value = 'edit' |
|
|
|
|
|
selectedId.value = role.id |
|
|
|
|
|
setFormFromRole(role) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function submit(){ |
|
|
|
|
|
if(!form.name.trim()){toast('请输入角色名称','error');return} |
|
|
|
|
|
const d:any={name:form.name.trim(),description:form.description.trim(),prompt:form.prompt,enabled:form.enabled} |
|
|
|
|
|
try{let r;if(editingId.value)r=await updateRole(editingId.value,d);else r=await createRole(d) |
|
|
|
|
|
if(r.success){toast(editingId.value?'修改成功':'新增成功','success');resetForm();reload()}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}} |
|
|
|
|
|
|
|
|
// ---- 取消 ---- |
|
|
|
|
|
function handleCancel(): void { |
|
|
|
|
|
if (mode.value === 'create') { |
|
|
|
|
|
mode.value = 'view' |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
resetForm() |
|
|
|
|
|
} else if (mode.value === 'edit') { |
|
|
|
|
|
if (selectedRole.value) { |
|
|
|
|
|
setFormFromRole(selectedRole.value) |
|
|
|
|
|
} else { |
|
|
|
|
|
// 角色已被删除 |
|
|
|
|
|
mode.value = 'view' |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
resetForm() |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function remove(role:any){if(!await confirm(`确定删除「${role.name}」?`))return |
|
|
|
|
|
try{const r=await deleteRole(role.id);if(r.success){toast('删除成功','success');reload()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}} |
|
|
|
|
|
|
|
|
// ---- 保存 ---- |
|
|
|
|
|
async function handleSubmit(): Promise<void> { |
|
|
|
|
|
const name = form.name.trim() |
|
|
|
|
|
if (!name) { |
|
|
|
|
|
toast('请输入角色名称', 'error') |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function toggleCategory(role:any, catId:string, checked:boolean){ |
|
|
|
|
|
const ids=new Set(role.categoryIds);if(checked)ids.add(catId);else ids.delete(catId) |
|
|
|
|
|
try{const r=await updateRoleCategories(role.id,Array.from(ids));if(r.success){role.categoryIds=Array.from(ids)}else toast(r.message||'授权失败','error')}catch(e:any){toast('授权失败:'+e.message,'error')}} |
|
|
|
|
|
|
|
|
saving.value = true |
|
|
|
|
|
try { |
|
|
|
|
|
const payload = { |
|
|
|
|
|
name, |
|
|
|
|
|
description: form.description.trim(), |
|
|
|
|
|
prompt: form.prompt.trim(), |
|
|
|
|
|
enabled: form.enabled, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let roleId: string | null = selectedId.value |
|
|
|
|
|
|
|
|
|
|
|
if (mode.value === 'create') { |
|
|
|
|
|
const r = await createRole(payload) |
|
|
|
|
|
if (!r.success) { toast(r.message || '保存失败', 'error'); return } |
|
|
|
|
|
roleId = r.data?.id || null |
|
|
|
|
|
if (!roleId) { |
|
|
|
|
|
// 后端返回了 success 但没有 id(旧版兼容),重新加载列表后按名称匹配 |
|
|
|
|
|
toast('新增成功', 'success') |
|
|
|
|
|
await reload() |
|
|
|
|
|
const matched = roles.value.find(x => x.name === name) |
|
|
|
|
|
if (matched) { |
|
|
|
|
|
roleId = matched.id |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} else { |
|
|
|
|
|
if (!roleId) { |
|
|
|
|
|
toast('系统错误:未找到当前编辑的角色', 'error') |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
const r = await updateRole(roleId, payload) |
|
|
|
|
|
if (!r.success) { toast(r.message || '保存失败', 'error'); return } |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 统一提交知识库授权 |
|
|
|
|
|
if (roleId) { |
|
|
|
|
|
const catRes = await updateRoleCategories(roleId, [...form.categoryIds]) |
|
|
|
|
|
if (!catRes.success) { |
|
|
|
|
|
toast(catRes.message || '保存成功,但知识库授权失败', 'error') |
|
|
|
|
|
} |
|
|
|
|
|
} else { |
|
|
|
|
|
toast('保存成功,但无法获取角色 ID,知识库授权未提交', 'warning') |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (mode.value === 'create') { |
|
|
|
|
|
toast('新增成功', 'success') |
|
|
|
|
|
} else { |
|
|
|
|
|
toast('保存成功', 'success') |
|
|
|
|
|
} |
|
|
|
|
|
await reload() |
|
|
|
|
|
|
|
|
|
|
|
// 保持选中 |
|
|
|
|
|
if (roleId) { |
|
|
|
|
|
mode.value = 'edit' |
|
|
|
|
|
selectedId.value = roleId |
|
|
|
|
|
const updated = roles.value.find(x => x.id === roleId) |
|
|
|
|
|
if (updated) setFormFromRole(updated) |
|
|
|
|
|
} else { |
|
|
|
|
|
mode.value = 'view' |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
resetForm() |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e: any) { |
|
|
|
|
|
toast('操作失败:' + e.message, 'error') |
|
|
|
|
|
} finally { |
|
|
|
|
|
saving.value = false |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// ---- 删除 ---- |
|
|
|
|
|
async function handleDelete(): Promise<void> { |
|
|
|
|
|
const role = selectedRole.value |
|
|
|
|
|
if (!role) return |
|
|
|
|
|
if (!(await confirm(`确定删除角色「${role.name}」?`, '删除后不可恢复,已绑定此角色的 API Key 将无法使用。'))) return |
|
|
|
|
|
deleting.value = true |
|
|
|
|
|
try { |
|
|
|
|
|
const r = await deleteRole(role.id) |
|
|
|
|
|
if (r.success) { |
|
|
|
|
|
toast('删除成功', 'success') |
|
|
|
|
|
selectedId.value = null |
|
|
|
|
|
mode.value = 'view' |
|
|
|
|
|
resetForm() |
|
|
|
|
|
await reload() |
|
|
|
|
|
} else { |
|
|
|
|
|
toast(r.message || '删除失败', 'error') |
|
|
|
|
|
} |
|
|
|
|
|
} catch (e: any) { |
|
|
|
|
|
toast('删除失败:' + e.message, 'error') |
|
|
|
|
|
} finally { |
|
|
|
|
|
deleting.value = false |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
</script> |
|
|
</script> |
|
|
|
|
|
|
|
|
<style scoped> |
|
|
<style scoped> |
|
|
.role-card{padding:12px;border:1px solid #e7e7e7;border-radius:8px;margin-bottom:10px;} |
|
|
|
|
|
.role-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;}.role-name{font-size:14px;}.role-desc{font-size:12px;color:#999;margin-top:2px;} |
|
|
|
|
|
.role-prompt{font-size:12px;color:#333;margin-top:6px;background:#f9fafb;border-radius:6px;padding:6px 8px;} |
|
|
|
|
|
.role-categories{margin-top:8px;font-size:12px;display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}.cat-label{color:#999;}.cat-hint{color:#999;} |
|
|
|
|
|
|
|
|
/* ===== 整体分栏 ===== */ |
|
|
|
|
|
.role-shell { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 16px; |
|
|
|
|
|
min-height: 520px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ===== 左侧列表 ===== */ |
|
|
|
|
|
.role-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-body { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
overflow-y: auto; |
|
|
|
|
|
padding: 10px 12px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-items { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
gap: 6px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-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; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item:hover { |
|
|
|
|
|
background: var(--td-bg-color-component); |
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item:focus-visible { |
|
|
|
|
|
outline: 2px solid var(--td-brand-color); |
|
|
|
|
|
outline-offset: -2px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item.active { |
|
|
|
|
|
background: var(--td-brand-color-light); |
|
|
|
|
|
border-color: var(--td-brand-color); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item-main { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
gap: 6px; |
|
|
|
|
|
margin-bottom: 3px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item-name { |
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
color: var(--td-text-color-primary); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.role-item-desc { |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
color: var(--td-text-color-placeholder); |
|
|
|
|
|
white-space: nowrap; |
|
|
|
|
|
overflow: hidden; |
|
|
|
|
|
text-overflow: ellipsis; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ===== 右侧编辑区 ===== */ |
|
|
|
|
|
.role-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-y: auto; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.detail-header { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: flex-start; |
|
|
|
|
|
justify-content: space-between; |
|
|
|
|
|
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; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.detail-footer { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
justify-content: flex-end; |
|
|
|
|
|
gap: 10px; |
|
|
|
|
|
margin-top: 24px; |
|
|
|
|
|
padding-top: 14px; |
|
|
|
|
|
border-top: 1px solid var(--td-border-level-1-color); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ===== 响应式 ===== */ |
|
|
|
|
|
@media (max-width: 1024px) { |
|
|
|
|
|
.role-sidebar { |
|
|
|
|
|
width: 220px; |
|
|
|
|
|
} |
|
|
|
|
|
.role-detail { |
|
|
|
|
|
padding: 16px 20px; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) { |
|
|
|
|
|
.role-shell { |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
} |
|
|
|
|
|
.role-sidebar { |
|
|
|
|
|
width: auto; |
|
|
|
|
|
max-height: 260px; |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
</style> |
|
|
</style> |