chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
/// <reference types="vite/client" />
// Element Plus 语言包类型声明
declare module 'element-plus/dist/locale/zh-cn.mjs' {
import type { Language } from 'element-plus/es/locale'
const zhCn: Language
export default zhCn
}
// Vue 单文件组件类型声明
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// Vite 环境变量类型声明
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>IT智能服务台 - 管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3053
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "wecom-it-desk-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "企微IT智能服务台 - 管理后台前端",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"element-plus": "^2.7.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vue-tsc": "^2.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+13
View File
@@ -0,0 +1,13 @@
<!--
=============================================================================
企微IT智能服务台 管理后台根组件
=============================================================================
说明作为 Vue Router 的容器提供 <router-view /> 渲染匹配的路由组件
-->
<template>
<router-view />
</template>
<script setup lang="ts">
// 根组件仅作为路由容器,无需额外逻辑
</script>
+385
View File
@@ -0,0 +1,385 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 API 调用函数
// =============================================================================
// 说明:封装所有管理后台 API 端点调用,统一返回类型
// 所有函数返回 axios response,调用方从 response.data.data 获取业务数据
import apiClient from './index'
import type {
DashboardOverview,
ConfigGroup,
UpdateConfigRequest,
UpdateConfigResponse,
PaginatedData,
ConfigChangeLogEntry,
Agent,
CreateAgentRequest,
UpdateAgentRequest,
Integration,
UpdateIntegrationRequest,
QuickReplyTemplate,
ReviewQuickReplyRequest,
ReviewQuickReplyResponse,
AssignmentModeConfig,
UpdateAssignmentModeRequest,
MonitorSessionsData,
SearchResults,
Role,
RoleAssignRequest,
RoleRevokeRequest,
RoleMappingRule,
RoleMappingRuleRequest,
} from '@/types'
// ==========================================================================
// 运营总览
// ==========================================================================
/** 获取仪表盘统计数据 */
export function getDashboardOverview(): Promise<{ data: { code: number; data: DashboardOverview; message: string } }> {
return apiClient.get('/admin/dashboard/overview')
}
// ==========================================================================
// 功能开关/参数管理
// ==========================================================================
/** 获取全部配置项(按功能分组) */
export function getConfigGroups(): Promise<{ data: { code: number; data: { groups: ConfigGroup[] }; message: string } }> {
return apiClient.get('/admin/configs')
}
/** 更新单个配置项 */
export function updateConfig(
key: string,
value: string
): Promise<{ data: { code: number; data: UpdateConfigResponse; message: string } }> {
const body: UpdateConfigRequest = { value }
return apiClient.put(`/admin/configs/${key}`, body)
}
/** 获取指定配置项的变更历史 */
export function getConfigHistory(
key: string,
limit: number = 20
): Promise<{ data: { code: number; data: { items: ConfigChangeLogEntry[] }; message: string } }> {
return apiClient.get(`/admin/configs/${key}/history`, { params: { limit } })
}
// ==========================================================================
// 坐席管理
// ==========================================================================
/** 获取坐席列表(管理视图) */
export function getAgents(
status?: string
): Promise<{ data: { code: number; data: { items: Agent[] }; message: string } }> {
const params: Record<string, string> = {}
if (status && status !== 'all') {
params.status = status
}
return apiClient.get('/admin/agents', { params })
}
/** 添加坐席 */
export function createAgent(
data: CreateAgentRequest
): Promise<{ data: { code: number; data: Agent; message: string } }> {
return apiClient.post('/admin/agents', data)
}
/** 编辑坐席 */
export function updateAgent(
id: string,
data: UpdateAgentRequest
): Promise<{ data: { code: number; data: Agent; message: string } }> {
return apiClient.put(`/admin/agents/${id}`, data)
}
// ==========================================================================
// OTP 管理
// ==========================================================================
/** 强制解绑OTP */
export function unbindOtp(
id: string
): Promise<{ data: { code: number; data: { message: string }; message: string } }> {
return apiClient.post(`/admin/agents/${id}/otp-unbind`)
}
/** 移除坐席 */
export function deleteAgent(
id: string
): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.delete(`/admin/agents/${id}`)
}
// ==========================================================================
// 外部系统集成配置
// ==========================================================================
/** 获取集成系统列表及配置状态 */
export function getIntegrations(): Promise<{ data: { code: number; data: { items: Integration[] }; message: string } }> {
return apiClient.get('/admin/integrations')
}
/** 更新集成配置(支持 url_key 和 access_key 两种模式) */
export function updateIntegration(
id: string,
data: UpdateIntegrationRequest
): Promise<{ data: { code: number; data: Integration; message: string } }> {
return apiClient.put(`/admin/integrations/${id}`, data)
}
// ==========================================================================
// 火绒安全集成 API
// ==========================================================================
/** 火绒连接测试结果 */
export interface HuorongTestResult {
success: boolean
message: string
total_terminals?: number
}
/** 测试火绒API连接 */
export function testHuorongConnection(): Promise<{ data: { code: number; data: HuorongTestResult; message: string } }> {
return apiClient.post('/admin/integrations/huorong/test')
}
/** 火绒终端列表 */
export function getHuorongTerminals(
params?: { group_id?: string; page?: number; per_page?: number }
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get('/admin/integrations/huorong/terminals', { params })
}
/** 火绒终端详情 */
export function getHuorongTerminalDetail(
clientId: string
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get(`/admin/integrations/huorong/terminals/${clientId}`)
}
/** 火绒漏洞信息 */
export function getHuorongLeaks(
params?: { group_id?: string; page?: number; per_page?: number }
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get('/admin/integrations/huorong/leaks', { params })
}
/** 火绒病毒事件 */
export function getHuorongVirusEvents(
params?: { client_id?: string; group_id?: string; query_type?: number; page?: number; per_page?: number }
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get('/admin/integrations/huorong/virus-events', { params })
}
// ==========================================================================
// 联软LV7000 安全集成 API
// ==========================================================================
/** 测试联软API连接 */
export function testLianruanConnection(): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.post('/admin/integrations/lianruan/test')
}
/** 联软终端查询(核心映射接口) */
export function queryLianruanTerminals(
params?: { strusername?: string; strdevname?: string; strdevip?: string; page?: number; per_page?: number }
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get('/admin/integrations/lianruan/terminals', { params })
}
// ==========================================================================
// RAGFlow 知识检索集成
// ==========================================================================
/** 测试 RAGFlow API 连接 */
export function testRagflowConnection(): Promise<{ data: { code: number; data: { success: boolean; message: string }; message: string } }> {
return apiClient.post('/admin/integrations/ragflow/test')
}
/** 列出 RAGFlow 知识库(数据集) */
export function getRagflowDatasets(params?: {
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { items: any[]; total: number }; message: string } }> {
return apiClient.get('/admin/integrations/ragflow/datasets', { params })
}
/** RAGFlow 知识检索测试 */
export function ragflowRetrieval(params: {
question: string
dataset_ids?: string
top_k?: number
}): Promise<{ data: { code: number; data: { chunks: any[]; doc_aggs: any[]; total: number }; message: string } }> {
return apiClient.post('/admin/integrations/ragflow/retrieval', null, { params })
}
/** 联软终端详细信息 */
export function getLianruanTerminalDetail(
devname: string
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get(`/admin/integrations/lianruan/terminals/${encodeURIComponent(devname)}/detail`)
}
// ==========================================================================
// 快速回复审核
// ==========================================================================
/** 获取待审核模板列表 */
export function getPendingQuickReplies(
category?: string
): Promise<{ data: { code: number; data: { items: QuickReplyTemplate[] }; message: string } }> {
const params: Record<string, string> = {}
if (category && category !== '全部') {
params.category = category
}
return apiClient.get('/admin/quick-replies/pending', { params })
}
/** 审核快速回复模板(通过/驳回) */
export function reviewQuickReply(
id: string,
action: string,
reason?: string
): Promise<{ data: { code: number; data: ReviewQuickReplyResponse; message: string } }> {
const body: ReviewQuickReplyRequest = { action: action as ReviewQuickReplyRequest['action'], reason }
return apiClient.put(`/admin/quick-replies/${id}/review`, body)
}
// ==========================================================================
// 消息分配模式
// ==========================================================================
/** 获取当前分配模式 */
export function getAssignmentMode(): Promise<{ data: { code: number; data: AssignmentModeConfig; message: string } }> {
return apiClient.get('/admin/assignment-mode')
}
/** 切换分配模式 */
export function updateAssignmentMode(
mode: string
): Promise<{ data: { code: number; data: AssignmentModeConfig; message: string } }> {
const body: UpdateAssignmentModeRequest = { mode }
return apiClient.put('/admin/assignment-mode', body)
}
// ==========================================================================
// 会话监控
// ==========================================================================
/** 获取实时会话列表(Demo预览) */
export function getMonitorSessions(
status?: string
): Promise<{ data: { code: number; data: MonitorSessionsData; message: string } }> {
const params: Record<string, string> = {}
if (status) {
params.status = status
}
return apiClient.get('/admin/monitor/sessions', { params })
}
// ==========================================================================
// 全局搜索
// ==========================================================================
/** 搜索配置项、坐席、快速回复 */
export function globalSearch(
query: string
): Promise<{ data: { code: number; data: SearchResults; message: string } }> {
return apiClient.get('/admin/search', { params: { q: query } })
}
// ==========================================================================
// 角色管理
// ==========================================================================
/** 获取所有角色列表(含用户数量统计) */
export function getRoles(): Promise<{ data: { code: number; data: Role[]; message: string } }> {
return apiClient.get('/admin/roles')
}
/** 手动分配角色给用户 */
export function assignRole(
data: RoleAssignRequest
): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.post('/admin/roles/assign', data)
}
/** 撤销用户角色 */
export function revokeRole(
data: RoleRevokeRequest
): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.post('/admin/roles/revoke', data)
}
/** 获取所有角色映射规则 */
export function getRoleMappingRules(): Promise<{ data: { code: number; data: RoleMappingRule[]; message: string } }> {
return apiClient.get('/admin/roles/mapping-rules')
}
/** 创建角色映射规则 */
export function createRoleMappingRule(
data: RoleMappingRuleRequest
): Promise<{ data: { code: number; data: { id: string }; message: string } }> {
return apiClient.post('/admin/roles/mapping-rules', data)
}
/** 删除角色映射规则 */
export function deleteRoleMappingRule(
ruleId: string
): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.delete(`/admin/roles/mapping-rules/${ruleId}`)
}
// ==========================================================================
// P2: 会话审计
// ==========================================================================
/** 获取会话审计列表 */
export function getAuditConversations(params?: {
status?: string
agent_id?: string
keyword?: string
date_from?: string
date_to?: string
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/audit/conversations', { params })
}
/** 获取会话审计详情(含消息列表) */
export function getAuditConversationDetail(
conversationId: string
): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.get(`/admin/audit/conversations/${conversationId}`)
}
// ==========================================================================
// P2: 坐席绩效统计
// ==========================================================================
/** 获取坐席绩效统计 */
export function getAgentPerformance(params?: {
date_from?: string
date_to?: string
}): Promise<{ data: { code: number; data: { items: any[] }; message: string } }> {
return apiClient.get('/admin/agent-performance', { params })
}
// ==========================================================================
// P2: 系统日志
// ==========================================================================
/** 获取系统日志(配置变更日志) */
export function getSystemLogs(params?: {
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/system-logs', { params })
}
+118
View File
@@ -0,0 +1,118 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 Axios 实例与拦截器
// =============================================================================
// 说明:创建 Axios 实例,配置:
// 1. 请求基础 URL
// 2. 请求拦截器(添加管理员认证头)
// 3. 响应拦截器(统一错误处理)
// 与坐席端区别:使用 admin_token 而非 agent_token
import axios from 'axios'
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
// ElementPlus 消息提示
import { ElMessage } from 'element-plus'
// --------------------------------------------------------------------------
// 创建 Axios 实例
// --------------------------------------------------------------------------
const apiClient: AxiosInstance = axios.create({
// 基础 URL:所有请求会自动加上这个前缀
// 开发环境通过 Vite proxy 转发到后端
baseURL: '/api',
// 请求超时时间(10秒)
timeout: 10000,
// 默认请求头
headers: {
'Content-Type': 'application/json',
},
})
// --------------------------------------------------------------------------
// 请求拦截器
// --------------------------------------------------------------------------
// 在每个请求发送前执行,用于添加管理员认证信息
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取管理员 token,添加到请求头
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// 请求配置错误时直接返回
return Promise.reject(error)
}
)
// --------------------------------------------------------------------------
// 响应拦截器
// --------------------------------------------------------------------------
// 在每个响应返回后执行,用于统一处理错误
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// 从响应中提取业务数据
const res = response.data
// 统一响应格式:{code: 0, data: {}, message: "success"}
// code === 0 表示业务成功
if (res.code !== 0) {
// 业务错误:显示错误消息
ElMessage.error(res.message || '请求失败')
// 特殊错误码处理
if (res.code === 1002) {
// 未授权:清除 token 并跳转到登录页
localStorage.removeItem('admin_token')
// 动态导入避免循环依赖
import('@/router').then((router) => {
router.default.push('/login')
})
}
// 返回 rejected Promise,让调用方的 catch 能捕获
return Promise.reject(new Error(res.message || '请求失败'))
}
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
return response
},
(error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)
let message = '网络异常,请稍后重试'
if (error.response) {
// 服务器返回了错误状态码
switch (error.response.status) {
case 401:
message = '未授权,请重新登录'
// 清除 token
localStorage.removeItem('admin_token')
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = `请求失败 (${error.response.status})`
}
} else if (error.code === 'ECONNABORTED') {
// 请求超时
message = '请求超时,请稍后重试'
}
// 显示错误提示
ElMessage.error(message)
return Promise.reject(error)
}
)
// 导出 Axios 实例,供 API 模块使用
export default apiClient
@@ -0,0 +1,204 @@
<!--
=============================================================================
企微IT智能服务台 坐席列表表格组件
=============================================================================
说明管理后台坐席列表表格使用 Element Plus el-table
功能显示坐席信息状态筛选角色/技能标签编辑操作
-->
<template>
<div class="table-wrapper">
<el-table
:data="agents"
style="width: 100%"
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px', fontWeight: '500' }"
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
row-class-name="agent-table-row"
v-loading="loading"
element-loading-text="正在加载坐席数据..."
>
<!-- 坐席列 -->
<el-table-column label="坐席" min-width="180">
<template #default="{ row }">
<div class="agent-info">
<div class="agent-avatar" :style="{ background: getAvatarBg(row) }">
{{ row.name.charAt(0) }}
</div>
<div>
<div class="agent-name">{{ row.name }}</div>
<div class="agent-id">{{ row.user_id }}</div>
</div>
</div>
</template>
</el-table-column>
<!-- 状态列 -->
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small" effect="dark">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<!-- 技能标签列 -->
<el-table-column label="技能标签" min-width="180">
<template #default="{ row }">
<div class="tag-group">
<el-tag
v-for="tag in row.skill_tags"
:key="tag"
size="small"
effect="plain"
>
{{ tag }}
</el-tag>
<span v-if="!row.skill_tags || row.skill_tags.length === 0" class="text-muted">未设置</span>
</div>
</template>
</el-table-column>
<!-- 角色列 -->
<el-table-column label="角色" width="80">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'warning' : 'info'" size="small">
{{ row.role === 'admin' ? '组长' : '坐席' }}
</el-tag>
</template>
</el-table-column>
<!-- OTP列 -->
<el-table-column label="OTP" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.otp_enabled === 1" type="success" size="small">
已启用
</el-tag>
<el-tag v-else-if="row.otp_secret" type="warning" size="small">
未验证
</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<!-- 负载列 -->
<el-table-column label="当前/最大负载" width="110" align="center">
<template #default="{ row }">
<span>{{ row.current_load }} / {{ row.max_load }}</span>
</template>
</el-table-column>
<!-- 今日结单列 -->
<el-table-column label="今日结单" width="90" align="center">
<template #default="{ row }">
{{ row.today_resolved ?? 0 }}
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="$emit('edit', row)">
编辑
</el-button>
<el-button size="small" text type="danger" @click="$emit('delete', row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import type { Agent } from '@/types'
// ==========================================================================
// Props / Emits
// ==========================================================================
defineProps<{
/** 坐席数据列表 */
agents: Agent[]
/** 是否正在加载 */
loading?: boolean
}>()
defineEmits<{
/** 编辑坐席 */
edit: [agent: Agent]
/** 移除坐席 */
delete: [agent: Agent]
}>()
// ==========================================================================
// 工具方法
// ==========================================================================
/** 获取状态标签类型 */
function getStatusType(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'online': return 'success'
case 'busy': return 'warning'
case 'offline': return 'info'
default: return 'info'
}
}
/** 获取状态文本 */
function getStatusText(status: string): string {
switch (status) {
case 'online': return '在线'
case 'busy': return '忙碌'
case 'offline': return '离线'
default: return status
}
}
/** 获取头像背景色 */
function getAvatarBg(row: Agent): string {
if (row.role === 'admin') return 'var(--accent-light)'
return 'rgba(16, 185, 129, 0.12)'
}
</script>
<style scoped>
/* 坐席信息 */
.agent-info {
display: flex;
align-items: center;
gap: 10px;
}
.agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.agent-name {
font-weight: 500;
color: var(--text-primary);
}
.agent-id {
font-size: 11px;
color: var(--text-muted);
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
</style>
<style>
/* 全局表行悬停样式 */
.agent-table-row:hover td {
background-color: var(--bg-tertiary) !important;
}
</style>
@@ -0,0 +1,27 @@
<!--
=============================================================================
企微IT智能服务台 面包屑导航组件
=============================================================================
说明根据当前路由 meta.title 动态生成面包屑导航
格式管理后台 / 当前页面标题
-->
<template>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">管理后台</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { computed } from 'vue'
import { useRoute } from 'vue-router'
// ==========================================================================
// 当前页面标题
// ==========================================================================
const route = useRoute()
const currentTitle = computed(() => (route.meta.title as string) || '')
</script>
@@ -0,0 +1,195 @@
<!--
=============================================================================
企微IT智能服务台 配置分组卡片组件
=============================================================================
说明配置分组卡片显示组名描述每项配置支持
- 布尔值使用 el-switch 开关
- 数值使用 el-input-number
- JSON 数组/字符串显示值 + 编辑按钮
-->
<template>
<el-card class="config-group-card" shadow="never">
<!-- 卡片头部 -->
<template #header>
<div class="config-group-header">
<div class="config-group-title">
<el-icon v-if="icon" :size="16" :style="{ color: iconColor || 'var(--accent)', marginRight: '6px' }">
<component :is="icon" />
</el-icon>
{{ group.name }}
</div>
<el-tag v-if="statusTag" :type="statusTag.type" size="small">
{{ statusTag.text }}
</el-tag>
</div>
<div v-if="groupDescription" class="config-group-desc">{{ groupDescription }}</div>
</template>
<!-- 配置项列表 -->
<div class="config-items">
<div
v-for="item in group.items"
:key="item.key"
class="config-item"
>
<div class="config-item-info">
<span class="config-item-name">{{ item.description }}</span>
<span v-if="itemStage" class="config-item-stage">{{ itemStage }}</span>
</div>
<div class="config-item-control">
<!-- 布尔值开关 -->
<el-switch
v-if="item.value_type === 'boolean'"
:model-value="item.value === 'true'"
@change="(val: boolean) => $emit('update', item.key, val ? 'true' : 'false')"
/>
<!-- 数值输入框 -->
<el-input-number
v-else-if="item.value_type === 'number'"
:model-value="Number(item.value)"
:min="0"
:max="999"
size="small"
controls-position="right"
@change="(val: number | undefined) => $emit('update', item.key, String(val ?? 0))"
style="width: 120px"
/>
<!-- JSON 数组/字符串代码显示 + 编辑按钮 -->
<template v-else>
<code class="config-json-preview">{{ truncateValue(item.value) }}</code>
<el-button size="small" text type="primary" @click="$emit('edit', item)">
编辑
</el-button>
</template>
</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import type { ConfigGroup } from '@/types'
// ==========================================================================
// Props / Emits
// ==========================================================================
defineProps<{
/** 配置分组数据 */
group: ConfigGroup
/** 分组图标名称 */
icon?: string
/** 图标颜色 */
iconColor?: string
/** 分组描述 */
groupDescription?: string
/** 状态标签 */
statusTag?: {
type: 'success' | 'warning' | 'danger' | 'info'
text: string
}
/** 配置项阶段标签 */
itemStage?: string
}>()
defineEmits<{
/** 配置值更新 */
update: [key: string, value: string]
/** 编辑 JSON 值 */
edit: [item: ConfigGroup['items'][0]]
}>()
// ==========================================================================
// 工具方法
// ==========================================================================
/** 截断过长的配置值显示 */
function truncateValue(value: string): string {
if (value.length > 40) {
return value.substring(0, 40) + '...'
}
return value
}
</script>
<style scoped>
/* 配置分组卡片 */
.config-group-card {
background-color: var(--bg-secondary);
border-color: var(--border);
}
/* 卡片头部 */
.config-group-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-group-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.config-group-desc {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
}
/* 配置项列表 */
.config-items {
display: flex;
flex-direction: column;
}
/* 单个配置项 */
.config-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.config-item:first-child {
border-top: none;
padding-top: 0;
}
/* 配置项信息 */
.config-item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.config-item-name {
font-size: 13px;
color: var(--text-primary);
}
.config-item-stage {
font-size: 11px;
color: var(--text-muted);
}
/* 配置项控件 */
.config-item-control {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* JSON 预览 */
.config-json-preview {
font-size: 11px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,199 @@
<!--
=============================================================================
企微IT智能服务台 集成系统卡片组件
=============================================================================
说明外部系统集成卡片显示系统名称图标状态指示灯配置按钮
可配置的系统Dify/RAGFlow显示"配置"按钮
不可配置的系统仅显示状态和描述
-->
<template>
<div class="integration-card" :class="{ 'is-configurable': integration.configurable }">
<!-- 系统图标 -->
<div
class="integration-icon"
:style="{ background: iconBg, color: iconColor }"
>
<el-icon :size="22">
<component :is="icon" />
</el-icon>
</div>
<!-- 系统名称 -->
<div class="integration-name">{{ integration.name }}</div>
<!-- 状态指示灯 -->
<div class="integration-status">
<el-tag :type="statusTagType" size="small" effect="dark">
{{ statusText }}
</el-tag>
</div>
<!-- 描述信息 -->
<div class="integration-detail">
<slot name="detail">
<template v-if="integration.configurable && integration.config">
<!-- url_key 模式Dify / RAGFlow -->
<template v-if="integration.config_type === 'url_key' || !integration.config_type">
<div v-if="integration.config.api_url" class="config-info">API: {{ integration.config.api_url }}</div>
<div class="config-info">{{ integration.config.api_key_set ? '密钥已配置' : '密钥未配置' }}</div>
</template>
<!-- access_key 模式火绒安全 -->
<template v-else-if="integration.config_type === 'access_key'">
<div v-if="integration.config.base_url" class="config-info">URL: {{ integration.config.base_url }}</div>
<div class="config-info">{{ integration.config.access_key_id_set ? 'AccessKey 已配置' : 'AccessKey 未配置' }}</div>
</template>
<!-- account_password 模式联软LV7000 -->
<template v-else-if="integration.config_type === 'account_password'">
<div v-if="integration.config.base_url" class="config-info">URL: {{ integration.config.base_url }}</div>
<div class="config-info">{{ integration.config.api_account_set ? '账号已配置' : '账号未配置' }}</div>
</template>
</template>
</slot>
</div>
<!-- 操作按钮 -->
<div class="integration-actions">
<el-button
v-if="integration.configurable"
size="small"
type="primary"
@click="$emit('configure', integration)"
>
配置
</el-button>
<el-button
v-if="integration.configurable"
size="small"
@click="$emit('test', integration)"
>
测试
</el-button>
<el-button
v-else
size="small"
@click="$emit('view', integration)"
>
查看
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { computed } from 'vue'
import type { Integration } from '@/types'
// ==========================================================================
// Props / Emits
// ==========================================================================
const props = defineProps<{
/** 集成系统数据 */
integration: Integration
/** 图标组件名 */
icon: string
/** 图标背景色 */
iconBg: string
/** 图标颜色 */
iconColor: string
}>()
defineEmits<{
/** 配置按钮点击 */
configure: [integration: Integration]
/** 测试按钮点击 */
test: [integration: Integration]
/** 查看按钮点击 */
view: [integration: Integration]
}>()
// ==========================================================================
// 计算属性
// ==========================================================================
/** 状态标签类型 */
const statusTagType = computed(() => {
switch (props.integration.status) {
case 'connected': return 'success'
case 'partial': return 'warning'
case 'disconnected': return 'info'
case 'pending': return 'info'
default: return 'info'
}
})
/** 状态文本 */
const statusText = computed(() => {
switch (props.integration.status) {
case 'connected': return '已连接'
case 'partial': return '部分集成'
case 'disconnected': return '未连接'
case 'pending': return '待确认'
default: return props.integration.status
}
})
</script>
<style scoped>
/* 集成卡片 */
.integration-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
text-align: center;
transition: border-color 0.2s;
}
.integration-card:hover {
border-color: var(--border-hover);
}
.integration-card.is-configurable {
border-color: var(--accent);
border-opacity: 0.3;
}
/* 图标 */
.integration-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 12px;
}
/* 名称 */
.integration-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
/* 状态 */
.integration-status {
margin-bottom: 12px;
}
/* 详情 */
.integration-detail {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 12px;
min-height: 32px;
}
.config-info {
margin-bottom: 2px;
}
/* 操作按钮 */
.integration-actions {
display: flex;
justify-content: center;
gap: 8px;
}
</style>
@@ -0,0 +1,170 @@
<!--
=============================================================================
企微IT智能服务台 快速回复卡片组件
=============================================================================
说明快速回复模板卡片显示分类标题内容状态
对于待审核模板显示通过/驳回操作按钮
-->
<template>
<div class="reply-card" :class="{ 'is-pending': reply.status === 'pending_review' }">
<!-- 卡片头部 -->
<div class="reply-card-header">
<div class="reply-card-title">
<el-tag size="small" effect="plain" style="margin-right: 8px">
{{ reply.category }}
</el-tag>
{{ reply.title }}
</div>
<div class="reply-card-status">
<!-- 已审核 -->
<el-tag v-if="reply.status === 'approved'" type="success" size="small">
已审核
</el-tag>
<!-- 待审核 -->
<template v-else-if="reply.status === 'pending_review'">
<div class="review-actions">
<el-button size="small" type="primary" @click="$emit('approve', reply)">
通过
</el-button>
<el-button size="small" @click="$emit('reject', reply)">
驳回
</el-button>
</div>
</template>
<!-- 已驳回 -->
<el-tag v-else-if="reply.status === 'rejected'" type="danger" size="small">
已驳回
</el-tag>
<!-- 草稿 -->
<el-tag v-else type="info" size="small">
草稿
</el-tag>
</div>
</div>
<!-- 内容预览 -->
<div class="reply-card-content">
{{ reply.content }}
</div>
<!-- 底部信息 -->
<div class="reply-card-footer">
<template v-if="reply.variables && reply.variables.length > 0">
<span class="footer-label">变量:</span>
<span class="footer-value">{{ reply.variables.join(', ') }}</span>
<span class="footer-sep">·</span>
</template>
<template v-if="reply.submitted_by_name">
<span class="footer-label">提交人:</span>
<span class="footer-value">{{ reply.submitted_by_name }}</span>
<span class="footer-sep">·</span>
</template>
<span class="footer-label">版本:</span>
<span class="footer-value">v{{ reply.version }}</span>
<span class="footer-sep">·</span>
<span class="footer-label">更新:</span>
<span class="footer-value">{{ formatDate(reply.updated_at) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import type { QuickReplyTemplate } from '@/types'
// ==========================================================================
// Props / Emits
// ==========================================================================
defineProps<{
/** 快速回复模板数据 */
reply: QuickReplyTemplate
}>()
defineEmits<{
/** 通过审核 */
approve: [reply: QuickReplyTemplate]
/** 驳回 */
reject: [reply: QuickReplyTemplate]
}>()
// ==========================================================================
// 工具方法
// ==========================================================================
/** 格式化日期 */
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
try {
const d = new Date(dateStr)
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
} catch {
return dateStr
}
}
</script>
<style scoped>
/* 卡片 */
.reply-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
transition: border-color 0.2s;
}
.reply-card:hover {
border-color: var(--border-hover);
}
.reply-card.is-pending {
border-color: var(--warning);
}
/* 卡片头部 */
.reply-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.reply-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
}
/* 内容预览 */
.reply-card-content {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
background: var(--bg-primary);
padding: 10px;
border-radius: 6px;
margin-bottom: 8px;
}
/* 底部信息 */
.reply-card-footer {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
}
.footer-label {
color: var(--text-muted);
}
.footer-value {
color: var(--text-secondary);
}
.footer-sep {
margin: 0 4px;
color: var(--text-muted);
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<!--
=============================================================================
企微IT智能服务台 全局搜索组件
=============================================================================
说明全局搜索组件支持搜索配置项坐席快速回复
回车或点搜索图标触发搜索结果以下拉面板展示
-->
<template>
<div class="search-box-wrapper">
<el-popover
:visible="showResults"
placement="bottom-end"
:width="320"
trigger="manual"
:popper-style="{ background: 'var(--bg-secondary)', border: '1px solid var(--border)' }"
:show-arrow="false"
>
<template #reference>
<div class="search-box" @click="showResults = searchText.length > 0">
<el-icon :size="14" style="color: var(--text-muted)"><Search /></el-icon>
<input
v-model="searchText"
type="text"
placeholder="搜索功能或配置..."
class="search-input"
@keydown.enter="handleSearch"
@input="handleInput"
/>
<el-icon
v-if="searchText"
:size="14"
style="color: var(--text-muted); cursor: pointer"
@click="clearSearch"
>
<Close />
</el-icon>
</div>
</template>
<!-- 搜索结果 -->
<div class="search-results">
<div v-if="loading" class="search-loading">搜索中...</div>
<template v-else-if="results.length > 0">
<div class="search-result-count">找到 {{ results.length }} 条结果</div>
<div
v-for="item in results"
:key="item.id"
class="search-result-item"
@click="navigateTo(item.route)"
>
<el-tag :type="getResultTagType(item.type)" size="small" effect="plain">
{{ getResultTypeText(item.type) }}
</el-tag>
<span class="result-name">{{ item.name }}</span>
</div>
</template>
<div v-else-if="searched" class="search-empty">暂无匹配结果</div>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { SearchResultItem } from '@/types'
import { globalSearch } from '@/api/admin'
// ==========================================================================
// 路由
// ==========================================================================
const router = useRouter()
// ==========================================================================
// 状态
// ==========================================================================
/** 搜索文本 */
const searchText = ref<string>('')
/** 是否显示搜索结果 */
const showResults = ref<boolean>(false)
/** 搜索结果 */
const results = ref<SearchResultItem[]>([])
/** 是否已搜索过 */
const searched = ref<boolean>(false)
/** 是否正在加载 */
const loading = ref<boolean>(false)
// ==========================================================================
// 方法
// ==========================================================================
/** 执行搜索 */
async function handleSearch(): Promise<void> {
const query = searchText.value.trim()
if (!query) {
clearSearch()
return
}
loading.value = true
searched.value = true
showResults.value = true
try {
const response = await globalSearch(query)
results.value = response.data.data.items
} catch {
results.value = []
} finally {
loading.value = false
}
}
/** 输入处理 */
function handleInput(): void {
if (searchText.value.length > 0) {
showResults.value = searched.value
} else {
showResults.value = false
results.value = []
searched.value = false
}
}
/** 清除搜索 */
function clearSearch(): void {
searchText.value = ''
showResults.value = false
results.value = []
searched.value = false
}
/** 导航到搜索结果 */
function navigateTo(routePath: string): void {
showResults.value = false
router.push(routePath)
}
/** 获取结果标签类型 */
function getResultTagType(type: string): string {
switch (type) {
case 'config': return 'primary'
case 'agent': return 'success'
case 'quick_reply': return 'warning'
default: return 'info'
}
}
/** 获取结果类型文本 */
function getResultTypeText(type: string): string {
switch (type) {
case 'config': return '配置'
case 'agent': return '坐席'
case 'quick_reply': return '回复'
default: return type
}
}
</script>
<style scoped>
/* 搜索框容器 */
.search-box-wrapper {
position: relative;
}
/* 搜索框 */
.search-box {
display: flex;
align-items: center;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0 10px;
gap: 6px;
}
.search-box:focus-within {
border-color: var(--accent);
}
/* 输入框 */
.search-input {
background: transparent;
border: none;
color: var(--text-primary);
padding: 6px 0;
font-size: 13px;
outline: none;
width: 180px;
}
.search-input::placeholder {
color: var(--text-muted);
}
/* 搜索结果 */
.search-results {
max-height: 300px;
overflow-y: auto;
}
.search-loading,
.search-empty {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.search-result-count {
padding: 8px 12px;
font-size: 11px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.search-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--bg-tertiary);
}
.result-name {
font-size: 13px;
color: var(--text-primary);
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<!--
=============================================================================
企微IT智能服务台 侧边栏导航组件
=============================================================================
说明管理后台侧边栏导航深色背景菜单分组展示
- 📋 运营管理运营总览功能开关坐席管理
- 🔗 系统集成系统集成
- 运营配置快速回复分配模式排查流程图
- 📊 监控与数据会话监控
- 🔒 开发中P2占位灰化+锁图标主题模板数据看板知识库管理
-->
<template>
<div class="sidebar">
<!-- Logo 区域 -->
<div class="sidebar-logo">
<div class="sidebar-logo-icon">
<el-icon :size="16"><Headset /></el-icon>
</div>
<div>
<div class="sidebar-logo-text">IT智能服务台</div>
<div class="sidebar-logo-sub">管理后台 v1.0</div>
</div>
</div>
<!-- 导航菜单 -->
<el-menu
:default-active="activeMenu"
:router="true"
:collapse="false"
class="sidebar-menu"
background-color="var(--bg-secondary)"
text-color="var(--text-secondary)"
active-text-color="var(--accent)"
>
<!-- 📋 运营管理 -->
<div class="menu-section-title">📋 运营管理</div>
<el-menu-item index="/dashboard">
<el-icon><PieChart /></el-icon>
<span>运营总览</span>
</el-menu-item>
<el-menu-item index="/configs">
<el-icon><Switch /></el-icon>
<span>功能开关</span>
</el-menu-item>
<el-menu-item index="/agents">
<el-icon><UserFilled /></el-icon>
<span>坐席管理</span>
</el-menu-item>
<el-menu-item index="/roles">
<el-icon><Key /></el-icon>
<span>角色管理</span>
</el-menu-item>
<!-- 🔗 系统集成 -->
<div class="menu-section-title">🔗 系统集成</div>
<el-menu-item index="/integrations">
<el-icon><Connection /></el-icon>
<span>系统集成</span>
</el-menu-item>
<el-menu-item index="/terminal-security">
<el-icon><Warning /></el-icon>
<span>终端安全</span>
</el-menu-item>
<!-- 运营配置 -->
<div class="menu-section-title"> 运营配置</div>
<el-menu-item index="/quick-replies">
<el-icon><ChatLineSquare /></el-icon>
<span>快速回复</span>
</el-menu-item>
<el-menu-item index="/assignment-mode">
<el-icon><Sort /></el-icon>
<span>分配模式</span>
</el-menu-item>
<el-menu-item index="/flowcharts">
<el-icon><Share /></el-icon>
<span>排查流程图</span>
</el-menu-item>
<!-- 📊 监控与数据 -->
<div class="menu-section-title">📊 监控与数据</div>
<el-menu-item index="/monitor">
<el-icon><Monitor /></el-icon>
<span>会话监控</span>
</el-menu-item>
<el-menu-item index="/session-audit">
<el-icon><Document /></el-icon>
<span>会话审计</span>
</el-menu-item>
<el-menu-item index="/agent-performance">
<el-icon><TrendCharts /></el-icon>
<span>坐席绩效</span>
</el-menu-item>
<el-menu-item index="/system-logs">
<el-icon><Notebook /></el-icon>
<span>系统日志</span>
</el-menu-item>
<!-- 🔒 开发中P2占位 -->
<div class="menu-section-title">🔒 开发中</div>
<el-menu-item index="/themes" class="locked-menu-item">
<el-icon><Brush /></el-icon>
<span>主题模板</span>
<el-icon class="lock-icon"><Lock /></el-icon>
</el-menu-item>
<el-menu-item index="/reports" class="locked-menu-item">
<el-icon><DataAnalysis /></el-icon>
<span>数据看板</span>
<el-icon class="lock-icon"><Lock /></el-icon>
</el-menu-item>
<el-menu-item index="/knowledge" class="locked-menu-item">
<el-icon><Reading /></el-icon>
<span>知识库管理</span>
<el-icon class="lock-icon"><Lock /></el-icon>
</el-menu-item>
</el-menu>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Headset, PieChart, Switch, UserFilled, Connection, Warning, ChatLineSquare, Sort, Share, Monitor, Brush, DataAnalysis, Reading, Lock, Key, Document, TrendCharts, Notebook } from '@element-plus/icons-vue'
// ==========================================================================
// 当前激活菜单
// ==========================================================================
const route = useRoute()
const activeMenu = computed(() => route.path)
</script>
<style scoped>
/* 侧边栏容器 */
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
/* Logo 区域 */
.sidebar-logo {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.sidebar-logo-text {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.sidebar-logo-sub {
font-size: 10px;
color: var(--text-muted);
}
/* 菜单容器 */
.sidebar-menu {
flex: 1;
overflow-y: auto;
border-right: none;
}
/* 菜单分组标题 */
.menu-section-title {
padding: 12px 20px 6px;
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.5px;
user-select: none;
}
/* 菜单项 */
.sidebar-menu .el-menu-item {
height: 40px;
line-height: 40px;
font-size: 13px;
border-left: 3px solid transparent;
margin: 0;
}
.sidebar-menu .el-menu-item.is-active {
background-color: var(--accent-light);
border-left-color: var(--accent);
}
/* 锁定的菜单项(灰化) */
.locked-menu-item {
opacity: 0.5;
pointer-events: auto !important;
}
.lock-icon {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<!--
=============================================================================
企微IT智能服务台 统计卡片组件
=============================================================================
说明深色背景统计卡片显示图标数值标签趋势
用于仪表盘会话监控等页面的统计数据展示
-->
<template>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-label">{{ label }}</span>
<el-icon v-if="icon" :size="20" :style="{ color: iconColor || 'var(--text-secondary)' }">
<component :is="icon" />
</el-icon>
</div>
<div class="stat-value" :style="{ color: valueColor || 'var(--text-primary)' }">
<slot name="value">
{{ formattedValue }}
</slot>
</div>
<div v-if="trend || subtitle" class="stat-footer" :class="trendClass">
<slot name="footer">
<span v-if="trend">{{ trend }}</span>
<span v-if="subtitle" class="stat-subtitle">{{ subtitle }}</span>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// Props 定义
// ==========================================================================
import { computed } from 'vue'
const props = defineProps<{
/** 统计卡片标签 */
label: string
/** 统计数值 */
value?: string | number
/** 数值颜色 */
valueColor?: string
/** 图标组件名 */
icon?: string
/** 图标颜色 */
iconColor?: string
/** 趋势文本 */
trend?: string
/** 趋势方向:up/down */
trendDirection?: 'up' | 'down' | 'none'
/** 副标题 */
subtitle?: string
}>()
// ==========================================================================
// 计算属性
// ==========================================================================
/** 格式化后的数值 */
const formattedValue = computed(() => props.value ?? '-')
/** 趋势样式类 */
const trendClass = computed(() => {
if (props.trendDirection === 'up') return 'trend-up'
if (props.trendDirection === 'down') return 'trend-down'
return ''
})
</script>
<style scoped>
/* 统计卡片 */
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color 0.2s;
}
.stat-card:hover {
border-color: var(--border-hover);
}
/* 卡片头部(标签 + 图标) */
.stat-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
/* 标签 */
.stat-label {
font-size: 12px;
color: var(--text-secondary);
}
/* 数值 */
.stat-value {
font-size: 28px;
font-weight: 700;
margin-bottom: 4px;
}
/* 尾部 */
.stat-footer {
font-size: 11px;
}
/* 趋势 */
.trend-up {
color: var(--success);
}
.trend-down {
color: var(--danger);
}
.stat-subtitle {
color: var(--text-muted);
}
</style>
@@ -0,0 +1,90 @@
<!--
=============================================================================
企微IT智能服务台 管理后台主布局
=============================================================================
说明管理后台主布局组件
左侧深色侧边栏Sidebar 组件
右侧顶部栏面包屑 + 用户信息+ 内容区router-view
-->
<template>
<div class="admin-layout">
<!-- 左侧侧边栏 -->
<Sidebar />
<!-- 右侧主内容区 -->
<div class="main-content">
<!-- 顶部栏 -->
<div class="top-bar">
<div class="top-bar-left">
<Breadcrumb />
</div>
<div class="top-bar-right">
<!-- 全局搜索 -->
<SearchBox />
<!-- 通知图标 -->
<el-icon :size="18" style="color: var(--text-secondary); cursor: pointer">
<Bell />
</el-icon>
<!-- 管理员头像 -->
<el-dropdown trigger="click" @command="handleUserCommand">
<div class="user-avatar" style="cursor: pointer">
{{ adminStore.adminName.charAt(0) || '管' }}
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
{{ adminStore.adminName || '管理员' }}
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 内容区 -->
<div class="content-scroll">
<router-view />
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 组件导入
// ==========================================================================
import Sidebar from '@/components/Sidebar.vue'
import Breadcrumb from '@/components/Breadcrumb.vue'
import SearchBox from '@/components/SearchBox.vue'
import { useAdminStore } from '@/stores/admin'
import { ElMessageBox } from 'element-plus'
// ==========================================================================
// Store
// ==========================================================================
const adminStore = useAdminStore()
// ==========================================================================
// 用户下拉菜单命令处理
// ==========================================================================
function handleUserCommand(command: string): void {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出管理后台吗?', '退出确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
adminStore.logout()
}).catch(() => {
// 取消退出
})
}
}
</script>
+52
View File
@@ -0,0 +1,52 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台应用入口
// =============================================================================
// 说明:Vue3 应用入口文件,负责:
// 1. 创建 Vue 应用实例
// 2. 注册 ElementPlus 组件库
// 3. 注册 Pinia 状态管理
// 4. 注册 Vue Router 路由
// 5. 注册全局图标组件
// 6. 挂载到 DOM
import { createApp } from 'vue'
// 根组件
import App from './App.vue'
// 路由配置
import router from './router'
// Pinia 状态管理
import { createPinia } from 'pinia'
// ElementPlus 组件库
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// ElementPlus 中文语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// ElementPlus 图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 全局样式
import './styles/global.css'
// 创建 Vue 应用实例
const app = createApp(App)
// --------------------------------------------------------------------------
// 注册 ElementPlus 图标组件(全局注册,模板中可直接使用)
// --------------------------------------------------------------------------
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// --------------------------------------------------------------------------
// 注册插件
// --------------------------------------------------------------------------
// Pinia: 状态管理(管理员信息、配置项、坐席管理等)
app.use(createPinia())
// Vue Router: 路由管理(页面跳转)
app.use(router)
// ElementPlus: UI 组件库(表格、表单、对话框等)+ 中文语言包
app.use(ElementPlus, { locale: zhCn })
// --------------------------------------------------------------------------
// 挂载应用到 DOM
// --------------------------------------------------------------------------
app.mount('#app')
+184
View File
@@ -0,0 +1,184 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台路由配置
// =============================================================================
// 说明:定义管理后台页面路由映射,包含 admin 权限路由守卫
import { createRouter, createWebHistory } from 'vue-router'
// --------------------------------------------------------------------------
// 路由配置
// --------------------------------------------------------------------------
const routes = [
{
// 登录页面
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '管理员登录', requiresAuth: false },
},
{
// 管理后台主布局(需要认证)
path: '/',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{
// 根路径重定向到仪表盘
path: '',
redirect: '/dashboard',
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '运营总览', requiresAuth: true },
},
{
path: 'configs',
name: 'Configs',
component: () => import('@/views/Configs.vue'),
meta: { title: '功能开关', requiresAuth: true },
},
{
path: 'agents',
name: 'Agents',
component: () => import('@/views/Agents.vue'),
meta: { title: '坐席管理', requiresAuth: true },
},
{
path: 'roles',
name: 'Roles',
component: () => import('@/views/Roles.vue'),
meta: { title: '角色管理', requiresAuth: true },
},
{
path: 'integrations',
name: 'Integrations',
component: () => import('@/views/Integrations.vue'),
meta: { title: '系统集成', requiresAuth: true },
},
{
path: 'terminal-security',
name: 'TerminalSecurity',
component: () => import('@/views/TerminalSecurity.vue'),
meta: { title: '终端安全', requiresAuth: true },
},
{
path: 'quick-replies',
name: 'QuickReplies',
component: () => import('@/views/QuickReplies.vue'),
meta: { title: '快速回复', requiresAuth: true },
},
{
path: 'assignment-mode',
name: 'AssignmentMode',
component: () => import('@/views/AssignmentMode.vue'),
meta: { title: '分配模式', requiresAuth: true },
},
{
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/Monitor.vue'),
meta: { title: '会话监控', requiresAuth: true },
},
{
path: 'flowcharts',
name: 'Flowcharts',
component: () => import('@/views/Flowcharts.vue'),
meta: { title: '排查流程图', requiresAuth: true },
},
{
path: 'session-audit',
name: 'SessionAudit',
component: () => import('@/views/SessionAudit.vue'),
meta: { title: '会话审计', requiresAuth: true },
},
{
path: 'agent-performance',
name: 'AgentPerformance',
component: () => import('@/views/AgentPerformance.vue'),
meta: { title: '坐席绩效', requiresAuth: true },
},
{
path: 'system-logs',
name: 'SystemLogs',
component: () => import('@/views/SystemLogs.vue'),
meta: { title: '系统日志', requiresAuth: true },
},
{
// P2 占位页:主题模板
path: 'themes',
name: 'Themes',
component: () => import('@/views/Placeholder.vue'),
meta: { title: '主题模板', requiresAuth: true, comingSoon: true },
},
{
// P2 占位页:数据看板
path: 'reports',
name: 'Reports',
component: () => import('@/views/Placeholder.vue'),
meta: { title: '数据看板', requiresAuth: true, comingSoon: true },
},
{
// P2 占位页:知识库管理
path: 'knowledge',
name: 'Knowledge',
component: () => import('@/views/Placeholder.vue'),
meta: { title: '知识库管理', requiresAuth: true, comingSoon: true },
},
],
},
{
// 404 页面:捕获所有未匹配路由
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/Placeholder.vue'),
meta: { title: '页面未找到' },
},
]
// --------------------------------------------------------------------------
// 创建路由实例
// --------------------------------------------------------------------------
// createWebHistory: 使用 HTML5 History 模式,基础路径 /itadmin/(与IT数据平台共享域名)
const router = createRouter({
history: createWebHistory('/itadmin/'),
routes,
})
// --------------------------------------------------------------------------
// 路由守卫 — 检查管理员登录状态
// --------------------------------------------------------------------------
router.beforeEach((to, _from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - IT智能服务台管理后台`
}
// ===== 新增:处理 URL 中的 token 参数(从 Portal 跳转过来时) =====
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
if (urlToken) {
// 保存 token 到 localStorage
localStorage.setItem('admin_token', urlToken)
// 清除 URL 中的 token 参数,保持 URL 干净
window.history.replaceState({}, '', window.location.pathname)
}
// ===== token 处理结束 =====
// 检查是否需要认证
const requiresAuth = to.meta.requiresAuth !== false
const token = localStorage.getItem('admin_token')
if (requiresAuth && !token) {
// 需要认证但没有 token,跳转到登录页
next({ path: '/login', query: { redirect: to.fullPath } })
} else if (to.path === '/login' && token) {
// 已登录用户访问登录页,跳转到仪表盘
next({ path: '/dashboard' })
} else {
next()
}
})
export default router
+184
View File
@@ -0,0 +1,184 @@
// =============================================================================
// 企微IT智能服务台 — 管理员状态管理(Pinia Store
// =============================================================================
// 说明:管理管理员登录状态、当前管理员信息、权限校验
// 核心功能:
// 1. 当前管理员信息
// 2. 登录/登出方法
// 3. Admin 角色校验
// 4. Token 管理(localStorage.admin_token
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Agent } from '@/types'
import apiClient from '@/api/index'
import router from '@/router'
// --------------------------------------------------------------------------
// Token 存储 key(管理后台使用 admin_token,与坐席端 agent_token 隔离)
// --------------------------------------------------------------------------
const TOKEN_KEY = 'admin_token'
const ADMIN_USER_ID_KEY = 'admin_user_id'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useAdminStore = defineStore('admin', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 当前管理员信息 */
const adminInfo = ref<Agent | null>(null)
/** 认证 token */
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
/** 管理员用户ID */
const adminUserId = ref<string | null>(localStorage.getItem(ADMIN_USER_ID_KEY))
/** 是否正在登录 */
const logging = ref<boolean>(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 是否已登录 */
const isLoggedIn = computed(() => !!token.value && !!adminInfo.value)
/** 管理员姓名 */
const adminName = computed(() => adminInfo.value?.name || '')
/** 管理员用户ID */
const userId = computed(() => adminInfo.value?.user_id || adminUserId.value || '')
// ==========================================================================
// 方法
// ==========================================================================
/**
* 管理员登录
* 复用坐席端登录 APIPOST /api/agents/login),但额外校验 role === 'admin'
*
* @param inputUserId - 企微用户ID
* @param inputName - 管理员姓名
*/
async function login(inputUserId: string, inputName: string): Promise<void> {
logging.value = true
try {
const response = await apiClient.post('/agents/login', {
user_id: inputUserId,
name: inputName,
})
const data = response.data.data
const agentInfoData = data.agent_info || data
// 校验角色是否为管理员
if (agentInfoData.role !== 'admin') {
throw new Error('无管理权限:该账号非管理员角色')
}
// 保存登录信息
token.value = data.token || agentInfoData.token
adminUserId.value = inputUserId
localStorage.setItem(TOKEN_KEY, data.token || agentInfoData.token)
localStorage.setItem(ADMIN_USER_ID_KEY, inputUserId)
// 保存管理员信息
adminInfo.value = {
...agentInfoData,
role: 'admin',
} as Agent
// 跳转到仪表盘
router.push('/dashboard')
} catch (error: unknown) {
console.error('管理员登录失败:', error)
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
throw new Error(errMsg)
} finally {
logging.value = false
}
}
/**
* 管理员登出
* 清除本地存储的登录信息,跳转到登录页
*/
function logout(): void {
// 清除状态
token.value = null
adminUserId.value = null
adminInfo.value = null
// 清除 localStorage
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
// 跳转到登录页
router.push('/login')
}
/**
* 刷新当前管理员信息
* 从后端获取最新的管理员数据
*/
async function refreshAdminInfo(): Promise<void> {
try {
if (!token.value) return
const response = await apiClient.get('/agents/me')
adminInfo.value = response.data.data
} catch (error) {
console.error('获取管理员信息失败:', error)
// 如果是 401 未授权,说明 token 过期,需要重新登录
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { status?: number } }
if (axiosError.response?.status === 401) {
logout()
}
}
}
}
/**
* 初始化:检查是否已登录
* 如果 localStorage 有 token,尝试获取管理员信息
*/
async function initAuth(): Promise<void> {
const savedToken = localStorage.getItem(TOKEN_KEY)
if (savedToken) {
token.value = savedToken
adminUserId.value = localStorage.getItem(ADMIN_USER_ID_KEY)
try {
await refreshAdminInfo()
} catch {
// token 无效,清除
logout()
}
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
adminInfo,
token,
adminUserId,
logging,
// 计算属性
isLoggedIn,
adminName,
userId,
// 方法
login,
logout,
refreshAdminInfo,
initAuth,
}
})
+157
View File
@@ -0,0 +1,157 @@
// =============================================================================
// 企微IT智能服务台 — 坐席管理状态管理(Pinia Store
// =============================================================================
// 说明:管理后台坐席管理数据,支持列表查询、筛选、增删改
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Agent, AgentFilterStatus, CreateAgentRequest, UpdateAgentRequest } from '@/types'
import { getAgents as apiGetAgents, createAgent as apiCreateAgent, updateAgent as apiUpdateAgent, deleteAgent as apiDeleteAgent } from '@/api/admin'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useAgentStore = defineStore('agent', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 坐席列表 */
const agents = ref<Agent[]>([])
/** 当前筛选状态 */
const filterStatus = ref<AgentFilterStatus>('all')
/** 是否正在加载 */
const loading = ref<boolean>(false)
/** 总坐席数 */
const totalCount = computed(() => agents.value.length)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 按状态筛选后的坐席列表 */
const filteredAgents = computed(() => {
if (filterStatus.value === 'all') {
return agents.value
}
return agents.value.filter((a) => a.status === filterStatus.value)
})
/** 各状态计数 */
const statusCounts = computed(() => {
const counts: Record<AgentFilterStatus, number> = {
all: agents.value.length,
online: 0,
busy: 0,
offline: 0,
}
for (const a of agents.value) {
if (a.status === 'online') counts.online++
else if (a.status === 'busy') counts.busy++
else counts.offline++
}
return counts
})
// ==========================================================================
// 方法
// ==========================================================================
/**
* 加载坐席列表
* @param status - 可选筛选状态
*/
async function loadAgents(status?: string): Promise<void> {
loading.value = true
try {
const response = await apiGetAgents(status)
agents.value = response.data.data.items
} catch (error) {
console.error('加载坐席列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 设置筛选状态并重新加载
* @param status - 筛选状态
*/
function setFilter(status: AgentFilterStatus): void {
filterStatus.value = status
}
/**
* 添加坐席
* @param data - 坐席创建数据
*/
async function addAgent(data: CreateAgentRequest): Promise<boolean> {
try {
await apiCreateAgent(data)
// 重新加载列表
await loadAgents()
return true
} catch (error) {
console.error('添加坐席失败:', error)
return false
}
}
/**
* 编辑坐席
* @param id - 坐席ID
* @param data - 坐席更新数据
*/
async function editAgent(id: string, data: UpdateAgentRequest): Promise<boolean> {
try {
await apiUpdateAgent(id, data)
// 重新加载列表
await loadAgents()
return true
} catch (error) {
console.error('编辑坐席失败:', error)
return false
}
}
/**
* 移除坐席
* @param id - 坐席ID
*/
async function removeAgent(id: string): Promise<boolean> {
try {
await apiDeleteAgent(id)
// 重新加载列表
await loadAgents()
return true
} catch (error) {
console.error('移除坐席失败:', error)
return false
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
agents,
filterStatus,
loading,
// 计算属性
filteredAgents,
statusCounts,
totalCount,
// 方法
loadAgents,
setFilter,
addAgent,
editAgent,
removeAgent,
}
})
+124
View File
@@ -0,0 +1,124 @@
// =============================================================================
// 企微IT智能服务台 — 配置项状态管理(Pinia Store
// =============================================================================
// 说明:管理功能开关/参数配置数据,支持读取、更新、变更历史查询
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ConfigGroup, ConfigItem } from '@/types'
import { getConfigGroups, updateConfig as apiUpdateConfig, getConfigHistory } from '@/api/admin'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useConfigStore = defineStore('config', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 配置分组列表 */
const groups = ref<ConfigGroup[]>([])
/** 是否正在加载 */
const loading = ref<boolean>(false)
/** 当前查看变更历史的配置项 key */
const historyKey = ref<string>('')
/** 变更历史列表 */
const historyItems = ref<{ id: string; config_key: string; old_value: string; new_value: string; changed_by: string; changed_by_name: string; changed_at: string }[]>([])
// ==========================================================================
// 计算属性
// ==========================================================================
/** 所有配置项扁平列表 */
const allConfigs = computed<ConfigItem[]>(() => {
return groups.value.flatMap((g) => g.items)
})
/** 按 key 获取配置项 */
function getConfigByKey(key: string): ConfigItem | undefined {
return allConfigs.value.find((item) => item.key === key)
}
// ==========================================================================
// 方法
// ==========================================================================
/**
* 加载全部配置分组
*/
async function loadConfigs(): Promise<void> {
loading.value = true
try {
const response = await getConfigGroups()
groups.value = response.data.data.groups
} catch (error) {
console.error('加载配置失败:', error)
} finally {
loading.value = false
}
}
/**
* 更新单个配置项
* @param key - 配置键
* @param value - 新值
*/
async function updateConfigValue(key: string, value: string): Promise<boolean> {
try {
const response = await apiUpdateConfig(key, value)
const result = response.data.data
// 更新本地缓存中的值
for (const group of groups.value) {
const item = group.items.find((i) => i.key === key)
if (item) {
item.value = value
break
}
}
return true
} catch (error) {
console.error('更新配置失败:', error)
return false
}
}
/**
* 加载指定配置项的变更历史
* @param key - 配置键
* @param limit - 最大返回条数
*/
async function loadHistory(key: string, limit: number = 20): Promise<void> {
historyKey.value = key
try {
const response = await getConfigHistory(key, limit)
historyItems.value = response.data.data.items
} catch (error) {
console.error('加载变更历史失败:', error)
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
groups,
loading,
historyKey,
historyItems,
// 计算属性
allConfigs,
// 方法
getConfigByKey,
loadConfigs,
updateConfigValue,
loadHistory,
}
})
+113
View File
@@ -0,0 +1,113 @@
// =============================================================================
// 企微IT智能服务台 — 快速回复管理状态管理(Pinia Store
// =============================================================================
// 说明:管理后台快速回复审核数据,支持分类筛选、审核操作(通过/驳回)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { QuickReplyTemplate } from '@/types'
import { getPendingQuickReplies, reviewQuickReply as apiReviewQuickReply } from '@/api/admin'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useQuickReplyStore = defineStore('quickReply', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 快速回复模板列表 */
const replies = ref<QuickReplyTemplate[]>([])
/** 当前选中的分类筛选 */
const activeCategory = ref<string>('全部')
/** 是否正在加载 */
const loading = ref<boolean>(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 按分类筛选后的模板列表 */
const filteredReplies = computed(() => {
if (activeCategory.value === '全部') {
return replies.value
}
return replies.value.filter((r) => r.category === activeCategory.value)
})
/** 各分类计数 */
const categoryCounts = computed(() => {
const counts: Record<string, number> = { '全部': replies.value.length }
for (const r of replies.value) {
counts[r.category] = (counts[r.category] || 0) + 1
}
return counts
})
// ==========================================================================
// 方法
// ==========================================================================
/**
* 加载待审核快速回复列表
* @param category - 可选分类筛选
*/
async function loadReplies(category?: string): Promise<void> {
loading.value = true
try {
const response = await getPendingQuickReplies(category)
replies.value = response.data.data.items
} catch (error) {
console.error('加载快速回复列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 设置分类筛选
* @param category - 分类名称
*/
function setCategory(category: string): void {
activeCategory.value = category
}
/**
* 审核快速回复(通过/驳回)
* @param id - 模板ID
* @param action - 审核操作:approve 或 reject
* @param reason - 驳回原因(驳回时必填)
*/
async function review(id: string, action: string, reason?: string): Promise<boolean> {
try {
await apiReviewQuickReply(id, action, reason)
// 审核完毕后重新加载列表
await loadReplies(activeCategory.value === '全部' ? undefined : activeCategory.value)
return true
} catch (error) {
console.error('审核操作失败:', error)
return false
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
replies,
activeCategory,
loading,
// 计算属性
filteredReplies,
categoryCounts,
// 方法
loadReplies,
setCategory,
review,
}
})
+622
View File
@@ -0,0 +1,622 @@
/* =============================================================================
企微IT智能服务台 — 管理后台全局样式(深色科技风)
=============================================================================
说明:定义深色主题 CSS 变量 + Element Plus 深色覆盖 + 全局基础样式
参考:PRD-admin.md §10.2 视觉风格 + ARCHITECTURE-admin.md §8.5 CSS 变量
============================================================================= */
/* --------------------------------------------------------------------------
Tailwind CSS 指令
-------------------------------------------------------------------------- */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* --------------------------------------------------------------------------
全局 CSS 变量(深色科技风)
-------------------------------------------------------------------------- */
:root {
/* 背景色 */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
/* 语义色 */
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-light: rgba(59, 130, 246, 0.15);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.12);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.12);
/* 文本色 */
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
/* 边框 */
--border: rgba(148, 163, 184, 0.12);
--border-hover: rgba(148, 163, 184, 0.25);
/* 圆角 */
--radius: 8px;
--radius-lg: 12px;
/* 阴影 */
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* --------------------------------------------------------------------------
全局基础样式
-------------------------------------------------------------------------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
#app {
height: 100%;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* 链接样式 */
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* 代码样式 */
code {
background: var(--bg-tertiary);
color: var(--accent);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
}
/* --------------------------------------------------------------------------
Element Plus 深色主题覆盖
-------------------------------------------------------------------------- */
/* --- el-table 深色覆盖 --- */
.el-table {
--el-table-bg-color: var(--bg-secondary);
--el-table-tr-bg-color: var(--bg-secondary);
--el-table-header-bg-color: var(--bg-tertiary);
--el-table-text-color: var(--text-primary);
--el-table-border-color: var(--border);
--el-table-row-hover-bg-color: var(--bg-tertiary);
--el-table-header-text-color: var(--text-secondary);
--el-table-current-row-bg-color: var(--accent-light);
}
.el-table th.el-table__cell {
background-color: var(--bg-tertiary);
}
.el-table tr {
background-color: var(--bg-secondary);
}
/* --- el-dialog 深色覆盖 --- */
.el-dialog {
--el-dialog-bg-color: var(--bg-secondary);
--el-dialog-title-font-size: 18px;
}
.el-dialog__header {
border-bottom: 1px solid var(--border);
}
.el-dialog__title {
color: var(--text-primary);
}
.el-dialog__body {
color: var(--text-secondary);
}
/* --- el-form 深色覆盖 --- */
.el-form {
--el-form-label-color: var(--text-secondary);
}
.el-form-item__label {
color: var(--text-secondary);
}
/* --- el-input 深色覆盖 --- */
.el-input__wrapper {
background-color: var(--bg-primary);
border-color: var(--border);
box-shadow: none;
}
.el-input__inner {
color: var(--text-primary);
}
.el-input__wrapper:hover {
border-color: var(--border-hover);
}
.el-input__wrapper.is-focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset;
}
.el-input.is-disabled .el-input__wrapper {
background-color: var(--bg-tertiary);
}
/* --- el-select 深色覆盖 --- */
.el-select .el-input__wrapper {
background-color: var(--bg-primary);
}
.el-select-dropdown {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
}
.el-select-dropdown__item {
color: var(--text-secondary);
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: var(--bg-tertiary);
}
.el-select-dropdown__item.selected {
color: var(--accent);
}
/* --- el-switch 深色覆盖 --- */
.el-switch.is-checked .el-switch__core {
background-color: var(--success);
border-color: var(--success);
}
.el-switch__core {
background-color: var(--bg-tertiary);
border-color: var(--border);
}
/* --- el-card 深色覆盖 --- */
.el-card {
background-color: var(--bg-secondary);
border-color: var(--border);
}
.el-card__header {
border-bottom-color: var(--border);
color: var(--text-primary);
}
.el-card__body {
color: var(--text-primary);
}
/* --- el-button 深色覆盖 --- */
.el-button--primary {
--el-button-bg-color: var(--accent);
--el-button-border-color: var(--accent);
--el-button-hover-bg-color: var(--accent-hover);
--el-button-hover-border-color: var(--accent-hover);
}
.el-button--default {
--el-button-bg-color: var(--bg-tertiary);
--el-button-border-color: var(--border);
--el-button-text-color: var(--text-secondary);
--el-button-hover-bg-color: var(--bg-tertiary);
--el-button-hover-border-color: var(--border-hover);
--el-button-hover-text-color: var(--text-primary);
}
.el-button--danger {
--el-button-bg-color: var(--danger);
--el-button-border-color: var(--danger);
}
.el-button--success {
--el-button-bg-color: var(--success);
--el-button-border-color: var(--success);
}
/* --- el-tag 深色覆盖 --- */
.el-tag {
--el-tag-bg-color: var(--accent-light);
--el-tag-border-color: var(--accent);
--el-tag-text-color: var(--accent);
}
.el-tag--success {
--el-tag-bg-color: var(--success-bg);
--el-tag-border-color: var(--success);
--el-tag-text-color: var(--success);
}
.el-tag--warning {
--el-tag-bg-color: var(--warning-bg);
--el-tag-border-color: var(--warning);
--el-tag-text-color: var(--warning);
}
.el-tag--danger {
--el-tag-bg-color: var(--danger-bg);
--el-tag-border-color: var(--danger);
--el-tag-text-color: var(--danger);
}
.el-tag--info {
--el-tag-bg-color: var(--bg-tertiary);
--el-tag-border-color: var(--text-muted);
--el-tag-text-color: var(--text-muted);
}
/* --- el-pagination 深色覆盖 --- */
.el-pagination {
color: var(--text-secondary);
}
.el-pagination .btn-prev,
.el-pagination .btn-next {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.el-pagination .el-pager li {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.el-pagination .el-pager li.is-active {
background-color: var(--accent);
color: #fff;
}
.el-pagination .el-pager li:hover {
color: var(--accent);
}
/* --- el-menu 深色覆盖(侧边栏用) --- */
.el-menu {
border-right: none;
}
.el-menu--vertical {
background-color: var(--bg-secondary);
}
.el-menu-item {
color: var(--text-secondary);
}
.el-menu-item:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.el-menu-item.is-active {
background-color: var(--accent-light);
color: var(--accent);
}
/* --- el-divider 深色覆盖 --- */
.el-divider {
border-top-color: var(--border);
}
/* --- el-checkbox 深色覆盖 --- */
.el-checkbox__label {
color: var(--text-secondary);
}
/* --- el-radio 深色覆盖 --- */
.el-radio__label {
color: var(--text-secondary);
}
/* --- el-popover / el-tooltip 深色覆盖 --- */
.el-popper.is-dark {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* --- el-message-box 深色覆盖 --- */
.el-message-box {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
}
.el-message-box__title {
color: var(--text-primary);
}
.el-message-box__message {
color: var(--text-secondary);
}
/* --- el-empty 深色覆盖 --- */
.el-empty__description p {
color: var(--text-muted);
}
/* --- el-tabs 深色覆盖 --- */
.el-tabs__item {
color: var(--text-secondary);
}
.el-tabs__item.is-active {
color: var(--accent);
}
.el-tabs__nav-wrap::after {
background-color: var(--border);
}
/* --- el-breadcrumb 深色覆盖 --- */
.el-breadcrumb__item span {
color: var(--text-muted);
}
.el-breadcrumb__item:last-child span {
color: var(--text-primary);
}
.el-breadcrumb__inner {
color: var(--text-muted);
}
.el-breadcrumb__inner.is-link:hover {
color: var(--accent);
}
/* --- el-input-number 深色覆盖 --- */
.el-input-number__decrease,
.el-input-number__increase {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.el-input-number__decrease:hover,
.el-input-number__increase:hover {
color: var(--accent);
}
/* --- el-textarea 深色覆盖 --- */
.el-textarea__inner {
background-color: var(--bg-primary);
border-color: var(--border);
color: var(--text-primary);
}
.el-textarea__inner:hover {
border-color: var(--border-hover);
}
.el-textarea__inner:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) inset;
}
/* --------------------------------------------------------------------------
自定义工具类
-------------------------------------------------------------------------- */
/* 页面标题 */
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-primary);
}
/* 页面描述 */
.page-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 24px;
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 双栏布局 */
.two-column {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
/* 功能卡片网格 */
.feature-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 1200px) {
.feature-grid {
grid-template-columns: 1fr;
}
}
/* 集成卡片网格 */
.integration-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1400px) {
.integration-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 表格容器 */
.table-wrapper {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* 表格标题行 */
.table-header-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-header-row .table-title {
font-size: 15px;
font-weight: 600;
}
/* 分类标签 */
.category-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 弹性标签容器 */
.tag-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* 布局容器 */
.admin-layout {
display: flex;
height: 100vh;
}
/* 主内容区容器 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 内容滚动区 */
.content-scroll {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* 顶部栏 */
.top-bar {
height: 56px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
/* 顶部栏左右区域 */
.top-bar-left {
display: flex;
align-items: center;
gap: 16px;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 用户头像 */
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: white;
}
/* 锁定的导航项/卡片 */
.locked-item {
opacity: 0.5;
pointer-events: none;
}
.locked-text {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
}
/* 优先级标签 */
.priority-tag {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
}
.priority-p0 {
background: var(--danger-bg);
color: #f87171;
}
.priority-p1 {
background: var(--warning-bg);
color: #fbbf24;
}
.priority-p2 {
background: var(--success-bg);
color: #34d399;
}
/* 空状态占位 */
.placeholder-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.placeholder-title {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.placeholder-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 24px;
}
/* 开关按钮样式(自定义覆盖 Element Plus */
.toggle-switch {
cursor: pointer;
}
/* 审核操作按钮组 */
.review-actions {
display: flex;
gap: 8px;
}
+504
View File
@@ -0,0 +1,504 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 TypeScript 类型定义
// =============================================================================
// 说明:定义管理后台所有数据结构类型,与后端 API 响应对应
// --------------------------------------------------------------------------
// 基础类型
// --------------------------------------------------------------------------
/** API 统一响应格式 */
export interface ApiResponse<T = unknown> {
code: number
data: T
message: string
}
/** 分页列表响应 */
export interface PaginatedData<T> {
items: T[]
total?: number
}
// --------------------------------------------------------------------------
// 坐席相关类型
// --------------------------------------------------------------------------
/** 坐席信息 */
export interface Agent {
id: string
user_id: string
name: string
status: AgentStatus
role: AgentRole
skill_tags: string[]
current_load: number
max_load: number
today_resolved?: number
created_at: string
updated_at: string
}
/** 坐席状态 */
export type AgentStatus = 'online' | 'offline' | 'busy'
/** 坐席角色 */
export type AgentRole = 'admin' | 'agent'
/** 坐席筛选状态 */
export type AgentFilterStatus = 'all' | 'online' | 'busy' | 'offline'
/** 技能标签枚举 */
export const SKILL_TAGS = ['电脑', '软件', '外设', '网络', '安全', '资产', '其他'] as const
export type SkillTag = (typeof SKILL_TAGS)[number]
/** 创建坐席请求 */
export interface CreateAgentRequest {
user_id: string
name: string
role: AgentRole
skill_tags: string[]
max_load: number
}
/** 更新坐席请求 */
export interface UpdateAgentRequest {
role?: AgentRole
skill_tags?: string[]
max_load?: number
}
// --------------------------------------------------------------------------
// 配置项相关类型
// --------------------------------------------------------------------------
/** 配置项值类型 */
export type ConfigValueType = 'boolean' | 'number' | 'json_array' | 'string'
/** 单个配置项 */
export interface ConfigItem {
key: string
value: string
description: string
value_type: ConfigValueType
}
/** 配置分组 */
export interface ConfigGroup {
name: string
key_prefix: string
items: ConfigItem[]
}
/** 配置项变更日志 */
export interface ConfigChangeLogEntry {
id: string
config_key: string
old_value: string
new_value: string
changed_by: string
changed_by_name: string
changed_at: string
}
/** 配置更新请求 */
export interface UpdateConfigRequest {
value: string
}
/** 配置更新响应 */
export interface UpdateConfigResponse {
key: string
old_value: string
new_value: string
changed_at: string
}
// --------------------------------------------------------------------------
// 仪表盘相关类型
// --------------------------------------------------------------------------
/** 仪表盘概览数据 */
export interface DashboardOverview {
online_agents: number
today_conversations: number
avg_response_time: string
ai_hit_rate: string
pending_reviews: number
system_alerts: SystemAlert[]
integrations_health: IntegrationHealth[]
}
/** 系统告警 */
export interface SystemAlert {
type: string
content: string
submitter?: string
time: string
severity: 'info' | 'warning' | 'danger'
}
/** 集成健康状态 */
export interface IntegrationHealth {
system: string
status: 'connected' | 'disconnected' | 'partial' | 'pending'
}
// --------------------------------------------------------------------------
// 集成系统相关类型
// --------------------------------------------------------------------------
/** 集成配置类型(区分三种配置模式) */
export type IntegrationConfigType = 'url_key' | 'access_key' | 'account_password'
/** 集成系统 */
export interface Integration {
id: string
name: string
status: 'connected' | 'disconnected' | 'partial' | 'pending'
configurable: boolean
config_type?: IntegrationConfigType // 配置模式,区分对话框表单
config: IntegrationConfig | null
}
/** 集成系统配置(联合类型,支持三种模式) */
export interface IntegrationConfig {
// url_key 模式(Dify / RAGFlow
api_url?: string
api_key_set?: boolean
// access_key 模式(火绒安全)
access_key_id_set?: boolean
access_key_secret_set?: boolean
base_url?: string | null
// account_password 模式(联软LV7000
api_account_set?: boolean
api_password_set?: boolean
}
/** 更新集成请求(支持三种模式) */
export interface UpdateIntegrationRequest {
// url_key 模式(Dify / RAGFlow
api_url?: string
api_key?: string
// access_key 模式(火绒安全)
access_key_id?: string
access_key_secret?: string
// account_password 模式(联软LV7000
api_account?: string
api_password?: string
validate_key?: string
base_url?: string
}
// --------------------------------------------------------------------------
// 快速回复相关类型
// --------------------------------------------------------------------------
/** 快速回复模板 */
export interface QuickReplyTemplate {
id: string
category: string
title: string
content: string
variables: string[]
status: QuickReplyStatus
version: number
submitted_by: string
submitted_by_name: string
sort_order: number
created_at: string
updated_at: string
}
/** 快速回复状态 */
export type QuickReplyStatus = 'draft' | 'pending_review' | 'approved' | 'rejected'
/** 审核操作 */
export type ReviewAction = 'approve' | 'reject'
/** 审核请求 */
export interface ReviewQuickReplyRequest {
action: ReviewAction
reason?: string
}
/** 审核响应 */
export interface ReviewQuickReplyResponse {
id: string
status: QuickReplyStatus
version: number
}
/** 快速回复分类 */
export const QUICK_REPLY_CATEGORIES = ['全部', '电脑', '软件', '外设', '网络', '安全', '资产', '其他'] as const
export type QuickReplyCategory = (typeof QUICK_REPLY_CATEGORIES)[number]
// --------------------------------------------------------------------------
// 分配模式相关类型
// --------------------------------------------------------------------------
/** 分配模式 */
export interface AssignmentMode {
id: string
name: string
enabled: boolean
locked: boolean
unlock_at?: string
}
/** 分配模式配置 */
export interface AssignmentModeConfig {
current_mode: string
modes: AssignmentMode[]
}
/** 更新分配模式请求 */
export interface UpdateAssignmentModeRequest {
mode: string
}
// --------------------------------------------------------------------------
// 会话监控相关类型
// --------------------------------------------------------------------------
/** 会话监控统计 */
export interface MonitorStats {
in_progress: number
queued: number
resolved_today: number
alerts: number
}
/** 监控会话 */
export interface MonitorSession {
id: string
employee_name: string
status: string
assigned_agent_name: string
urgency_score: number
created_at: string
last_message_summary: string
}
/** 监控会话列表响应 */
export interface MonitorSessionsData {
stats: MonitorStats
items: MonitorSession[]
}
// --------------------------------------------------------------------------
// 搜索相关类型
// --------------------------------------------------------------------------
/** 搜索结果项 */
export interface SearchResultItem {
type: 'config' | 'agent' | 'quick_reply'
id: string
name: string
route: string
}
/** 搜索结果 */
export interface SearchResults {
items: SearchResultItem[]
}
// --------------------------------------------------------------------------
// 终端安全相关类型(火绒数据)
// --------------------------------------------------------------------------
/** 火绒终端基本信息(_list 接口返回,字段名与火绒API一致) */
export interface HuorongTerminal {
id?: number // 内部数据库ID
client_id: string // 终端唯一ID40位十六进制)
client_name: string // 客户端名称
computer_name: string // 计算机名
local_ip: string // 本地IP
connect_ip: string // 连接IP
mac: string // MAC地址
group_id?: number | string // 分组ID
os_version: string // 操作系统版本
version: string // 火绒客户端版本
definitions: string // 病毒库更新时间
is_online: boolean // 在线状态
last_connect_time?: number // 最后连接时间(Unix时间戳)
last_seen_time?: number // 最后可见时间(Unix时间戳)
first_appear_time?: number // 首次出现时间(Unix时间戳)
// 前端计算/映射字段(非API返回)
risk_level?: string // 风险等级:safe/low/medium/high
virus_count?: number // 病毒检测数
leak_count?: number // 漏洞数量
}
/** 火绒终端详细信息(_info2 接口返回) */
export interface HuorongTerminalDetail {
client_id: string
computer_name: string // 计算机名
local_ip: string // 本地IP
mac: string // MAC地址
os_version: string // 操作系统版本
version: string // 火绒客户端版本
is_online: boolean // 在线状态
last_connect_time?: number // 最后连接时间(Unix时间戳)
// 硬件信息(可选,_info2 返回)
cpu?: string
memory?: string
disk?: string
// 安全状态
virus_count: number
leak_count: number
is_isolated: boolean // 是否已被网络隔离
risk_level?: string
}
/** 火绒漏洞终端信息(_leak 接口返回)
* 注意:_leak 返回的是"存在高危漏洞的终端列表",字段名与 _list 不同!
* - cid(非 client_id)、hostname(非 computer_name)、ip_addr(非 local_ip
* - stat1=离线,2=在线,3=异常),非 is_online 布尔值
*/
export interface HuorongLeakInfo {
cid: string // 终端唯一ID_leak中叫cid
hostname: string // 计算机名(_leak中叫hostname
client_name: string // 终端名称
group_name: string // 分组名称
group_id?: number | string // 分组ID
ip_addr: string // 本地IP_leak中叫ip_addr
call_ip: string // 连接IP_leak中叫call_ip
mac: string // MAC地址
osver: string // 操作系统版本(_leak中叫osver
os_type: string // 终端类型
prodver: string // 火绒客户端版本(_leak中叫prodver
virdb?: number | string // 病毒库版本(Unix时间戳)
stat: number // 在线状态码: 1=离线 2=在线 3=异常
}
/** 火绒漏洞查询响应额外统计字段 */
export interface HuorongLeakStats {
all_client: number // 全部终端数
risk_client: number // 高危终端数
}
/** 火绒病毒处理结果 */
export interface HuorongVirusHandleResult {
success: number // 处理成功数
fail: number // 处理失败数
ignored: number // 暂不处理数
trusted: number // 已信任数
}
/** 火绒病毒事件(_virus_events 接口返回) */
export interface HuorongVirusEvent {
group_id?: number | string // 分组ID
client_id: string // 终端唯一ID
client_name: string // 终端名称
computer_name: string // 计算机名
local_ip: string // 本地IP
connect_ip: string // 连接IP
mac: string // MAC地址
count: number // 病毒日志总数
result?: HuorongVirusHandleResult // 处理结果统计
}
/** 终端安全统计概览 */
export interface TerminalSecurityStats {
total_terminals: number // 终端总数
online_terminals: number // 在线终端
high_risk_terminals: number // 高危终端
virus_events_today: number // 今日病毒事件
isolated_terminals: number // 已隔离终端
}
// --------------------------------------------------------------------------
// 角色管理相关类型
// --------------------------------------------------------------------------
/** 角色信息 */
export interface Role {
id: string
name: string // 角色标识:user / agent / admin
display_name: string // 显示名称:用户 / 坐席 / 管理员
description: string | null
permissions: string[] // 权限列表,如 ["ticket.create", "conversation.manage"]
is_default: boolean // 是否默认角色(user 默认)
user_count: number | null // 拥有该角色的用户数
created_at: string
updated_at: string
}
/** 用户角色关联 */
export interface UserRole {
id: string
employee_id: string // 企微 UserID
role_id: string // 关联角色 ID
role_name: string // 角色标识
role_display_name: string // 角色显示名称
source: UserRoleSource // 分配来源
assigned_by: string | null // 分配者
assigned_at: string
expires_at: string | null // 过期时间(null = 永不过期)
}
/** 用户角色来源 */
export type UserRoleSource = 'auto' | 'tag' | 'ehr' | 'manual'
/** 角色来源标签映射 */
export const ROLE_SOURCE_LABELS: Record<UserRoleSource, string> = {
auto: '系统自动',
tag: '企微标签',
ehr: 'eHR 岗位',
manual: '手动分配',
}
/** 角色映射规则 */
export interface RoleMappingRule {
id: string
role_id: string // 目标角色 ID
role_name: string // 目标角色标识
source_type: MappingSourceSource // 来源类型
source_value: string // 来源值(标签名或岗位关键词)
priority: number // 优先级(越大越优先)
is_active: boolean // 是否启用
created_at: string
}
/** 映射规则来源类型 */
export type MappingSourceSource = 'wecom_tag' | 'ehr_position'
/** 映射规则来源类型标签 */
export const MAPPING_SOURCE_LABELS: Record<MappingSourceSource, string> = {
wecom_tag: '企微标签',
ehr_position: 'eHR 岗位',
}
/** 分配角色请求 */
export interface RoleAssignRequest {
employee_id: string
role_name: string
reason?: string
}
/** 撤销角色请求 */
export interface RoleRevokeRequest {
employee_id: string
role_name: string
reason?: string
}
/** 创建映射规则请求 */
export interface RoleMappingRuleRequest {
role_name: string
source_type: MappingSourceSource
source_value: string
priority?: number
is_active?: boolean
}
// --------------------------------------------------------------------------
// 登录相关类型
// --------------------------------------------------------------------------
/** 登录响应 */
export interface LoginResponse {
agent_info: Agent
token: string
}
@@ -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>
+358
View File
@@ -0,0 +1,358 @@
<!--
=============================================================================
企微IT智能服务台 坐席人员管理页
=============================================================================
说明管理坐席人员功能包括
- 表格列表显示所有坐席信息
- 按状态筛选全部/在线/忙碌/离线
- 编辑坐席对话框角色技能标签最大负载
- 添加坐席对话框user_idname技能标签等
- 移除坐席确认
-->
<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>
+236
View File
@@ -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>
+203
View File
@@ -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>
+307
View File
@@ -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>
+232
View File
@@ -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>
+699
View File
@@ -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>
+239
View File
@@ -0,0 +1,239 @@
<!--
=============================================================================
企微IT智能服务台 管理员登录页
=============================================================================
说明管理员登录页面
- 复用坐席端登录 APIPOST /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>
+212
View File
@@ -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>
+67
View File
@@ -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>
+186
View File
@@ -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>
+882
View File
@@ -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>
+403
View File
@@ -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>
+127
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
'bg-primary': '#0f172a',
'bg-secondary': '#1e293b',
'bg-tertiary': '#334155',
'accent': '#3b82f6',
'success': '#10b981',
'warning': '#f59e0b',
'danger': '#ef4444',
'text-primary': '#f1f5f9',
'text-secondary': '#94a3b8',
'text-muted': '#64748b',
},
},
},
plugins: [],
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": ".",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+56
View File
@@ -0,0 +1,56 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 Vite 配置
// =============================================================================
// 说明:Vite 构建工具配置,定义开发服务器、构建输出等
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// Vite 配置
// https://vitejs.dev/config/
export default defineConfig({
// 生产环境基础路径(部署在 /itadmin/ 子路径下,与IT数据平台共享域名)
base: '/itadmin/',
// Vue3 插件
plugins: [vue()],
// 开发服务器配置
server: {
// 开发服务器端口
// 5173 = 坐席端(frontend-agent)5174 = H5用户端(frontend-h5)5175 = 管理后台
port: 5175,
// 自动打开浏览器
open: true,
// API 代理:将 /api 请求转发到后端,解决开发环境跨域问题
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
// 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离)
rewrite: (path) => path.replace(/^\/api/, ''),
},
// WebSocket 代理:将 /ws 请求转发到后端 WebSocket 服务
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
// 构建配置
build: {
// 输出目录
outDir: 'dist',
// 静态资源内联阈值(小于4KB的资源会被base64内联)
assetsInlineLimit: 4096,
},
// 路径别名
resolve: {
alias: {
// 使用 @ 指向 src 目录,方便导入
'@': '/src',
},
},
})