chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 坐席绩效统计页面 — 按坐席维度展示服务数据 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="agent-performance">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 280px"
|
||||
@change="loadPerformance"
|
||||
/>
|
||||
<el-button type="primary" :icon="Refresh" @click="loadPerformance">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 绩效表格 -->
|
||||
<el-table :data="agents" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="坐席姓名" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">
|
||||
{{ row.role === 'admin' ? '组长' : '坐席' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="skill_tags" label="技能标签" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="tag in (row.skill_tags || [])" :key="tag" size="small" class="skill-tag">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<span v-if="!row.skill_tags?.length" style="color: #909399">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_load" label="当前负载" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.current_load >= row.max_load ? '#f56c6c' : '#303133' }">
|
||||
{{ row.current_load }}/{{ row.max_load }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_conversations" label="总会话数" width="100" align="center" sortable />
|
||||
<el-table-column prop="resolved_conversations" label="已结单" width="90" align="center" sortable />
|
||||
<el-table-column prop="resolution_rate" label="结单率" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: rateColor(row.resolution_rate) }">
|
||||
{{ row.resolution_rate }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="today_conversations" label="今日会话" width="100" align="center" sortable />
|
||||
</el-table>
|
||||
|
||||
<!-- 汇总统计 -->
|
||||
<div class="summary-bar">
|
||||
<el-descriptions :column="4" border size="small">
|
||||
<el-descriptions-item label="坐席总数">{{ agents.length }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线坐席">{{ agents.filter(a => a.status === 'online').length }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总会话数">{{ totalConversations }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总结单数">{{ totalResolved }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 坐席绩效统计页面逻辑
|
||||
// =============================================================================
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getAgentPerformance } from '@/api/admin'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const loading = ref(false)
|
||||
const agents = ref<any[]>([])
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
// ---------- 计算属性 ----------
|
||||
const totalConversations = computed(() =>
|
||||
agents.value.reduce((sum, a) => sum + (a.total_conversations || 0), 0)
|
||||
)
|
||||
const totalResolved = computed(() =>
|
||||
agents.value.reduce((sum, a) => sum + (a.resolved_conversations || 0), 0)
|
||||
)
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadPerformance() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (dateRange.value) {
|
||||
params.date_from = dateRange.value[0]
|
||||
params.date_to = dateRange.value[1]
|
||||
}
|
||||
|
||||
const { data } = await getAgentPerformance(params)
|
||||
if (data.code === 0) {
|
||||
agents.value = data.data.items || []
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载绩效数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function statusTagType(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
online: 'success',
|
||||
busy: 'warning',
|
||||
offline: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
online: '在线',
|
||||
busy: '忙碌',
|
||||
offline: '离线',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function rateColor(rate: string): string {
|
||||
if (rate === '—') return '#909399'
|
||||
const num = parseInt(rate)
|
||||
if (num >= 80) return '#67c23a'
|
||||
if (num >= 50) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
onMounted(() => loadPerformance())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-performance {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 坐席人员管理页
|
||||
=============================================================================
|
||||
说明:管理坐席人员,功能包括:
|
||||
- 表格列表显示所有坐席信息
|
||||
- 按状态筛选(全部/在线/忙碌/离线)
|
||||
- 编辑坐席对话框(角色、技能标签、最大负载)
|
||||
- 添加坐席对话框(user_id、name、技能标签等)
|
||||
- 移除坐席确认
|
||||
-->
|
||||
<template>
|
||||
<div class="agents-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">坐席人员管理</div>
|
||||
<div class="page-desc">坐席状态、技能标签、权限分级管理。技能标签与快速回复7大类对齐。</div>
|
||||
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="agents-toolbar">
|
||||
<!-- 状态筛选标签 -->
|
||||
<div class="category-tabs">
|
||||
<el-button
|
||||
v-for="tab in statusTabs"
|
||||
:key="tab.value"
|
||||
:type="agentStore.filterStatus === tab.value ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="agentStore.setFilter(tab.value)"
|
||||
>
|
||||
{{ tab.label }} ({{ agentStore.statusCounts[tab.value] }})
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 添加坐席按钮 -->
|
||||
<el-button type="primary" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加坐席
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 坐席表格 -->
|
||||
<AgentTable
|
||||
:agents="agentStore.filteredAgents"
|
||||
:loading="agentStore.loading"
|
||||
@edit="openEditDialog"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 编辑坐席对话框 -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
title="编辑坐席"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form v-if="editingAgent" label-position="top">
|
||||
<el-form-item label="姓名">
|
||||
<el-input :model-value="editingAgent.name" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="editForm.role">
|
||||
<el-radio value="agent">坐席</el-radio>
|
||||
<el-radio value="admin">组长</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="技能标签">
|
||||
<el-checkbox-group v-model="editForm.skillTags">
|
||||
<el-checkbox
|
||||
v-for="tag in skillTagOptions"
|
||||
:key="tag"
|
||||
:value="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大负载">
|
||||
<el-input-number
|
||||
v-model="editForm.maxLoad"
|
||||
:min="1"
|
||||
:max="20"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="OTP二次验证">
|
||||
<div v-if="editingAgent?.otp_enabled === 1">
|
||||
<el-tag type="success">已启用</el-tag>
|
||||
<el-button size="small" type="danger" style="margin-left: 12px;" @click="handleUnbindOtp">
|
||||
强制解绑
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else-if="editingAgent?.otp_secret">
|
||||
<el-tag type="warning">已绑定(未验证)</el-tag>
|
||||
<el-button size="small" type="danger" style="margin-left: 12px;" @click="handleUnbindOtp">
|
||||
强制解绑
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tag v-else type="info">未绑定</el-tag>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleEditSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 添加坐席对话框 -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-model="addDialogVisible"
|
||||
title="添加坐席"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="addFormRef"
|
||||
:model="addForm"
|
||||
:rules="addFormRules"
|
||||
label-position="top"
|
||||
>
|
||||
<el-form-item label="企微用户ID" prop="userId">
|
||||
<el-input v-model="addForm.userId" placeholder="如 WangLi" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="addForm.name" placeholder="如 王丽" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="addForm.role">
|
||||
<el-radio value="agent">坐席</el-radio>
|
||||
<el-radio value="admin">组长</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="技能标签">
|
||||
<el-checkbox-group v-model="addForm.skillTags">
|
||||
<el-checkbox
|
||||
v-for="tag in skillTagOptions"
|
||||
:key="tag"
|
||||
:value="tag"
|
||||
:label="tag"
|
||||
/>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大负载">
|
||||
<el-input-number
|
||||
v-model="addForm.maxLoad"
|
||||
:min="1"
|
||||
:max="20"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAddSave">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import AgentTable from '@/components/AgentTable.vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { unbindOtp as unbindOtpApi } from '@/api/admin'
|
||||
import type { Agent, AgentFilterStatus } from '@/types'
|
||||
import { SKILL_TAGS } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
// ==========================================================================
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
// ==========================================================================
|
||||
// 状态筛选标签
|
||||
// ==========================================================================
|
||||
const statusTabs: Array<{ label: string; value: AgentFilterStatus }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '忙碌', value: 'busy' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 技能标签选项
|
||||
// ==========================================================================
|
||||
const skillTagOptions = [...SKILL_TAGS]
|
||||
|
||||
// ==========================================================================
|
||||
// 编辑对话框
|
||||
// ==========================================================================
|
||||
const editDialogVisible = ref<boolean>(false)
|
||||
const editingAgent = ref<Agent | null>(null)
|
||||
const editForm = reactive({
|
||||
role: 'agent' as string,
|
||||
skillTags: [] as string[],
|
||||
maxLoad: 5,
|
||||
})
|
||||
|
||||
/** 打开编辑对话框 */
|
||||
function openEditDialog(agent: Agent): void {
|
||||
editingAgent.value = agent
|
||||
editForm.role = agent.role
|
||||
editForm.skillTags = [...agent.skill_tags]
|
||||
editForm.maxLoad = agent.max_load
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存编辑 */
|
||||
async function handleEditSave(): Promise<void> {
|
||||
if (!editingAgent.value) return
|
||||
const success = await agentStore.editAgent(editingAgent.value.id, {
|
||||
role: editForm.role as Agent['role'],
|
||||
skill_tags: editForm.skillTags,
|
||||
max_load: editForm.maxLoad,
|
||||
})
|
||||
if (success) {
|
||||
ElMessage.success('坐席信息已更新')
|
||||
editDialogVisible.value = false
|
||||
} else {
|
||||
ElMessage.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制解绑OTP
|
||||
*/
|
||||
async function handleUnbindOtp(): Promise<void> {
|
||||
if (!editingAgent.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要强制解绑 ${editingAgent.value.name} 的OTP吗?`,
|
||||
'提示',
|
||||
{ confirmButtonText: '确定解绑', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await unbindOtpApi(editingAgent.value.id)
|
||||
ElMessage.success('OTP已解绑')
|
||||
// 刷新列表
|
||||
await agentStore.loadAgents()
|
||||
editDialogVisible.value = false
|
||||
} catch (error) {
|
||||
if ((error as Error)?.message?.includes('cancel')) {
|
||||
// 用户取消
|
||||
} else {
|
||||
console.error('解绑OTP失败:', error)
|
||||
ElMessage.error('解绑OTP失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 添加对话框
|
||||
// ==========================================================================
|
||||
const addDialogVisible = ref<boolean>(false)
|
||||
const addFormRef = ref<FormInstance>()
|
||||
const addForm = reactive({
|
||||
userId: '',
|
||||
name: '',
|
||||
role: 'agent' as string,
|
||||
skillTags: [] as string[],
|
||||
maxLoad: 5,
|
||||
})
|
||||
|
||||
const addFormRules: FormRules = {
|
||||
userId: [{ required: true, message: '请输入企微用户ID', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
/** 打开添加对话框 */
|
||||
function openAddDialog(): void {
|
||||
addForm.userId = ''
|
||||
addForm.name = ''
|
||||
addForm.role = 'agent'
|
||||
addForm.skillTags = []
|
||||
addForm.maxLoad = 5
|
||||
addDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存添加 */
|
||||
async function handleAddSave(): Promise<void> {
|
||||
const valid = await addFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
const success = await agentStore.addAgent({
|
||||
user_id: addForm.userId.trim(),
|
||||
name: addForm.name.trim(),
|
||||
role: addForm.role as Agent['role'],
|
||||
skill_tags: addForm.skillTags,
|
||||
max_load: addForm.maxLoad,
|
||||
})
|
||||
if (success) {
|
||||
ElMessage.success('坐席已添加')
|
||||
addDialogVisible.value = false
|
||||
} else {
|
||||
ElMessage.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 删除坐席
|
||||
// ==========================================================================
|
||||
function handleDelete(agent: Agent): void {
|
||||
ElMessageBox.confirm(
|
||||
`确定要移除坐席「${agent.name}」吗?`,
|
||||
'移除坐席',
|
||||
{
|
||||
confirmButtonText: '确定移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
).then(async () => {
|
||||
const success = await agentStore.removeAgent(agent.id)
|
||||
if (success) {
|
||||
ElMessage.success('坐席已移除')
|
||||
}
|
||||
}).catch(() => {
|
||||
// 取消删除
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(() => {
|
||||
agentStore.loadAgents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 顶部操作栏 */
|
||||
.agents-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 分类标签 */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 消息分配模式页
|
||||
=============================================================================
|
||||
说明:展示6个消息分配模式,手动接单可选,其余灰化+锁图标+阶段标签
|
||||
阶段一仅支持手动接单,后续模式按坐席规模自动解锁
|
||||
-->
|
||||
<template>
|
||||
<div class="assignment-mode-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">消息分配模式</div>
|
||||
<div class="page-desc">当前坐席1人足以承担,手动接单完全满足。后续模式按坐席规模自动解锁。</div>
|
||||
|
||||
<!-- 分配模式卡片列表 -->
|
||||
<div class="mode-list">
|
||||
<div
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
class="mode-card"
|
||||
:class="{
|
||||
'mode-selected': mode.id === currentMode,
|
||||
'mode-locked': mode.locked,
|
||||
}"
|
||||
@click="selectMode(mode)"
|
||||
>
|
||||
<!-- 单选圆点 -->
|
||||
<div class="mode-radio" :class="{ selected: mode.id === currentMode }"></div>
|
||||
|
||||
<!-- 模式信息 -->
|
||||
<div class="mode-info">
|
||||
<div class="mode-title">
|
||||
{{ mode.name }}
|
||||
<el-tag
|
||||
v-if="mode.id === currentMode"
|
||||
type="success"
|
||||
size="small"
|
||||
class="mode-badge"
|
||||
>
|
||||
当前启用
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="mode.locked"
|
||||
:type="mode.unlock_at === '阶段三' ? 'info' : 'warning'"
|
||||
size="small"
|
||||
class="mode-badge"
|
||||
>
|
||||
{{ mode.unlock_at || '后续' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="mode-desc">{{ getModeDescription(mode.id) }}</div>
|
||||
<div v-if="mode.locked" class="mode-lock-text">
|
||||
<el-icon :size="12"><Lock /></el-icon>
|
||||
{{ getLockReason(mode.id) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAssignmentMode, updateAssignmentMode as apiUpdateMode } from '@/api/admin'
|
||||
import type { AssignmentMode } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
const modes = ref<AssignmentMode[]>([
|
||||
{ id: 'manual', name: '手动接单', enabled: true, locked: false },
|
||||
{ id: 'round_robin', name: '轮询分配', enabled: false, locked: true, unlock_at: '阶段二' },
|
||||
{ id: 'least_active', name: '最少活跃优先', enabled: false, locked: true, unlock_at: '阶段二' },
|
||||
{ id: 'weighted', name: '加权比例分配', enabled: false, locked: true, unlock_at: '阶段三' },
|
||||
{ id: 'skill_match', name: '技能匹配分配', enabled: false, locked: true, unlock_at: '阶段三' },
|
||||
{ id: 'priority_queue', name: '优先队列', enabled: false, locked: true, unlock_at: '阶段三' },
|
||||
])
|
||||
|
||||
const currentMode = ref<string>('manual')
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await getAssignmentMode()
|
||||
const data = response.data.data
|
||||
currentMode.value = data.current_mode
|
||||
if (data.modes && data.modes.length > 0) {
|
||||
modes.value = data.modes
|
||||
}
|
||||
} catch {
|
||||
// 使用默认数据
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 选择模式
|
||||
// ==========================================================================
|
||||
async function selectMode(mode: AssignmentMode): Promise<void> {
|
||||
if (mode.locked) {
|
||||
ElMessage.warning(`${mode.name}暂未开放,${mode.unlock_at || '后续版本'}解锁`)
|
||||
return
|
||||
}
|
||||
if (mode.id === currentMode.value) return
|
||||
|
||||
try {
|
||||
await apiUpdateMode(mode.id)
|
||||
currentMode.value = mode.id
|
||||
ElMessage.success(`已切换为「${mode.name}」`)
|
||||
} catch {
|
||||
ElMessage.error('切换分配模式失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 模式描述
|
||||
// ==========================================================================
|
||||
function getModeDescription(id: string): string {
|
||||
const descs: Record<string, string> = {
|
||||
manual: '坐席在待办列表中自行选择接单。AB 角色冗余设计,1 人即可承担。',
|
||||
round_robin: '按坐席列表顺序依次分配,循环往复。',
|
||||
least_active: '自动分配给当前活跃会话数最少的坐席。',
|
||||
weighted: '按坐席权重分配(如高级权重2、初级权重1)。',
|
||||
skill_match: '根据问题类别匹配坐席技能标签。',
|
||||
priority_queue: '紧急/阻断性问题优先路由到高级坐席/组长。',
|
||||
}
|
||||
return descs[id] || ''
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 锁定原因
|
||||
// ==========================================================================
|
||||
function getLockReason(id: string): string {
|
||||
const reasons: Record<string, string> = {
|
||||
round_robin: '坐席人数不足3人,暂不需要',
|
||||
least_active: '坐席人数不足3人,暂不需要',
|
||||
weighted: '坐席人数不足5人,暂不需要',
|
||||
skill_match: '需坐席≥5人 + 技能标签体系成熟',
|
||||
priority_queue: '需坐席≥5人 + 紧急度评分上线',
|
||||
}
|
||||
return reasons[id] || '后续阶段解锁'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 模式列表 */
|
||||
.mode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 模式卡片 */
|
||||
.mode-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.mode-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
.mode-card.mode-selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.mode-card.mode-locked {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mode-card.mode-locked:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* 单选圆点 */
|
||||
.mode-radio {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mode-radio.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.mode-radio.selected::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 模式信息 */
|
||||
.mode-info {
|
||||
flex: 1;
|
||||
}
|
||||
.mode-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.mode-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mode-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mode-lock-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 功能开关/参数管理页
|
||||
=============================================================================
|
||||
说明:按阶段分组的功能卡片,每项配置根据 value_type 显示不同控件
|
||||
- 布尔值:el-switch 开关
|
||||
- 数值:el-input-number
|
||||
- JSON:代码显示 + 编辑按钮(弹出对话框编辑)
|
||||
-->
|
||||
<template>
|
||||
<div class="configs-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">功能开关 / 参数</div>
|
||||
<div class="page-desc">按阶段控制功能开放,运行时切换无需改代码。配置变更即时生效,支持回滚。</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="configStore.loading" class="loading-state">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<!-- 配置分组网格 -->
|
||||
<div v-else class="feature-grid">
|
||||
<ConfigGroup
|
||||
v-for="group in configStore.groups"
|
||||
:key="group.key_prefix"
|
||||
:group="group"
|
||||
:icon="getGroupIcon(group.key_prefix)"
|
||||
:icon-color="getGroupIconColor(group.key_prefix)"
|
||||
:group-description="getGroupDescription(group.key_prefix)"
|
||||
:status-tag="getGroupStatusTag(group.key_prefix)"
|
||||
:item-stage="getItemStage(group.key_prefix)"
|
||||
@update="handleUpdate"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- JSON 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="jsonEditVisible"
|
||||
:title="'编辑配置 — ' + editingItem?.description"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form v-if="editingItem" label-position="top">
|
||||
<el-form-item label="配置键">
|
||||
<el-input :model-value="editingItem.key" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置值(JSON 格式)">
|
||||
<el-input
|
||||
v-model="jsonEditValue"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="请输入合法的 JSON 值"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleJsonSave">保存变更</el-button>
|
||||
<el-button @click="jsonEditVisible = false">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ConfigGroup from '@/components/ConfigGroup.vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import type { ConfigItem } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
// ==========================================================================
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// ==========================================================================
|
||||
// JSON 编辑对话框
|
||||
// ==========================================================================
|
||||
const jsonEditVisible = ref<boolean>(false)
|
||||
const editingItem = ref<ConfigItem | null>(null)
|
||||
const jsonEditValue = ref<string>('')
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(() => {
|
||||
configStore.loadConfigs()
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 配置更新
|
||||
// ==========================================================================
|
||||
|
||||
/** 处理配置值更新(布尔值/数值) */
|
||||
async function handleUpdate(key: string, value: string): Promise<void> {
|
||||
const success = await configStore.updateConfigValue(key, value)
|
||||
if (success) {
|
||||
ElMessage.success('配置已更新')
|
||||
} else {
|
||||
ElMessage.error('配置更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开 JSON 编辑对话框 */
|
||||
function handleEdit(item: ConfigItem): void {
|
||||
editingItem.value = item
|
||||
jsonEditValue.value = item.value
|
||||
jsonEditVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存 JSON 编辑 */
|
||||
async function handleJsonSave(): Promise<void> {
|
||||
if (!editingItem.value) return
|
||||
const success = await configStore.updateConfigValue(editingItem.value.key, jsonEditValue.value)
|
||||
if (success) {
|
||||
ElMessage.success('配置已更新')
|
||||
jsonEditVisible.value = false
|
||||
} else {
|
||||
ElMessage.error('配置更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 分组辅助方法
|
||||
// ==========================================================================
|
||||
|
||||
/** 分组配置映射 */
|
||||
const groupMeta: Record<string, { icon: string; iconColor: string; desc: string }> = {
|
||||
ai_: {
|
||||
icon: 'Cpu',
|
||||
iconColor: 'var(--accent)',
|
||||
desc: 'Dify Agent + RAGFlow 检索增强,员工自助咨询',
|
||||
},
|
||||
manual_: {
|
||||
icon: 'Headset',
|
||||
iconColor: 'var(--success)',
|
||||
desc: '员工可摇铃呼叫坐席,坐席手动接单',
|
||||
},
|
||||
queue_: {
|
||||
icon: 'Clock',
|
||||
iconColor: 'var(--warning)',
|
||||
desc: '坐席全忙时排队等待,支持优先级排序',
|
||||
},
|
||||
satisfaction_: {
|
||||
icon: 'Star',
|
||||
iconColor: '#fbbf24',
|
||||
desc: '会话结束后推送评价,统计满意度趋势',
|
||||
},
|
||||
emergency_: {
|
||||
icon: 'WarningFilled',
|
||||
iconColor: 'var(--danger)',
|
||||
desc: '系统维护时降级,引导员工走企微原生通道',
|
||||
},
|
||||
keyword_: {
|
||||
icon: 'Key',
|
||||
iconColor: 'var(--accent)',
|
||||
desc: '转人工/情绪关键词,JSON 格式存储',
|
||||
},
|
||||
}
|
||||
|
||||
function getGroupIcon(keyPrefix: string): string {
|
||||
return groupMeta[keyPrefix]?.icon || 'Setting'
|
||||
}
|
||||
|
||||
function getGroupIconColor(keyPrefix: string): string {
|
||||
return groupMeta[keyPrefix]?.iconColor || 'var(--accent)'
|
||||
}
|
||||
|
||||
function getGroupDescription(keyPrefix: string): string {
|
||||
return groupMeta[keyPrefix]?.desc || ''
|
||||
}
|
||||
|
||||
function getGroupStatusTag(keyPrefix: string): { type: 'success' | 'warning' | 'danger' | 'info'; text: string } | undefined {
|
||||
if (keyPrefix === 'ai_' || keyPrefix === 'manual_' || keyPrefix === 'keyword_') {
|
||||
return { type: 'success', text: '已启用' }
|
||||
}
|
||||
if (keyPrefix === 'queue_' || keyPrefix === 'satisfaction_') {
|
||||
return { type: 'warning', text: '阶段二' }
|
||||
}
|
||||
if (keyPrefix === 'emergency_') {
|
||||
return { type: 'danger', text: '当前关闭' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getItemStage(keyPrefix: string): string | undefined {
|
||||
if (keyPrefix === 'queue_' || keyPrefix === 'satisfaction_') {
|
||||
return '阶段二'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 运营总览仪表盘
|
||||
=============================================================================
|
||||
说明:管理后台首页仪表盘,展示:
|
||||
1. 4个统计卡片(在线坐席、今日会话、平均响应时间、AI命中率)
|
||||
2. 待处理事项列表
|
||||
3. 系统健康状态
|
||||
-->
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">运营总览</div>
|
||||
<div class="page-desc">实时监控服务台运行状态,关键指标一目了然</div>
|
||||
|
||||
<!-- 统计卡片行 -->
|
||||
<div class="stats-grid">
|
||||
<StatCard
|
||||
label="在线坐席"
|
||||
:value="overview.online_agents"
|
||||
valueColor="var(--success)"
|
||||
icon="UserFilled"
|
||||
iconColor="var(--success)"
|
||||
trend="运行正常"
|
||||
trendDirection="up"
|
||||
/>
|
||||
<StatCard
|
||||
label="今日会话"
|
||||
:value="overview.today_conversations"
|
||||
valueColor="var(--accent)"
|
||||
icon="ChatDotRound"
|
||||
iconColor="var(--accent)"
|
||||
:subtitle="'较昨日 +12%'"
|
||||
trendDirection="up"
|
||||
/>
|
||||
<StatCard
|
||||
label="平均响应时间"
|
||||
valueColor="var(--warning)"
|
||||
icon="Timer"
|
||||
iconColor="var(--warning)"
|
||||
trend="较昨日改善"
|
||||
trendDirection="down"
|
||||
>
|
||||
<template #value>
|
||||
{{ overview.avg_response_time || '—' }}
|
||||
</template>
|
||||
</StatCard>
|
||||
<StatCard
|
||||
label="AI 命中率"
|
||||
:value="overview.ai_hit_rate || '—'"
|
||||
valueColor="#8b5cf6"
|
||||
icon="MagicStick"
|
||||
iconColor="#8b5cf6"
|
||||
trend="本周 +5%"
|
||||
trendDirection="up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 双栏布局:待处理事项 + 系统健康 -->
|
||||
<div class="two-column">
|
||||
<!-- 待处理事项 -->
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header-row">
|
||||
<div class="table-title">待处理事项</div>
|
||||
<el-tag type="warning" size="small">
|
||||
{{ overview.pending_reviews || 0 }} 项待审
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-table
|
||||
:data="pendingItems"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
||||
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
||||
row-class-name="dashboard-table-row"
|
||||
>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.tagType" size="small" effect="plain">
|
||||
{{ row.type }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" prop="content" min-width="200" />
|
||||
<el-table-column label="提交人" prop="submitter" width="80" />
|
||||
<el-table-column label="时间" prop="time" width="100" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="handlePendingAction(row)">
|
||||
{{ row.actionText }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 系统健康状态 -->
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header-row">
|
||||
<div class="table-title">系统健康</div>
|
||||
</div>
|
||||
<div class="health-list">
|
||||
<div
|
||||
v-for="item in healthItems"
|
||||
:key="item.name"
|
||||
class="health-item"
|
||||
>
|
||||
<span class="health-name">{{ item.name }}</span>
|
||||
<el-tag :type="item.statusType" size="small" effect="dark">
|
||||
{{ item.statusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import { getDashboardOverview } from '@/api/admin'
|
||||
import type { DashboardOverview, SystemAlert } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// 路由
|
||||
// ==========================================================================
|
||||
const router = useRouter()
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 仪表盘数据 */
|
||||
const overview = reactive<DashboardOverview>({
|
||||
online_agents: 0,
|
||||
today_conversations: 0,
|
||||
avg_response_time: '—',
|
||||
ai_hit_rate: '—',
|
||||
pending_reviews: 0,
|
||||
system_alerts: [],
|
||||
integrations_health: [],
|
||||
})
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 待处理事项(基于 system_alerts 构建)
|
||||
// ==========================================================================
|
||||
const pendingItems = ref<Array<{
|
||||
type: string
|
||||
tagType: string
|
||||
content: string
|
||||
submitter: string
|
||||
time: string
|
||||
actionText: string
|
||||
route: string
|
||||
}>>([])
|
||||
|
||||
// ==========================================================================
|
||||
// 系统健康状态
|
||||
// ==========================================================================
|
||||
const healthItems = ref<Array<{
|
||||
name: string
|
||||
statusType: string
|
||||
statusText: string
|
||||
}>>([])
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getDashboardOverview()
|
||||
const data = response.data.data
|
||||
Object.assign(overview, data)
|
||||
|
||||
// 构建待处理事项
|
||||
buildPendingItems(data.system_alerts || [])
|
||||
|
||||
// 构建系统健康
|
||||
buildHealthItems(data.integrations_health || [])
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
// 使用默认 demo 数据
|
||||
overview.online_agents = 2
|
||||
overview.today_conversations = 47
|
||||
overview.avg_response_time = '1.8s'
|
||||
overview.ai_hit_rate = '68%'
|
||||
overview.pending_reviews = 3
|
||||
|
||||
// Demo 待处理事项
|
||||
pendingItems.value = [
|
||||
{ type: '快速回复', tagType: 'primary', content: 'VPN连接失败排查话术', submitter: '张伟', time: '10分钟前', actionText: '审核', route: '/quick-replies' },
|
||||
{ type: '知识推荐', tagType: 'warning', content: '打印机脱机问题(同类18次)', submitter: '系统', time: '1小时前', actionText: '处理', route: '/dashboard' },
|
||||
{ type: '异常告警', tagType: 'danger', content: '坐席王丽会话超时未响应', submitter: '系统', time: '2小时前', actionText: '查看', route: '/monitor' },
|
||||
]
|
||||
|
||||
// Demo 系统健康
|
||||
healthItems.value = [
|
||||
{ name: 'Dify AI 引擎', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'RAGFlow 知识库', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'Redis 缓存', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'PostgreSQL', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'WebSocket 服务', statusType: 'warning', statusText: '2连接抖动' },
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 构建待处理事项
|
||||
// ==========================================================================
|
||||
function buildPendingItems(alerts: SystemAlert[]): void {
|
||||
pendingItems.value = alerts.map((alert) => ({
|
||||
type: alert.type,
|
||||
tagType: alert.severity === 'danger' ? 'danger' : alert.severity === 'warning' ? 'warning' : 'primary',
|
||||
content: alert.content,
|
||||
submitter: alert.submitter || '系统',
|
||||
time: alert.time,
|
||||
actionText: alert.severity === 'danger' ? '查看' : '处理',
|
||||
route: '/dashboard',
|
||||
}))
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 构建系统健康
|
||||
// ==========================================================================
|
||||
function buildHealthItems(integrations: Array<{ system: string; status: string }>): void {
|
||||
const statusMap: Record<string, string> = {
|
||||
connected: 'success',
|
||||
partial: 'warning',
|
||||
disconnected: 'info',
|
||||
pending: 'info',
|
||||
}
|
||||
const textMap: Record<string, string> = {
|
||||
connected: '正常',
|
||||
partial: '部分异常',
|
||||
disconnected: '未连接',
|
||||
pending: '待确认',
|
||||
}
|
||||
healthItems.value = integrations.map((item) => ({
|
||||
name: item.system,
|
||||
statusType: statusMap[item.status] || 'info',
|
||||
statusText: textMap[item.status] || item.status,
|
||||
}))
|
||||
|
||||
// 如果后端没有返回,添加默认项
|
||||
if (healthItems.value.length === 0) {
|
||||
healthItems.value = [
|
||||
{ name: 'Dify AI 引擎', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'RAGFlow 知识库', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'Redis 缓存', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'PostgreSQL', statusType: 'success', statusText: '正常' },
|
||||
{ name: 'WebSocket 服务', statusType: 'warning', statusText: '2连接抖动' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 待处理事项操作
|
||||
// ==========================================================================
|
||||
function handlePendingAction(row: { route: string }): void {
|
||||
router.push(row.route)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 仪表盘页面 */
|
||||
.dashboard-page {
|
||||
/* 样式使用全局 .stats-grid .two-column 等 */
|
||||
}
|
||||
|
||||
/* 系统健康列表 */
|
||||
.health-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.health-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.health-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 仪表盘表格行悬停 */
|
||||
.dashboard-table-row:hover td {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 排查流程图管理页
|
||||
=============================================================================
|
||||
说明:JSON 导入导出 + 预览 + 版本管理
|
||||
阶段三开始实现,当前为占位功能
|
||||
显示模板列表 + 灰化的导入/导出/新建按钮
|
||||
底部展示实现路径
|
||||
-->
|
||||
<template>
|
||||
<div class="flowcharts-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">排查流程图管理</div>
|
||||
<div class="page-desc">JSON 导入导出 + 预览 + 版本管理。阶段三开始实现,后续升级为可视化拖拽编辑。</div>
|
||||
|
||||
<!-- 操作按钮(灰化占位) -->
|
||||
<div class="flowchart-actions">
|
||||
<el-button type="primary" disabled>
|
||||
<el-icon><Upload /></el-icon>
|
||||
导入 JSON
|
||||
</el-button>
|
||||
<el-button disabled>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出全部
|
||||
</el-button>
|
||||
<el-button disabled>
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建流程图
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 流程图模板表格 -->
|
||||
<div class="table-wrapper">
|
||||
<el-table
|
||||
:data="flowcharts"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
||||
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
||||
row-class-name="flowchart-table-row"
|
||||
>
|
||||
<el-table-column label="流程图名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flowchart-name">
|
||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px">
|
||||
<Share />
|
||||
</el-icon>
|
||||
{{ row.name }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.category }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点数" width="80" align="center" prop="nodeCount" />
|
||||
<el-table-column label="版本" width="70" align="center" prop="version" />
|
||||
<el-table-column label="最后更新" width="110" prop="updatedAt" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.statusType" size="small">
|
||||
{{ row.statusText }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default>
|
||||
<el-button size="small" text type="primary" disabled>预览</el-button>
|
||||
<el-button size="small" text disabled>编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 实现路径 -->
|
||||
<div class="roadmap-section">
|
||||
<div class="roadmap-title">
|
||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px"><Flag /></el-icon>
|
||||
实现路径
|
||||
</div>
|
||||
<div class="roadmap-steps">
|
||||
<div class="roadmap-step active">
|
||||
<div class="step-number">Step 1</div>
|
||||
<div class="step-title">JSON 导入导出 + 预览</div>
|
||||
<div class="step-phase">阶段三 3B</div>
|
||||
</div>
|
||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
||||
<div class="roadmap-step">
|
||||
<div class="step-number">Step 2</div>
|
||||
<div class="step-title">导出为 Dify 变量</div>
|
||||
<div class="step-phase">阶段四 4A</div>
|
||||
</div>
|
||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
||||
<div class="roadmap-step">
|
||||
<div class="step-number">Step 3</div>
|
||||
<div class="step-title">Dify HTTP 回调</div>
|
||||
<div class="step-phase">阶段四</div>
|
||||
</div>
|
||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
||||
<div class="roadmap-step">
|
||||
<div class="step-number">Step 4</div>
|
||||
<div class="step-title">可视化拖拽编辑</div>
|
||||
<div class="step-phase">远景</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// Demo 数据
|
||||
// ==========================================================================
|
||||
const flowcharts = [
|
||||
{
|
||||
name: 'VPN连接故障排查',
|
||||
category: '网络',
|
||||
nodeCount: 12,
|
||||
version: 'v2.1',
|
||||
updatedAt: '2026-06-10',
|
||||
statusType: 'success',
|
||||
statusText: '已发布',
|
||||
},
|
||||
{
|
||||
name: '打印机脱机排查',
|
||||
category: '外设',
|
||||
nodeCount: 8,
|
||||
version: 'v1.3',
|
||||
updatedAt: '2026-06-08',
|
||||
statusType: 'success',
|
||||
statusText: '已发布',
|
||||
},
|
||||
{
|
||||
name: '邮箱登录失败排查',
|
||||
category: '软件',
|
||||
nodeCount: 10,
|
||||
version: 'v1.0',
|
||||
updatedAt: '2026-06-06',
|
||||
statusType: 'warning',
|
||||
statusText: '草稿',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 操作按钮 */
|
||||
.flowchart-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 流程图名称 */
|
||||
.flowchart-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 实现路径区域 */
|
||||
.roadmap-section {
|
||||
margin-top: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.roadmap-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.roadmap-steps {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roadmap-step {
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.roadmap-step.active {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.roadmap-step.active .step-number {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.roadmap-step.active .step-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-phase {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.roadmap-arrow {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 流程图表格行悬停 */
|
||||
.flowchart-table-row:hover td {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,699 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 外部系统集成配置页
|
||||
=============================================================================
|
||||
说明:显示6个外部系统的连接状态和配置入口
|
||||
- Dify AI / RAGFlow:可配置(API URL + Key)— url_key 模式
|
||||
- 火绒安全:可配置(AccessKey ID + Secret + Base URL)— access_key 模式
|
||||
- 联软LV7000:可配置(API账号 + 密码 + Base URL)— account_password 模式
|
||||
- 数据平台 / 北森 eHR:显示状态 + 占位
|
||||
-->
|
||||
<template>
|
||||
<div class="integrations-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">外部系统集成</div>
|
||||
<div class="page-desc">管理后台管「配置和参数」,Dify 网页管「Workflow 逻辑」,两者边界明确。</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<!-- 集成系统卡片网格 -->
|
||||
<div v-else class="integration-grid">
|
||||
<IntegrationCard
|
||||
v-for="item in integrations"
|
||||
:key="item.id"
|
||||
:integration="item"
|
||||
:icon="getIntegrationIcon(item.id)"
|
||||
:icon-bg="getIntegrationIconBg(item.id)"
|
||||
:icon-color="getIntegrationIconColor(item.id)"
|
||||
@configure="handleConfigure"
|
||||
@test="handleTest"
|
||||
@view="handleView"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 配置对话框 — url_key 模式(Dify / RAGFlow) -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-if="configuringIntegration?.config_type === 'url_key'"
|
||||
v-model="configDialogVisible"
|
||||
:title="'配置集成 — ' + configuringIntegration?.name"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="API URL">
|
||||
<el-input
|
||||
v-model="urlKeyForm.apiUrl"
|
||||
placeholder="https://api.dify.ai/v1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input
|
||||
v-model="urlKeyForm.apiKey"
|
||||
type="password"
|
||||
placeholder="输入 API Key"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleUrlKeySave">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 配置对话框 — access_key 模式(火绒安全) -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-if="configuringIntegration?.config_type === 'access_key'"
|
||||
v-model="configDialogVisible"
|
||||
:title="'配置集成 — ' + configuringIntegration?.name"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="Base URL(内网地址)">
|
||||
<el-input
|
||||
v-model="accessKeyForm.baseUrl"
|
||||
placeholder="http://huorong.oa.servyou-it.com:8080"
|
||||
/>
|
||||
<div class="form-hint">火绒终端安全管理系统的内网API地址</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey ID">
|
||||
<el-input
|
||||
v-model="accessKeyForm.accessKeyId"
|
||||
placeholder="输入 AccessKey ID"
|
||||
/>
|
||||
<div class="form-hint">在火绒管理后台 → 系统设置 → API管理 中创建</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey Secret">
|
||||
<el-input
|
||||
v-model="accessKeyForm.accessKeySecret"
|
||||
type="password"
|
||||
placeholder="输入 AccessKey Secret"
|
||||
show-password
|
||||
/>
|
||||
<div class="form-hint">Secret 仅在创建时显示一次,请妥善保管</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
:loading="testingConnection"
|
||||
@click="handleTestHuorongConnection"
|
||||
>
|
||||
{{ testingConnection ? '测试中...' : '测试连接' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAccessKeySave">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 配置对话框 — account_password 模式(联软LV7000) -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-if="configuringIntegration?.config_type === 'account_password'"
|
||||
v-model="configDialogVisible"
|
||||
:title="'配置集成 — ' + configuringIntegration?.name"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="Base URL(内网地址)">
|
||||
<el-input
|
||||
v-model="accountPasswordForm.baseUrl"
|
||||
placeholder="http://192.168.x.x:30098"
|
||||
/>
|
||||
<div class="form-hint">联软LV7000终端安全管理系统的内网API地址,默认端口30098</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 账号">
|
||||
<el-input
|
||||
v-model="accountPasswordForm.apiAccount"
|
||||
placeholder="输入API账号"
|
||||
/>
|
||||
<div class="form-hint">在联软管理后台 → 系统设置 → API管理 中创建</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 密码">
|
||||
<el-input
|
||||
v-model="accountPasswordForm.apiPassword"
|
||||
type="password"
|
||||
placeholder="输入API密码"
|
||||
show-password
|
||||
/>
|
||||
<div class="form-hint">与API账号配套的密码,仅在创建时显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证密钥(可选)">
|
||||
<el-input
|
||||
v-model="accountPasswordForm.validateKey"
|
||||
placeholder="输入验证密钥(如需要)"
|
||||
/>
|
||||
<div class="form-hint">部分联软版本需要额外的验证密钥,无则留空</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="configDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
:loading="testingConnection"
|
||||
@click="handleTestLianruanConnection"
|
||||
>
|
||||
{{ testingConnection ? '测试中...' : '测试连接' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAccountPasswordSave">保存配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 联软连接测试结果对话框 -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-model="testResultVisible"
|
||||
title="连接测试结果"
|
||||
width="420px"
|
||||
>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<el-result
|
||||
:icon="testResult.success ? 'success' : 'error'"
|
||||
:title="testResult.success ? '连接成功' : '连接失败'"
|
||||
:sub-title="testResult.message"
|
||||
/>
|
||||
<div v-if="testResult.success && testResult.total_terminals" class="test-detail">
|
||||
检测到 <strong>{{ testResult.total_terminals }}</strong> 个终端
|
||||
</div>
|
||||
<!-- 失败时显示调试提示 -->
|
||||
<div v-if="!testResult.success && testResult.debug_hint" class="test-debug-hint">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>{{ testResult.debug_hint }}</span>
|
||||
</div>
|
||||
<!-- 显示凭据摘要 -->
|
||||
<div v-if="testResult.debug" class="test-debug-info">
|
||||
<div>Base URL: {{ testResult.debug.base_url }}</div>
|
||||
<div>AccessKey ID: {{ testResult.debug.access_key_id }}</div>
|
||||
<div>Secret 长度: {{ testResult.debug.key_length }} 字符</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="testResultVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { WarningFilled } from '@element-plus/icons-vue'
|
||||
import IntegrationCard from '@/components/IntegrationCard.vue'
|
||||
import { getIntegrations, updateIntegration, testHuorongConnection, testLianruanConnection, testRagflowConnection } from '@/api/admin'
|
||||
import type { Integration } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
const loading = ref<boolean>(false)
|
||||
const integrations = ref<Integration[]>([])
|
||||
|
||||
// ==========================================================================
|
||||
// 配置对话框 — 通用状态
|
||||
// ==========================================================================
|
||||
const configDialogVisible = ref<boolean>(false)
|
||||
const configuringIntegration = ref<Integration | null>(null)
|
||||
|
||||
// url_key 模式表单(Dify / RAGFlow)
|
||||
const urlKeyForm = reactive({
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
})
|
||||
|
||||
// access_key 模式表单(火绒安全)
|
||||
const accessKeyForm = reactive({
|
||||
baseUrl: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
})
|
||||
|
||||
// account_password 模式表单(联软LV7000)
|
||||
const accountPasswordForm = reactive({
|
||||
baseUrl: '',
|
||||
apiAccount: '',
|
||||
apiPassword: '',
|
||||
validateKey: '',
|
||||
})
|
||||
|
||||
// 火绒连接测试
|
||||
const testingConnection = ref<boolean>(false)
|
||||
const testResultVisible = ref<boolean>(false)
|
||||
const testResult = ref<{ success: boolean; message: string; total_terminals?: number; debug_hint?: string; debug?: Record<string, unknown> } | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getIntegrations()
|
||||
integrations.value = response.data.data.items
|
||||
} catch {
|
||||
// 使用默认 demo 数据
|
||||
integrations.value = getDefaultIntegrations()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 默认集成数据(API 不可用时的 fallback)
|
||||
// ==========================================================================
|
||||
function getDefaultIntegrations(): Integration[] {
|
||||
return [
|
||||
{
|
||||
id: 'dify',
|
||||
name: 'Dify AI',
|
||||
status: 'connected',
|
||||
configurable: true,
|
||||
config_type: 'url_key',
|
||||
config: { api_url: 'https://api.dify.ai/v1', api_key_set: true },
|
||||
},
|
||||
{
|
||||
id: 'ragflow',
|
||||
name: 'RAGFlow',
|
||||
status: 'partial',
|
||||
configurable: true,
|
||||
config_type: 'url_key',
|
||||
config: { api_url: '', api_key_set: false },
|
||||
},
|
||||
{
|
||||
id: 'data_platform',
|
||||
name: '数据平台',
|
||||
status: 'disconnected',
|
||||
configurable: false,
|
||||
config: null,
|
||||
},
|
||||
{
|
||||
id: 'beisen',
|
||||
name: '北森 eHR',
|
||||
status: 'disconnected',
|
||||
configurable: false,
|
||||
config: null,
|
||||
},
|
||||
{
|
||||
id: 'huorong',
|
||||
name: '火绒安全',
|
||||
status: 'disconnected',
|
||||
configurable: true,
|
||||
config_type: 'access_key',
|
||||
config: { api_url: '', api_key_set: false, access_key_id_set: false, access_key_secret_set: false, base_url: null },
|
||||
},
|
||||
{
|
||||
id: 'lianruan',
|
||||
name: '联软LV7000',
|
||||
status: 'disconnected',
|
||||
configurable: true,
|
||||
config_type: 'account_password',
|
||||
config: { api_url: '', api_account_set: false, api_password_set: false, base_url: null },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 集成系统图标映射
|
||||
// ==========================================================================
|
||||
const iconMap: Record<string, { icon: string; bg: string; color: string }> = {
|
||||
dify: { icon: 'Cpu', bg: 'var(--accent-light)', color: 'var(--accent)' },
|
||||
ragflow: { icon: 'Coin', bg: 'var(--success-bg)', color: 'var(--success)' },
|
||||
data_platform: { icon: 'TrendCharts', bg: 'var(--warning-bg)', color: 'var(--warning)' },
|
||||
beisen: { icon: 'Avatar', bg: 'rgba(139,92,246,0.12)', color: '#8b5cf6' },
|
||||
huorong: { icon: 'WarningFilled', bg: 'var(--danger-bg)', color: 'var(--danger)' },
|
||||
lianruan: { icon: 'Monitor', bg: 'rgba(236,72,153,0.12)', color: '#ec4899' },
|
||||
}
|
||||
|
||||
function getIntegrationIcon(id: string): string {
|
||||
return iconMap[id]?.icon || 'Setting'
|
||||
}
|
||||
|
||||
function getIntegrationIconBg(id: string): string {
|
||||
return iconMap[id]?.bg || 'var(--accent-light)'
|
||||
}
|
||||
|
||||
function getIntegrationIconColor(id: string): string {
|
||||
return iconMap[id]?.color || 'var(--accent)'
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 操作处理
|
||||
// ==========================================================================
|
||||
|
||||
/** 打开配置对话框(根据 config_type 显示不同表单) */
|
||||
function handleConfigure(integration: Integration): void {
|
||||
configuringIntegration.value = integration
|
||||
const config = integration.config as Record<string, unknown> | null
|
||||
|
||||
if (integration.config_type === 'access_key') {
|
||||
// 火绒模式:填充 access_key 表单
|
||||
accessKeyForm.baseUrl = (config?.base_url as string) || (config?.api_url as string) || ''
|
||||
accessKeyForm.accessKeyId = ''
|
||||
accessKeyForm.accessKeySecret = ''
|
||||
} else if (integration.config_type === 'account_password') {
|
||||
// 联软模式:填充 account_password 表单
|
||||
accountPasswordForm.baseUrl = (config?.base_url as string) || (config?.api_url as string) || ''
|
||||
accountPasswordForm.apiAccount = ''
|
||||
accountPasswordForm.apiPassword = ''
|
||||
accountPasswordForm.validateKey = ''
|
||||
} else {
|
||||
// 默认 url_key 模式(Dify / RAGFlow)
|
||||
urlKeyForm.apiUrl = (config?.api_url as string) || ''
|
||||
urlKeyForm.apiKey = ''
|
||||
}
|
||||
|
||||
configDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 保存 url_key 模式配置(Dify / RAGFlow) */
|
||||
async function handleUrlKeySave(): Promise<void> {
|
||||
if (!configuringIntegration.value) return
|
||||
try {
|
||||
await updateIntegration(configuringIntegration.value.id, {
|
||||
api_url: urlKeyForm.apiUrl,
|
||||
api_key: urlKeyForm.apiKey || undefined,
|
||||
})
|
||||
ElMessage.success('配置已保存')
|
||||
|
||||
// 更新本地数据
|
||||
const idx = integrations.value.findIndex((i) => i.id === configuringIntegration.value!.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: urlKeyForm.apiUrl ? 'connected' : 'partial',
|
||||
config: {
|
||||
api_url: urlKeyForm.apiUrl,
|
||||
api_key_set: !!urlKeyForm.apiKey,
|
||||
},
|
||||
}
|
||||
}
|
||||
configDialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error('保存配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 access_key 模式配置(火绒安全) */
|
||||
async function handleAccessKeySave(): Promise<void> {
|
||||
if (!configuringIntegration.value) return
|
||||
|
||||
// 校验必填项
|
||||
if (!accessKeyForm.baseUrl) {
|
||||
ElMessage.warning('请填写 Base URL')
|
||||
return
|
||||
}
|
||||
if (!accessKeyForm.accessKeyId) {
|
||||
ElMessage.warning('请填写 AccessKey ID')
|
||||
return
|
||||
}
|
||||
if (!accessKeyForm.accessKeySecret) {
|
||||
ElMessage.warning('请填写 AccessKey Secret')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateIntegration(configuringIntegration.value.id, {
|
||||
access_key_id: accessKeyForm.accessKeyId,
|
||||
access_key_secret: accessKeyForm.accessKeySecret,
|
||||
base_url: accessKeyForm.baseUrl,
|
||||
})
|
||||
ElMessage.success('配置已保存')
|
||||
|
||||
// 更新本地数据(标记为已配置,但不一定连接成功)
|
||||
const idx = integrations.value.findIndex((i) => i.id === configuringIntegration.value!.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: 'partial', // 需要测试连接才能确认 connected
|
||||
config: {
|
||||
api_url: accessKeyForm.baseUrl,
|
||||
api_key_set: true,
|
||||
access_key_id_set: true,
|
||||
access_key_secret_set: true,
|
||||
base_url: accessKeyForm.baseUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
configDialogVisible.value = false
|
||||
// 提示用户测试连接
|
||||
ElMessage.info('请在集成管理页点击"测试连接"验证凭据是否正确')
|
||||
} catch {
|
||||
ElMessage.error('保存配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 account_password 模式配置(联软LV7000) */
|
||||
async function handleAccountPasswordSave(): Promise<void> {
|
||||
if (!configuringIntegration.value) return
|
||||
|
||||
// 校验必填项
|
||||
if (!accountPasswordForm.baseUrl) {
|
||||
ElMessage.warning('请填写 Base URL')
|
||||
return
|
||||
}
|
||||
if (!accountPasswordForm.apiAccount) {
|
||||
ElMessage.warning('请填写 API 账号')
|
||||
return
|
||||
}
|
||||
if (!accountPasswordForm.apiPassword) {
|
||||
ElMessage.warning('请填写 API 密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateIntegration(configuringIntegration.value.id, {
|
||||
api_account: accountPasswordForm.apiAccount,
|
||||
api_password: accountPasswordForm.apiPassword,
|
||||
base_url: accountPasswordForm.baseUrl,
|
||||
validate_key: accountPasswordForm.validateKey || undefined,
|
||||
})
|
||||
ElMessage.success('配置已保存')
|
||||
|
||||
// 更新本地数据
|
||||
const idx = integrations.value.findIndex((i) => i.id === configuringIntegration.value!.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: 'connected',
|
||||
config: {
|
||||
api_url: accountPasswordForm.baseUrl,
|
||||
api_account_set: true,
|
||||
api_password_set: true,
|
||||
base_url: accountPasswordForm.baseUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
configDialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error('保存配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试联软API连接 */
|
||||
async function handleTestLianruanConnection(): Promise<void> {
|
||||
// 先保存配置再测试,确保使用最新配置
|
||||
if (!accountPasswordForm.baseUrl || !accountPasswordForm.apiAccount || !accountPasswordForm.apiPassword) {
|
||||
ElMessage.warning('请先填写完整配置')
|
||||
return
|
||||
}
|
||||
|
||||
testingConnection.value = true
|
||||
try {
|
||||
// 先保存配置
|
||||
await updateIntegration(configuringIntegration.value!.id, {
|
||||
api_account: accountPasswordForm.apiAccount,
|
||||
api_password: accountPasswordForm.apiPassword,
|
||||
base_url: accountPasswordForm.baseUrl,
|
||||
validate_key: accountPasswordForm.validateKey || undefined,
|
||||
})
|
||||
|
||||
// 再测试连接
|
||||
const response = await testLianruanConnection()
|
||||
testResult.value = response.data.data
|
||||
testResultVisible.value = true
|
||||
|
||||
// 如果测试成功,更新本地状态
|
||||
if (testResult.value?.success) {
|
||||
const idx = integrations.value.findIndex((i) => i.id === configuringIntegration.value!.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: 'connected',
|
||||
config: {
|
||||
api_url: accountPasswordForm.baseUrl,
|
||||
api_account_set: true,
|
||||
api_password_set: true,
|
||||
base_url: accountPasswordForm.baseUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
|
||||
testResultVisible.value = true
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试火绒API连接 */
|
||||
async function handleTestHuorongConnection(): Promise<void> {
|
||||
// 先保存配置再测试,确保使用最新配置
|
||||
if (!accessKeyForm.baseUrl || !accessKeyForm.accessKeyId || !accessKeyForm.accessKeySecret) {
|
||||
ElMessage.warning('请先填写完整配置')
|
||||
return
|
||||
}
|
||||
|
||||
testingConnection.value = true
|
||||
try {
|
||||
// 先保存配置
|
||||
await updateIntegration(configuringIntegration.value!.id, {
|
||||
access_key_id: accessKeyForm.accessKeyId,
|
||||
access_key_secret: accessKeyForm.accessKeySecret,
|
||||
base_url: accessKeyForm.baseUrl,
|
||||
})
|
||||
|
||||
// 再测试连接
|
||||
const response = await testHuorongConnection()
|
||||
testResult.value = response.data.data
|
||||
testResultVisible.value = true
|
||||
|
||||
// 如果测试成功,更新本地状态
|
||||
if (testResult.value?.success) {
|
||||
const idx = integrations.value.findIndex((i) => i.id === configuringIntegration.value!.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: 'connected',
|
||||
config: {
|
||||
api_url: accessKeyForm.baseUrl,
|
||||
api_key_set: true,
|
||||
access_key_id_set: true,
|
||||
access_key_secret_set: true,
|
||||
base_url: accessKeyForm.baseUrl,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
|
||||
testResultVisible.value = true
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试连接(通用,非火绒/联软) */
|
||||
async function handleTest(integration: Integration): Promise<void> {
|
||||
if (integration.id === 'huorong' || integration.id === 'lianruan') {
|
||||
// 火绒/联软的测试在配置对话框中完成
|
||||
return
|
||||
}
|
||||
|
||||
if (integration.id === 'ragflow') {
|
||||
// RAGFlow 测试连接
|
||||
testingConnection.value = true
|
||||
try {
|
||||
const response = await testRagflowConnection()
|
||||
testResult.value = response.data.data
|
||||
testResultVisible.value = true
|
||||
|
||||
// 如果测试成功,更新本地状态
|
||||
if (testResult.value?.success) {
|
||||
const idx = integrations.value.findIndex((i) => i.id === integration.id)
|
||||
if (idx >= 0) {
|
||||
integrations.value[idx] = {
|
||||
...integrations.value[idx],
|
||||
status: 'connected',
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
|
||||
testResultVisible.value = true
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`正在测试 ${integration.name} 连接...`)
|
||||
}
|
||||
|
||||
/** 查看详情 */
|
||||
function handleView(integration: Integration): void {
|
||||
ElMessage.info(`${integration.name} — 暂无更多配置信息`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面 */
|
||||
.integrations-page {
|
||||
/* 样式使用全局 .integration-grid */
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* 表单提示 */
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 测试结果 */
|
||||
.test-result {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.test-detail {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 调试提示 */
|
||||
.test-debug-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #d97706;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.test-debug-info {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 管理员登录页
|
||||
=============================================================================
|
||||
说明:管理员登录页面
|
||||
- 复用坐席端登录 API(POST /api/agents/login)
|
||||
- 登录后检查 role === 'admin',非 admin 提示"无管理权限"
|
||||
- 使用 admin Store 的 login 方法
|
||||
-->
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="login-bg">
|
||||
<div class="login-bg-gradient"></div>
|
||||
</div>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-card">
|
||||
<!-- Logo -->
|
||||
<div class="login-logo">
|
||||
<div class="login-logo-icon">
|
||||
<el-icon :size="28"><Headset /></el-icon>
|
||||
</div>
|
||||
<h1 class="login-title">IT智能服务台</h1>
|
||||
<p class="login-subtitle">管理后台</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item label="企微用户ID" prop="userId">
|
||||
<el-input
|
||||
v-model="loginForm.userId"
|
||||
placeholder="请输入企微用户ID(如 SongXian)"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input
|
||||
v-model="loginForm.name"
|
||||
placeholder="请输入您的姓名"
|
||||
size="large"
|
||||
:prefix-icon="Avatar"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="adminStore.logging"
|
||||
:disabled="adminStore.logging"
|
||||
@click="handleLogin"
|
||||
class="login-btn"
|
||||
>
|
||||
{{ adminStore.logging ? '登录中...' : '登录管理后台' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="errorMsg = ''"
|
||||
style="margin-top: 16px"
|
||||
/>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="login-tips">
|
||||
<el-icon :size="14"><InfoFilled /></el-icon>
|
||||
<span>仅限组长(admin角色)登录管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { User, Avatar, InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
// ==========================================================================
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
// ==========================================================================
|
||||
// 表单状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 表单引用 */
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 登录表单数据 */
|
||||
const loginForm = reactive({
|
||||
userId: '',
|
||||
name: '',
|
||||
})
|
||||
|
||||
/** 表单校验规则 */
|
||||
const rules: FormRules = {
|
||||
userId: [
|
||||
{ required: true, message: '请输入企微用户ID', trigger: 'blur' },
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref<string>('')
|
||||
|
||||
// ==========================================================================
|
||||
// 方法
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 处理登录
|
||||
*/
|
||||
async function handleLogin(): Promise<void> {
|
||||
// 表单校验
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
await adminStore.login(loginForm.userId.trim(), loginForm.name.trim())
|
||||
} catch (error: unknown) {
|
||||
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
||||
errorMsg.value = errMsg
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 登录页面容器 */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-bg-gradient {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(ellipse at center, rgba(59, 130, 246, 0.08) 0%, transparent 60%);
|
||||
animation: rotate 30s linear infinite;
|
||||
}
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.login-logo-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 提示信息 */
|
||||
.login-tips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 会话监控页
|
||||
=============================================================================
|
||||
说明:实时查看坐席工作状态、会话队列、异常告警
|
||||
阶段二功能,当前为 Demo 预览
|
||||
顶部统计卡片 + 实时会话表格
|
||||
-->
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">会话监控</div>
|
||||
<div class="page-desc">
|
||||
实时查看坐席工作状态、会话队列、异常告警。阶段二功能,当前为 Demo 预览。
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<StatCard
|
||||
label="进行中会话"
|
||||
:value="stats.in_progress"
|
||||
valueColor="var(--accent)"
|
||||
icon="ChatLineSquare"
|
||||
iconColor="var(--accent)"
|
||||
/>
|
||||
<StatCard
|
||||
label="等待中"
|
||||
:value="stats.queued"
|
||||
valueColor="var(--warning)"
|
||||
icon="Clock"
|
||||
iconColor="var(--warning)"
|
||||
/>
|
||||
<StatCard
|
||||
label="今日已结单"
|
||||
:value="stats.resolved_today"
|
||||
valueColor="var(--success)"
|
||||
icon="CircleCheckFilled"
|
||||
iconColor="var(--success)"
|
||||
/>
|
||||
<StatCard
|
||||
label="异常告警"
|
||||
:value="stats.alerts"
|
||||
valueColor="var(--danger)"
|
||||
icon="WarningFilled"
|
||||
iconColor="var(--danger)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 实时会话表格 -->
|
||||
<div class="table-wrapper">
|
||||
<div class="table-header-row">
|
||||
<div class="table-title">实时会话</div>
|
||||
<el-tag type="primary" size="small">Demo 预览</el-tag>
|
||||
</div>
|
||||
<el-table
|
||||
:data="sessions"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
||||
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
||||
row-class-name="monitor-table-row"
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载会话数据..."
|
||||
>
|
||||
<el-table-column label="会话ID" width="80">
|
||||
<template #default="{ row }">
|
||||
<span class="session-id">{{ row.id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.employee_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="坐席" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.assigned_agent_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="持续时长" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDuration(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="紧急度" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.urgency_score || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getSessionStatusTag(row.status)" size="small">
|
||||
{{ getSessionStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import { getMonitorSessions } from '@/api/admin'
|
||||
import type { MonitorSession, MonitorStats } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
const stats = reactive<MonitorStats>({
|
||||
in_progress: 0,
|
||||
queued: 0,
|
||||
resolved_today: 0,
|
||||
alerts: 0,
|
||||
})
|
||||
|
||||
const sessions = ref<MonitorSession[]>([])
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getMonitorSessions()
|
||||
const data = response.data.data
|
||||
Object.assign(stats, data.stats)
|
||||
sessions.value = data.items
|
||||
} catch {
|
||||
// 使用 Demo 数据
|
||||
stats.in_progress = 5
|
||||
stats.queued = 0
|
||||
stats.resolved_today = 42
|
||||
stats.alerts = 1
|
||||
|
||||
sessions.value = [
|
||||
{ id: '#C042', employee_name: '李明 (EMP101)', status: 'serving', assigned_agent_name: '宋献', urgency_score: 8, created_at: new Date(Date.now() - 5 * 60000).toISOString(), last_message_summary: '电脑无法开机' },
|
||||
{ id: '#C041', employee_name: '赵刚 (EMP205)', status: 'serving', assigned_agent_name: '宋献', urgency_score: 14, created_at: new Date(Date.now() - 12 * 60000).toISOString(), last_message_summary: 'VPN连接失败' },
|
||||
{ id: '#C040', employee_name: '孙芳 (EMP312)', status: 'timeout', assigned_agent_name: '王丽', urgency_score: 6, created_at: new Date(Date.now() - 8 * 60000).toISOString(), last_message_summary: '打印机脱机' },
|
||||
{ id: '#C039', employee_name: '周磊 (EMP088)', status: 'serving', assigned_agent_name: '宋献', urgency_score: 4, created_at: new Date(Date.now() - 3 * 60000).toISOString(), last_message_summary: '邮箱登录失败' },
|
||||
{ id: '#C038', employee_name: '吴婷 (EMP156)', status: 'ai_chatting', assigned_agent_name: 'AI 助手', urgency_score: 2, created_at: new Date(Date.now() - 1 * 60000).toISOString(), last_message_summary: '密码重置请求' },
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 辅助方法
|
||||
// ==========================================================================
|
||||
|
||||
/** 格式化持续时长 */
|
||||
function formatDuration(session: MonitorSession): string {
|
||||
try {
|
||||
const created = new Date(session.created_at).getTime()
|
||||
const now = Date.now()
|
||||
const diffMs = now - created
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
const seconds = Math.floor((diffMs % 60000) / 1000)
|
||||
return `${minutes}分${seconds}秒`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话状态标签类型 */
|
||||
function getSessionStatusTag(status: string): string {
|
||||
switch (status) {
|
||||
case 'serving': return 'success'
|
||||
case 'timeout': return 'warning'
|
||||
case 'ai_chatting': return 'primary'
|
||||
case 'queued': return 'info'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/** 会话状态文本 */
|
||||
function getSessionStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'serving': return '进行中'
|
||||
case 'timeout': return '超时未响应'
|
||||
case 'ai_chatting': return 'AI 对话中'
|
||||
case 'queued': return '等待中'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面 */
|
||||
.monitor-page {
|
||||
/* 样式使用全局 .stats-grid .table-wrapper 等 */
|
||||
}
|
||||
|
||||
/* 会话ID */
|
||||
.session-id {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 监控表格行悬停 */
|
||||
.monitor-table-row:hover td {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 通用占位页
|
||||
=============================================================================
|
||||
说明:通用占位页面模板
|
||||
- 居中"开发中"提示
|
||||
- 返回首页按钮
|
||||
- 可配置标题和描述
|
||||
-->
|
||||
<template>
|
||||
<div class="placeholder-center">
|
||||
<!-- 图标 -->
|
||||
<div class="placeholder-icon">
|
||||
<el-icon :size="64">
|
||||
<Tools />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="placeholder-title">{{ title }}</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="placeholder-desc">{{ description }}</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<el-button type="primary" @click="goHome">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
返回运营总览
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
// ==========================================================================
|
||||
// 路由
|
||||
// ==========================================================================
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// ==========================================================================
|
||||
// 动态标题/描述
|
||||
// ==========================================================================
|
||||
const title = computed(() => {
|
||||
const metaTitle = route.meta.title as string
|
||||
if (route.path === '/:pathMatch(.*)*') return '页面未找到'
|
||||
return metaTitle || '功能开发中'
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
if (route.path === '/:pathMatch(.*)*') return '您访问的页面不存在'
|
||||
if (route.meta.comingSoon) return '该功能正在开发中,敬请期待'
|
||||
return '此功能将在后续版本中开放,请关注版本更新'
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 返回首页
|
||||
// ==========================================================================
|
||||
function goHome(): void {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 快速回复管理页
|
||||
=============================================================================
|
||||
说明:管理快速回复模板审核流程
|
||||
- 左侧/顶部:分类标签筛选
|
||||
- 右侧/主体:卡片列表显示待审核模板
|
||||
- 审核操作:通过(直接生效)/ 驳回(需填原因对话框)
|
||||
-->
|
||||
<template>
|
||||
<div class="quick-replies-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">快速回复管理</div>
|
||||
<div class="page-desc">分类管理 + 版本历史 + 审核发布流程。坐席提交→组长审核→全员可见。</div>
|
||||
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="replies-toolbar">
|
||||
<!-- 分类筛选标签 -->
|
||||
<div class="category-tabs">
|
||||
<el-button
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:type="quickReplyStore.activeCategory === cat ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="quickReplyStore.setCategory(cat)"
|
||||
>
|
||||
{{ cat }} ({{ getCategoryCount(cat) }})
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 新增按钮 -->
|
||||
<el-button type="primary">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增模板
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="quickReplyStore.loading" class="loading-state">
|
||||
<el-skeleton :rows="5" animated />
|
||||
</div>
|
||||
|
||||
<!-- 模板列表 -->
|
||||
<div v-else-if="quickReplyStore.filteredReplies.length > 0">
|
||||
<QuickReplyCard
|
||||
v-for="reply in quickReplyStore.filteredReplies"
|
||||
:key="reply.id"
|
||||
:reply="reply"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-else description="暂无待审核的快速回复模板" />
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 驳回原因对话框 -->
|
||||
<!-- ================================================================ -->
|
||||
<el-dialog
|
||||
v-model="rejectDialogVisible"
|
||||
title="驳回快速回复"
|
||||
width="440px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="驳回原因">
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请填写驳回原因,如:内容需要更具体的操作步骤"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="handleRejectConfirm">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import QuickReplyCard from '@/components/QuickReplyCard.vue'
|
||||
import { useQuickReplyStore } from '@/stores/quickReply'
|
||||
import type { QuickReplyTemplate } from '@/types'
|
||||
import { QUICK_REPLY_CATEGORIES } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// Store
|
||||
// ==========================================================================
|
||||
const quickReplyStore = useQuickReplyStore()
|
||||
|
||||
// ==========================================================================
|
||||
// 分类列表
|
||||
// ==========================================================================
|
||||
const categories = [...QUICK_REPLY_CATEGORIES]
|
||||
|
||||
// ==========================================================================
|
||||
// 驳回对话框
|
||||
// ==========================================================================
|
||||
const rejectDialogVisible = ref<boolean>(false)
|
||||
const rejectingReply = ref<QuickReplyTemplate | null>(null)
|
||||
const rejectReason = ref<string>('')
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(() => {
|
||||
quickReplyStore.loadReplies()
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 分类计数
|
||||
// ==========================================================================
|
||||
function getCategoryCount(cat: string): number {
|
||||
if (cat === '全部') return quickReplyStore.replies.length
|
||||
return quickReplyStore.replies.filter((r) => r.category === cat).length
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 审核操作
|
||||
// ==========================================================================
|
||||
|
||||
/** 通过审核 */
|
||||
async function handleApprove(reply: QuickReplyTemplate): Promise<void> {
|
||||
const success = await quickReplyStore.review(reply.id, 'approve')
|
||||
if (success) {
|
||||
ElMessage.success('已通过审核')
|
||||
} else {
|
||||
ElMessage.error('审核操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开驳回对话框 */
|
||||
function handleReject(reply: QuickReplyTemplate): void {
|
||||
rejectingReply.value = reply
|
||||
rejectReason.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 确认驳回 */
|
||||
async function handleRejectConfirm(): Promise<void> {
|
||||
if (!rejectingReply.value) return
|
||||
const success = await quickReplyStore.review(
|
||||
rejectingReply.value.id,
|
||||
'reject',
|
||||
rejectReason.value || '未填写原因'
|
||||
)
|
||||
if (success) {
|
||||
ElMessage.success('已驳回')
|
||||
rejectDialogVisible.value = false
|
||||
} else {
|
||||
ElMessage.error('驳回操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 工具栏 */
|
||||
.replies-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 分类标签 */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,882 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 角色管理页
|
||||
=============================================================================
|
||||
说明:管理用户角色分配和角色映射规则
|
||||
- 角色卡片:展示 3 个预置角色(用户/坐席/管理员)及用户数量
|
||||
- 用户角色分配:手动为用户分配/撤销角色
|
||||
- 映射规则:配置企微标签/eHR 岗位自动映射到角色
|
||||
-->
|
||||
<template>
|
||||
<div class="roles-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">角色管理</div>
|
||||
<div class="page-desc">管理系统角色定义、用户角色分配和自动映射规则</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- ============================================================ -->
|
||||
<!-- 角色卡片 -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="section-title">角色概览</div>
|
||||
<div class="role-cards">
|
||||
<div
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="role-card"
|
||||
:class="{ 'role-card--default': role.is_default }"
|
||||
>
|
||||
<div class="role-card-header">
|
||||
<div class="role-card-icon" :style="{ background: getRoleIconBg(role.name), color: getRoleIconColor(role.name) }">
|
||||
<el-icon :size="20">
|
||||
<User v-if="role.name === 'user'" />
|
||||
<Headset v-else-if="role.name === 'agent'" />
|
||||
<UserFilled v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="role-card-info">
|
||||
<div class="role-card-name">{{ role.display_name }}</div>
|
||||
<div class="role-card-id">{{ role.name }}</div>
|
||||
</div>
|
||||
<el-tag v-if="role.is_default" type="info" size="small">默认</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="role-card-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ role.user_count ?? 0 }}</span>
|
||||
<span class="stat-label">关联用户</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ role.permissions.length }}</span>
|
||||
<span class="stat-label">权限数</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="role.description" class="role-card-desc">{{ role.description }}</div>
|
||||
|
||||
<div class="role-card-perms">
|
||||
<el-tag
|
||||
v-for="perm in role.permissions.slice(0, 6)"
|
||||
:key="perm"
|
||||
size="small"
|
||||
type="info"
|
||||
class="perm-tag"
|
||||
>
|
||||
{{ perm }}
|
||||
</el-tag>
|
||||
<el-tag v-if="role.permissions.length > 6" size="small" type="info" class="perm-tag">
|
||||
+{{ role.permissions.length - 6 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 用户角色分配 -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">用户角色分配</div>
|
||||
<el-button type="primary" size="small" @click="showAssignDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
分配角色
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="data-table-wrapper">
|
||||
<el-table
|
||||
:data="filteredUserRoles"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="暂无用户角色记录"
|
||||
>
|
||||
<el-table-column prop="employee_id" label="员工 ID" min-width="140" />
|
||||
<el-table-column prop="role_display_name" label="角色" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleTagType(row.role_name)" size="small">
|
||||
{{ row.role_display_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="source-badge" :class="'source-' + row.source">
|
||||
{{ ROLE_SOURCE_LABELS[row.source as UserRoleSource] || row.source }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_by" label="分配者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.assigned_by || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_at" label="分配时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.assigned_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expires_at" label="过期时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.expires_at">{{ formatTime(row.expires_at) }}</span>
|
||||
<span v-else class="text-muted">永不过期</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
:disabled="isDefaultRole(row.role_name)"
|
||||
@click="handleRevoke(row)"
|
||||
>
|
||||
撤销
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 角色映射规则 -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="section-header">
|
||||
<div class="section-title">自动映射规则</div>
|
||||
<el-button type="primary" size="small" @click="showCreateRuleDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建规则
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="data-table-wrapper">
|
||||
<el-table
|
||||
:data="mappingRules"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="暂无映射规则"
|
||||
>
|
||||
<el-table-column prop="role_name" label="目标角色" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleTagType(row.role_name)" size="small">
|
||||
{{ row.role_name }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source_type" label="来源类型" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ MAPPING_SOURCE_LABELS[row.source_type as MappingSourceSource] || row.source_type }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source_value" label="匹配值" min-width="160" />
|
||||
<el-table-column prop="priority" label="优先级" min-width="80" />
|
||||
<el-table-column prop="is_active" label="状态" min-width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
@click="handleDeleteRule(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 分配角色对话框 -->
|
||||
<!-- ============================================================ -->
|
||||
<el-dialog
|
||||
v-model="assignDialogVisible"
|
||||
title="分配角色"
|
||||
width="440px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="员工 ID(企微 UserID)">
|
||||
<el-input
|
||||
v-model="assignForm.employee_id"
|
||||
placeholder="输入企微 UserID"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标角色">
|
||||
<el-select v-model="assignForm.role_name" placeholder="选择角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.name"
|
||||
:label="role.display_name + ' (' + role.name + ')'"
|
||||
:value="role.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配原因(可选)">
|
||||
<el-input
|
||||
v-model="assignForm.reason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="说明分配原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="assigning" @click="handleAssign">
|
||||
{{ assigning ? '分配中...' : '确认分配' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 撤销角色确认对话框 -->
|
||||
<!-- ============================================================ -->
|
||||
<el-dialog
|
||||
v-model="revokeDialogVisible"
|
||||
title="撤销角色"
|
||||
width="440px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="revokingUserRole" class="revoke-info">
|
||||
<p>确认撤销以下角色分配?</p>
|
||||
<div class="revoke-detail">
|
||||
<div><strong>员工:</strong>{{ revokingUserRole.employee_id }}</div>
|
||||
<div><strong>角色:</strong>{{ revokingUserRole.role_display_name }} ({{ revokingUserRole.role_name }})</div>
|
||||
<div><strong>来源:</strong>{{ ROLE_SOURCE_LABELS[revokingUserRole.source as UserRoleSource] || revokingUserRole.source }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form label-position="top" style="margin-top: 16px;">
|
||||
<el-form-item label="撤销原因(可选)">
|
||||
<el-input
|
||||
v-model="revokeForm.reason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="说明撤销原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="revokeDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="revoking" @click="handleRevokeConfirm">
|
||||
{{ revoking ? '撤销中...' : '确认撤销' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 新建映射规则对话框 -->
|
||||
<!-- ============================================================ -->
|
||||
<el-dialog
|
||||
v-model="createRuleDialogVisible"
|
||||
title="新建映射规则"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="目标角色">
|
||||
<el-select v-model="ruleForm.role_name" placeholder="选择角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.name"
|
||||
:label="role.display_name + ' (' + role.name + ')'"
|
||||
:value="role.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源类型">
|
||||
<el-select v-model="ruleForm.source_type" placeholder="选择来源类型" style="width: 100%">
|
||||
<el-option label="企微标签" value="wecom_tag" />
|
||||
<el-option label="eHR 岗位" value="ehr_position" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="匹配值">
|
||||
<el-input
|
||||
v-model="ruleForm.source_value"
|
||||
:placeholder="ruleForm.source_type === 'wecom_tag' ? '企微标签名,如 IT坐席' : '岗位关键词,如 IT支持'"
|
||||
/>
|
||||
<div class="form-hint">
|
||||
{{ ruleForm.source_type === 'wecom_tag'
|
||||
? '用户拥有此企微标签时,自动获得目标角色'
|
||||
: '用户 eHR 岗位包含此关键词时,自动获得目标角色'
|
||||
}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级(0-100)">
|
||||
<el-input-number v-model="ruleForm.priority" :min="0" :max="100" :step="10" />
|
||||
<div class="form-hint">数值越大优先级越高,同一来源取最高优先级角色</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createRuleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creatingRule" @click="handleCreateRule">
|
||||
{{ creatingRule ? '创建中...' : '创建规则' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// 依赖导入
|
||||
// ==========================================================================
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, User, Headset, UserFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRoles,
|
||||
assignRole,
|
||||
revokeRole,
|
||||
getRoleMappingRules,
|
||||
createRoleMappingRule,
|
||||
deleteRoleMappingRule,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
Role,
|
||||
UserRole,
|
||||
UserRoleSource,
|
||||
RoleMappingRule,
|
||||
MappingSourceSource,
|
||||
} from '@/types'
|
||||
import { ROLE_SOURCE_LABELS, MAPPING_SOURCE_LABELS } from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
const loading = ref<boolean>(false)
|
||||
const roles = ref<Role[]>([])
|
||||
const userRoles = ref<UserRole[]>([])
|
||||
const mappingRules = ref<RoleMappingRule[]>([])
|
||||
|
||||
// ==========================================================================
|
||||
// 分配角色
|
||||
// ==========================================================================
|
||||
const assignDialogVisible = ref<boolean>(false)
|
||||
const assigning = ref<boolean>(false)
|
||||
const assignForm = reactive({
|
||||
employee_id: '',
|
||||
role_name: 'agent',
|
||||
reason: '',
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 撤销角色
|
||||
// ==========================================================================
|
||||
const revokeDialogVisible = ref<boolean>(false)
|
||||
const revoking = ref<boolean>(false)
|
||||
const revokingUserRole = ref<UserRole | null>(null)
|
||||
const revokeForm = reactive({
|
||||
reason: '',
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 新建映射规则
|
||||
// ==========================================================================
|
||||
const createRuleDialogVisible = ref<boolean>(false)
|
||||
const creatingRule = ref<boolean>(false)
|
||||
const ruleForm = reactive({
|
||||
role_name: 'agent',
|
||||
source_type: 'wecom_tag' as MappingSourceSource,
|
||||
source_value: '',
|
||||
priority: 10,
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 初始化
|
||||
// ==========================================================================
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rolesRes, rulesRes] = await Promise.all([
|
||||
getRoles(),
|
||||
getRoleMappingRules(),
|
||||
])
|
||||
roles.value = rolesRes.data.data
|
||||
mappingRules.value = rulesRes.data.data
|
||||
} catch {
|
||||
// 使用默认 demo 数据
|
||||
roles.value = getDefaultRoles()
|
||||
mappingRules.value = getDefaultMappingRules()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 默认数据(API 不可用时的 fallback)
|
||||
// ==========================================================================
|
||||
function getDefaultRoles(): Role[] {
|
||||
return [
|
||||
{
|
||||
id: '1', name: 'user', display_name: '用户',
|
||||
description: '普通用户,通过企微 H5 端提交问题',
|
||||
permissions: ['ticket.create', 'ticket.view_own', 'message.send'],
|
||||
is_default: true, user_count: 120,
|
||||
created_at: '2026-06-12T00:00:00', updated_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
{
|
||||
id: '2', name: 'agent', display_name: '坐席',
|
||||
description: 'IT 支持坐席,处理用户问题',
|
||||
permissions: ['ticket.create', 'ticket.view_all', 'ticket.assign', 'ticket.resolve', 'conversation.manage', 'quick_reply.use'],
|
||||
is_default: false, user_count: 8,
|
||||
created_at: '2026-06-12T00:00:00', updated_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
{
|
||||
id: '3', name: 'admin', display_name: '管理员',
|
||||
description: '系统管理员,拥有全部管理权限',
|
||||
permissions: ['ticket.create', 'ticket.view_all', 'ticket.assign', 'ticket.resolve', 'conversation.manage', 'quick_reply.manage', 'system.config', 'agent.manage', 'role.manage'],
|
||||
is_default: false, user_count: 2,
|
||||
created_at: '2026-06-12T00:00:00', updated_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function getDefaultMappingRules(): RoleMappingRule[] {
|
||||
return [
|
||||
{
|
||||
id: '1', role_id: '2', role_name: 'agent', source_type: 'wecom_tag',
|
||||
source_value: 'IT坐席', priority: 10, is_active: true,
|
||||
created_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
{
|
||||
id: '2', role_id: '2', role_name: 'agent', source_type: 'ehr_position',
|
||||
source_value: 'IT支持', priority: 5, is_active: true,
|
||||
created_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
{
|
||||
id: '3', role_id: '2', role_name: 'agent', source_type: 'ehr_position',
|
||||
source_value: 'IT运维', priority: 5, is_active: true,
|
||||
created_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
{
|
||||
id: '4', role_id: '2', role_name: 'agent', source_type: 'ehr_position',
|
||||
source_value: '技术支持', priority: 3, is_active: true,
|
||||
created_at: '2026-06-12T00:00:00',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 用户角色列表(从角色卡片点击可筛选,目前显示全部)
|
||||
// ==========================================================================
|
||||
const filteredUserRoles = computed(() => userRoles.value)
|
||||
|
||||
// ==========================================================================
|
||||
// 工具函数
|
||||
// ==========================================================================
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 角色标签颜色 */
|
||||
function getRoleTagType(name: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const map: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
user: 'info',
|
||||
agent: 'success',
|
||||
admin: 'danger',
|
||||
}
|
||||
return map[name] || 'info'
|
||||
}
|
||||
|
||||
/** 角色图标背景色 */
|
||||
function getRoleIconBg(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
user: 'var(--bg-tertiary)',
|
||||
agent: 'var(--success-bg)',
|
||||
admin: 'var(--danger-bg)',
|
||||
}
|
||||
return map[name] || 'var(--bg-tertiary)'
|
||||
}
|
||||
|
||||
/** 角色图标颜色 */
|
||||
function getRoleIconColor(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
user: 'var(--text-muted)',
|
||||
agent: 'var(--success)',
|
||||
admin: 'var(--danger)',
|
||||
}
|
||||
return map[name] || 'var(--text-muted)'
|
||||
}
|
||||
|
||||
/** 是否默认角色(默认角色不允许撤销) */
|
||||
function isDefaultRole(roleName: string): boolean {
|
||||
return roles.value.some(r => r.name === roleName && r.is_default)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 操作处理
|
||||
// ==========================================================================
|
||||
|
||||
/** 打开分配角色对话框 */
|
||||
function showAssignDialog(): void {
|
||||
assignForm.employee_id = ''
|
||||
assignForm.role_name = 'agent'
|
||||
assignForm.reason = ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交分配角色 */
|
||||
async function handleAssign(): Promise<void> {
|
||||
if (!assignForm.employee_id.trim()) {
|
||||
ElMessage.warning('请输入员工 ID')
|
||||
return
|
||||
}
|
||||
if (!assignForm.role_name) {
|
||||
ElMessage.warning('请选择目标角色')
|
||||
return
|
||||
}
|
||||
|
||||
assigning.value = true
|
||||
try {
|
||||
await assignRole({
|
||||
employee_id: assignForm.employee_id.trim(),
|
||||
role_name: assignForm.role_name,
|
||||
reason: assignForm.reason || undefined,
|
||||
})
|
||||
ElMessage.success('角色分配成功')
|
||||
assignDialogVisible.value = false
|
||||
// 刷新数据
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('分配失败,请检查员工 ID 是否正确')
|
||||
} finally {
|
||||
assigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开撤销角色确认 */
|
||||
function handleRevoke(userRole: UserRole): void {
|
||||
revokingUserRole.value = userRole
|
||||
revokeForm.reason = ''
|
||||
revokeDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 确认撤销角色 */
|
||||
async function handleRevokeConfirm(): Promise<void> {
|
||||
if (!revokingUserRole.value) return
|
||||
|
||||
revoking.value = true
|
||||
try {
|
||||
await revokeRole({
|
||||
employee_id: revokingUserRole.value.employee_id,
|
||||
role_name: revokingUserRole.value.role_name,
|
||||
reason: revokeForm.reason || undefined,
|
||||
})
|
||||
ElMessage.success('角色撤销成功')
|
||||
revokeDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('撤销失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新建映射规则对话框 */
|
||||
function showCreateRuleDialog(): void {
|
||||
ruleForm.role_name = 'agent'
|
||||
ruleForm.source_type = 'wecom_tag'
|
||||
ruleForm.source_value = ''
|
||||
ruleForm.priority = 10
|
||||
createRuleDialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 提交创建映射规则 */
|
||||
async function handleCreateRule(): Promise<void> {
|
||||
if (!ruleForm.source_value.trim()) {
|
||||
ElMessage.warning('请输入匹配值')
|
||||
return
|
||||
}
|
||||
|
||||
creatingRule.value = true
|
||||
try {
|
||||
await createRoleMappingRule({
|
||||
role_name: ruleForm.role_name,
|
||||
source_type: ruleForm.source_type,
|
||||
source_value: ruleForm.source_value.trim(),
|
||||
priority: ruleForm.priority,
|
||||
})
|
||||
ElMessage.success('映射规则创建成功')
|
||||
createRuleDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('创建失败,可能已存在相同规则')
|
||||
} finally {
|
||||
creatingRule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除映射规则 */
|
||||
async function handleDeleteRule(rule: RoleMappingRule): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除映射规则:${MAPPING_SOURCE_LABELS[rule.source_type]} = "${rule.source_value}" → ${rule.role_name}?`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRoleMappingRule(rule.id)
|
||||
ElMessage.success('映射规则已删除')
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新全部数据 */
|
||||
async function loadData(): Promise<void> {
|
||||
try {
|
||||
const [rolesRes, rulesRes] = await Promise.all([
|
||||
getRoles(),
|
||||
getRoleMappingRules(),
|
||||
])
|
||||
roles.value = rolesRes.data.data
|
||||
mappingRules.value = rulesRes.data.data
|
||||
} catch {
|
||||
// 静默失败,保留现有数据
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.roles-page {
|
||||
/* 页面容器 */
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
/* 角色卡片网格 */
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.role-card--default {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.role-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.role-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-card-id {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-card-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-card-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-card-perms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.perm-tag {
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* 数据表格 */
|
||||
.data-table-wrapper {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 来源标签 */
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.source-manual {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.source-auto {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.source-ehr {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 撤销确认 */
|
||||
.revoke-info p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.revoke-detail {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.revoke-detail strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 表单提示 */
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 会话审计页面 — 历史会话查看与筛选 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="session-audit">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索员工姓名/消息摘要"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="filters.status" placeholder="会话状态" clearable style="width: 140px">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="AI处理中" value="ai_handling" />
|
||||
<el-option label="排队中" value="queued" />
|
||||
<el-option label="服务中" value="serving" />
|
||||
<el-option label="已结单" value="resolved" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 280px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<el-table
|
||||
:data="conversations"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
@row-click="handleRowClick"
|
||||
row-class-name="clickable-row"
|
||||
>
|
||||
<el-table-column prop="employee_name" label="员工姓名" width="120" />
|
||||
<el-table-column prop="department" label="部门" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_agent_name" label="负责坐席" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.assigned_agent_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="urgency_score" label="紧急度" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: urgencyColor(row.urgency_score) }">
|
||||
{{ row.urgency_score }}/5
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_message_summary" label="最近消息" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click.stop="showDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadConversations"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="`会话详情 — ${selectedConversation?.employee_name || ''}`"
|
||||
size="500px"
|
||||
direction="rtl"
|
||||
>
|
||||
<div v-if="detailLoading" v-loading="true" style="height: 200px" />
|
||||
<div v-else-if="conversationDetail" class="detail-content">
|
||||
<!-- 基本信息 -->
|
||||
<div class="detail-section">
|
||||
<h4>基本信息</h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="员工姓名">{{ conversationDetail.employee_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="员工ID">{{ conversationDetail.employee_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">{{ conversationDetail.department || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="岗位">{{ conversationDetail.position || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(conversationDetail.status)" size="small">
|
||||
{{ statusLabel(conversationDetail.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责坐席">{{ conversationDetail.assigned_agent_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatTime(conversationDetail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="detail-section">
|
||||
<h4>消息记录 ({{ conversationDetail.messages?.length || 0 }})</h4>
|
||||
<div class="message-list">
|
||||
<div
|
||||
v-for="msg in conversationDetail.messages"
|
||||
:key="msg.id"
|
||||
class="message-item"
|
||||
:class="`sender-${msg.sender_type}`"
|
||||
>
|
||||
<div class="message-header">
|
||||
<el-tag :type="senderTagType(msg.sender_type)" size="small">
|
||||
{{ senderLabel(msg.sender_type) }}
|
||||
</el-tag>
|
||||
<span class="sender-name">{{ msg.sender_name }}</span>
|
||||
<span class="message-time">{{ formatTime(msg.created_at) }}</span>
|
||||
</div>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 会话审计页面逻辑
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getAuditConversations, getAuditConversationDetail } from '@/api/admin'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const conversations = ref<any[]>([])
|
||||
const selectedConversation = ref<any>(null)
|
||||
const conversationDetail = ref<any>(null)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadConversations() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (filters.keyword) params.keyword = filters.keyword
|
||||
if (filters.status) params.status = filters.status
|
||||
if (dateRange.value) {
|
||||
params.date_from = dateRange.value[0]
|
||||
params.date_to = dateRange.value[1]
|
||||
}
|
||||
|
||||
const { data } = await getAuditConversations(params)
|
||||
if (data.code === 0) {
|
||||
conversations.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载会话列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function showDetail(row: any) {
|
||||
selectedConversation.value = row
|
||||
drawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const { data } = await getAuditConversationDetail(row.id)
|
||||
if (data.code === 0) {
|
||||
conversationDetail.value = data.data
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载会话详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
dateRange.value = null
|
||||
pagination.page = 1
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function handleRowClick(row: any) {
|
||||
showDetail(row)
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function statusTagType(status: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
ai_handling: 'info',
|
||||
queued: 'warning',
|
||||
serving: '',
|
||||
resolved: 'success',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ai_handling: 'AI处理中',
|
||||
queued: '排队中',
|
||||
serving: '服务中',
|
||||
resolved: '已结单',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function urgencyColor(score: number): string {
|
||||
if (score >= 4) return '#f56c6c'
|
||||
if (score >= 3) return '#e6a23c'
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
function senderTagType(type: string): '' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
employee: 'info',
|
||||
agent: 'success',
|
||||
ai: 'warning',
|
||||
system: 'danger',
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function senderLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
employee: '员工',
|
||||
agent: '坐席',
|
||||
ai: 'AI',
|
||||
system: '系统',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
onMounted(() => loadConversations())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-audit {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.message-item.sender-employee {
|
||||
background: #ecf5ff;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.message-item.sender-agent {
|
||||
background: #f0f9eb;
|
||||
border-left: 3px solid #67c23a;
|
||||
}
|
||||
|
||||
.message-item.sender-ai {
|
||||
background: #fdf6ec;
|
||||
border-left: 3px solid #e6a23c;
|
||||
}
|
||||
|
||||
.message-item.sender-system {
|
||||
background: #f4f4f5;
|
||||
border-left: 3px solid #909399;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: #909399;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 系统日志页面 — 配置变更历史记录 -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="system-logs">
|
||||
<!-- 日志列表 -->
|
||||
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="changed_at" label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.changed_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="config_key" label="配置键" width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="changed_by_name" label="操作人" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.changed_by_name || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="old_value" label="变更前" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="old-value">{{ row.old_value || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="new_value" label="变更后" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="new-value">{{ row.new_value || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadLogs"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 系统日志页面逻辑
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getSystemLogs } from '@/api/admin'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSystemLogs({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
if (data.code === 0) {
|
||||
logs.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载系统日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
onMounted(() => loadLogs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-logs {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.old-value {
|
||||
color: #f56c6c;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.new-value {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user