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