WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+247 -1
View File
@@ -2,7 +2,7 @@
// 企微IT智能服务台 — 管理后台 API 调用函数
// =============================================================================
// 说明:封装所有管理后台 API 端点调用,统一返回类型
// 所有函数返回 axios response,调用方从 response.data.data 获取业务数据
// 所有函数返回 axios response,调用方从 response 获取业务数据
import apiClient from './index'
import type {
@@ -114,6 +114,14 @@ export function deleteAgent(
return apiClient.delete(`/admin/agents/${id}`)
}
/** 重置坐席密码 */
export function resetAgentPassword(
id: string,
newPassword: string
): Promise<{ data: { code: number; data: { message: string }; message: string } }> {
return apiClient.post(`/admin/agents/${id}/reset-password`, { new_password: newPassword })
}
// ==========================================================================
// 外部系统集成配置
// ==========================================================================
@@ -250,6 +258,71 @@ export function reviewQuickReply(
return apiClient.put(`/admin/quick-replies/${id}/review`, body)
}
/** 创建快速回复模板 */
export function createQuickReplyTemplate(
data: QuickReplyTemplate
): Promise<{ data: { code: number; data: QuickReplyTemplate; message: string } }> {
return apiClient.post('/quick-replies', data)
}
/** 更新快速回复模板 */
export function updateQuickReplyTemplate(
id: string,
data: Partial<QuickReplyTemplate>
): Promise<{ data: { code: number; data: QuickReplyTemplate; message: string } }> {
return apiClient.put(`/quick-replies/${id}`, data)
}
// ==========================================================================
// 知识库管理
// ==========================================================================
/** 知识库条目类型 */
export interface KnowledgeBaseItem {
id: string
category: string
title: string
content: string
tags: string[]
view_count: number
use_count: number
created_at: string
updated_at: string
}
/** 获取知识库列表 */
export function getKnowledgeBase(
category?: string,
keyword?: string
): Promise<{ data: { code: number; data: { items: KnowledgeBaseItem[] }; message: string } }> {
const params: Record<string, string> = {}
if (category) params.category = category
if (keyword) params.keyword = keyword
return apiClient.get('/knowledge', { params })
}
/** 创建知识库条目 */
export function createKnowledgeBase(
data: Omit<KnowledgeBaseItem, 'id' | 'view_count' | 'use_count' | 'created_at' | 'updated_at'>
): Promise<{ data: { code: number; data: KnowledgeBaseItem; message: string } }> {
return apiClient.post('/knowledge', data)
}
/** 更新知识库条目 */
export function updateKnowledgeBase(
id: string,
data: Partial<KnowledgeBaseItem>
): Promise<{ data: { code: number; data: KnowledgeBaseItem; message: string } }> {
return apiClient.put(`/knowledge/${id}`, data)
}
/** 删除知识库条目 */
export function deleteKnowledgeBase(
id: string
): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.delete(`/knowledge/${id}`)
}
// ==========================================================================
// 消息分配模式
// ==========================================================================
@@ -383,3 +456,176 @@ export function getSystemLogs(params?: {
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/system-logs', { params })
}
// ==========================================================================
// P2: 满意度评价统计
// ==========================================================================
/** 满意度评价统计数据项 */
export interface EvaluationStatsItem {
label: string
count: number
percentage: number
}
/** 满意度评价记录 */
export interface EvaluationRecord {
id: string
conversation_id: string
employee_id: string
employee_name: string
star_rating: number
emoji: string
feedback_text: string | null
created_at: string
}
/** 满意度评价统计响应 */
export interface EvaluationStatsResponse {
total_count: number
avg_star_rating: number
star_distribution: EvaluationStatsItem[]
emoji_distribution: EvaluationStatsItem[]
recent_evaluations: EvaluationRecord[]
}
/** 获取满意度评价统计数据 */
export function getEvaluationStats(params?: {
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: EvaluationStatsResponse; message: string } }> {
return apiClient.get('/evaluations/stats', { params })
}
// ==========================================================================
// 知识库自动迭代 (P2-13)
// ==========================================================================
/** 知识库优化建议项 */
export interface KnowledgeSuggestionItem {
id: string
suggestion_type: 'new_faq' | 'update' | 'outdated'
status: 'pending' | 'approved' | 'rejected' | 'applied'
title: string
content: string
category: string
tags: string[]
source_type: 'annotation' | 'conversation' | 'ai_uncertain'
source_data: string[] | null
reason: string | null
reject_reason: string | null
reviewer_id: string | null
reviewed_at: string | null
created_at: string
updated_at: string
}
/** 知识库优化建议统计 */
export interface KnowledgeSuggestionStats {
total: number
pending: number
approved: number
rejected: number
applied: number
new_faq_count: number
update_count: number
outdated_count: number
}
/** 获取知识库优化建议列表 */
export function getKnowledgeSuggestions(params?: {
status?: string
suggestion_type?: string
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { total: number; items: KnowledgeSuggestionItem[] }; message: string } }> {
return apiClient.get('/admin/knowledge-iteration/suggestions', { params })
}
/** 获取知识库优化建议详情 */
export function getKnowledgeSuggestion(
id: string
): Promise<{ data: { code: number; data: KnowledgeSuggestionItem; message: string } }> {
return apiClient.get(`/admin/knowledge-iteration/suggestions/${id}`)
}
/** 触发知识库迭代分析 */
export function triggerKnowledgeIterationAnalysis(params: {
days?: number
}): Promise<{ data: { code: number; data: any; message: string } }> {
return apiClient.post('/admin/knowledge-iteration/analyze', null, { params })
}
/** 获取知识库建议统计 */
export function getKnowledgeSuggestionStats(): Promise<{ data: { code: number; data: KnowledgeSuggestionStats; message: string } }> {
return apiClient.get('/admin/knowledge-iteration/stats')
}
/** 审核通过知识库建议 */
export function approveKnowledgeSuggestion(
id: string
): Promise<{ data: { code: number; data: KnowledgeSuggestionItem; message: string } }> {
return apiClient.post(`/admin/knowledge-iteration/suggestions/${id}/approve`)
}
/** 拒绝知识库建议 */
export function rejectKnowledgeSuggestion(
id: string,
body: { reject_reason: string }
): Promise<{ data: { code: number; data: KnowledgeSuggestionItem; message: string } }> {
return apiClient.post(`/admin/knowledge-iteration/suggestions/${id}/reject`, body)
}
/** 管理员 API 对象 (供 KnowledgeSuggestions 等页面使用) */
export const adminApi = {
getKnowledgeSuggestions,
getKnowledgeSuggestionStats,
triggerKnowledgeIterationAnalysis,
approveKnowledgeSuggestion,
rejectKnowledgeSuggestion,
getDashboardOverview,
getConfigGroups,
updateConfig,
getConfigHistory,
getAgents,
createAgent,
updateAgent,
unbindOtp,
deleteAgent,
getIntegrations,
updateIntegration,
testHuorongConnection,
getHuorongTerminals,
getHuorongTerminalDetail,
getHuorongLeaks,
getHuorongVirusEvents,
testLianruanConnection,
queryLianruanTerminals,
getLianruanTerminalDetail,
testRagflowConnection,
getRagflowDatasets,
ragflowRetrieval,
getPendingQuickReplies,
reviewQuickReply,
createQuickReplyTemplate,
updateQuickReplyTemplate,
getKnowledgeBase,
createKnowledgeBase,
updateKnowledgeBase,
deleteKnowledgeBase,
getAssignmentMode,
updateAssignmentMode,
getMonitorSessions,
globalSearch,
getRoles,
assignRole,
revokeRole,
getRoleMappingRules,
createRoleMappingRule,
deleteRoleMappingRule,
getAuditConversations,
getAuditConversationDetail,
getAgentPerformance,
getSystemLogs,
getEvaluationStats,
}
+8 -6
View File
@@ -71,19 +71,21 @@ apiClient.interceptors.response.use(
})
}
// 返回 rejected Promise,让调用方的 catch 能捕获
return Promise.reject(new Error(res.message || '请求失败'))
// 返回 rejected Promise,让调用方的 catch 能捕获Scheme A: {code, message}
return Promise.reject({ code: res.code, message: res.message || '请求失败' })
}
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据
return response
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约
return res.data
},
(error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)
// 网络错误或服务器错误(HTTP 状态码非 2xx)— 统一 reject {code, message}CTRT-03
let message = '网络异常,请稍后重试'
let code = -1
if (error.response) {
// 服务器返回了错误状态码
code = error.response.status
switch (error.response.status) {
case 401:
message = '未授权,请重新登录'
@@ -110,7 +112,7 @@ apiClient.interceptors.response.use(
// 显示错误提示
ElMessage.error(message)
return Promise.reject(error)
return Promise.reject({ code, message })
}
)
+2 -2
View File
@@ -92,7 +92,7 @@ export async function listMfaUsers(
params: MfaUserListParams = {}
): Promise<MfaUserListData> {
const response: AxiosResponse = await apiClient.get('/admin/mfa/users', { params })
return response.data.data
return response
}
/**
@@ -107,5 +107,5 @@ export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
const response: AxiosResponse = await apiClient.post(
`/admin/mfa/reset/${encodeURIComponent(employeeId)}`
)
return response.data.data
return response
}
+1 -1
View File
@@ -76,7 +76,7 @@ export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
const res = await http.get<ApiResponse<TroubleshootingTemplate[]>>(
'/troubleshooting-templates'
)
return res.data.data?.items || []
return res.data.data || []
}
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
+6 -1
View File
@@ -94,11 +94,14 @@
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="120" align="center" fixed="right">
<el-table-column label="操作" width="180" 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="warning" @click="$emit('reset-password', row)">
重置密码
</el-button>
<el-button size="small" text type="danger" @click="$emit('delete', row)">
移除
</el-button>
@@ -127,6 +130,8 @@ defineProps<{
defineEmits<{
/** 编辑坐席 */
edit: [agent: Agent]
/** 重置密码 */
'reset-password': [agent: Agent]
/** 移除坐席 */
delete: [agent: Agent]
}>()
+9 -1
View File
@@ -95,6 +95,14 @@
<el-icon><Notebook /></el-icon>
<span>系统日志</span>
</el-menu-item>
<el-menu-item index="/evaluation-stats">
<el-icon><Star /></el-icon>
<span>满意度评价</span>
</el-menu-item>
<el-menu-item index="/knowledge-suggestions">
<el-icon><Document /></el-icon>
<span>知识库建议</span>
</el-menu-item>
<!-- 🔒 开发中P2占位 -->
<div class="menu-section-title">🔒 开发中</div>
@@ -123,7 +131,7 @@
// ==========================================================================
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'
import { Headset, PieChart, Switch, UserFilled, Connection, Warning, ChatLineSquare, Sort, Share, Monitor, Brush, DataAnalysis, Reading, Lock, Key, Document, TrendCharts, Notebook, Star } from '@element-plus/icons-vue'
// ==========================================================================
// 当前激活菜单
@@ -10,7 +10,7 @@
<template>
<el-dialog
:model-value="modelValue"
@update:model-value="(v) => $emit('update:modelValue', v)"
@update:model-value="(v: boolean) => $emit('update:modelValue', v)"
:title="dialogTitle"
width="80%"
top="5vh"
+20
View File
@@ -76,6 +76,12 @@ const routes = [
component: () => import('@/views/QuickReplies.vue'),
meta: { title: '快速回复', requiresAuth: true },
},
{
path: 'knowledge',
name: 'Knowledge',
component: () => import('@/views/Knowledge.vue'),
meta: { title: '知识库', requiresAuth: true },
},
{
path: 'assignment-mode',
name: 'AssignmentMode',
@@ -140,6 +146,20 @@ const routes = [
component: () => import('@/views/MfaManage.vue'),
meta: { title: 'MFA 管理', requiresAuth: true },
},
{
// 满意度评价统计
path: 'evaluation-stats',
name: 'EvaluationStats',
component: () => import('@/views/EvaluationStats.vue'),
meta: { title: '满意度评价', requiresAuth: true },
},
{
// 知识库优化建议 (P2-13)
path: 'knowledge-suggestions',
name: 'KnowledgeSuggestions',
component: () => import('@/views/KnowledgeSuggestions.vue'),
meta: { title: '知识库建议', requiresAuth: true },
},
],
},
{
+4 -2
View File
@@ -68,13 +68,15 @@ export const useAdminStore = defineStore('admin', () => {
async function login(inputUserId: string, password: string, otpCode?: string): Promise<void> {
logging.value = true
try {
// 注意:后端 AgentLogin Schema 要求必填字段 name
const response = await apiClient.post('/agents/login', {
user_id: inputUserId,
name: inputUserId, // 后端必填字段,使用 user_id 作为默认值
password: password,
otp_code: otpCode || undefined,
})
const data = response.data.data
const data = response
const agentInfoData = data.agent_info || data
// 检查是否需要 OTP 验证
@@ -143,7 +145,7 @@ export const useAdminStore = defineStore('admin', () => {
try {
if (!token.value) return
const response = await apiClient.get('/agents/me')
adminInfo.value = response.data.data
adminInfo.value = response
} catch (error) {
console.error('获取管理员信息失败:', error)
// 如果是 401 未授权,说明 token 过期,需要重新登录
+150
View File
@@ -0,0 +1,150 @@
// =============================================================================
// 企微IT智能服务台 — 知识库状态管理(Pinia Store
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { KnowledgeBaseItem } from '@/api/admin'
import { getKnowledgeBase, createKnowledgeBase, updateKnowledgeBase, deleteKnowledgeBase } from '@/api/admin'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useKnowledgeStore = defineStore('knowledge', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 知识库列表 */
const items = ref<KnowledgeBaseItem[]>([])
/** 当前选中的分类筛选 */
const activeCategory = ref<string>('全部')
/** 搜索关键词 */
const keyword = ref<string>('')
/** 是否正在加载 */
const loading = ref<boolean>(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 按分类筛选后的列表 */
const filteredItems = computed(() => {
if (activeCategory.value === '全部') {
return items.value
}
return items.value.filter((item) => item.category === activeCategory.value)
})
/** 各分类计数 */
const categoryCounts = computed(() => {
const counts: Record<string, number> = { '全部': items.value.length }
for (const item of items.value) {
counts[item.category] = (counts[item.category] || 0) + 1
}
return counts
})
// ==========================================================================
// 方法
// ==========================================================================
/**
* 加载知识库列表
*/
async function loadKnowledge(category?: string, searchKeyword?: string): Promise<void> {
loading.value = true
try {
const response = await getKnowledgeBase(category, searchKeyword)
items.value = response.data.data.items
} catch (error) {
console.error('加载知识库列表失败:', error)
} finally {
loading.value = false
}
}
/**
* 设置分类筛选
*/
function setCategory(category: string): void {
activeCategory.value = category
}
/**
* 设置搜索关键词
*/
function setKeyword(kw: string): void {
keyword.value = kw
}
/**
* 创建知识库条目
*/
async function create(
data: Omit<KnowledgeBaseItem, 'id' | 'view_count' | 'use_count' | 'created_at' | 'updated_at'>
): Promise<boolean> {
try {
await createKnowledgeBase(data)
await loadKnowledge()
return true
} catch (error) {
console.error('创建知识库条目失败:', error)
return false
}
}
/**
* 更新知识库条目
*/
async function update(id: string, data: Partial<KnowledgeBaseItem>): Promise<boolean> {
try {
await updateKnowledgeBase(id, data)
await loadKnowledge()
return true
} catch (error) {
console.error('更新知识库条目失败:', error)
return false
}
}
/**
* 删除知识库条目
*/
async function remove(id: string): Promise<boolean> {
try {
await deleteKnowledgeBase(id)
await loadKnowledge()
return true
} catch (error) {
console.error('删除知识库条目失败:', error)
return false
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
items,
activeCategory,
keyword,
loading,
// 计算属性
filteredItems,
categoryCounts,
// 方法
loadKnowledge,
setCategory,
setKeyword,
create,
update,
remove,
}
})
+83 -1
View File
@@ -42,6 +42,7 @@
:agents="agentStore.filteredAgents"
:loading="agentStore.loading"
@edit="openEditDialog"
@reset-password="openResetPasswordDialog"
@delete="handleDelete"
/>
@@ -165,6 +166,45 @@
<el-button type="primary" @click="handleAddSave">确认添加</el-button>
</template>
</el-dialog>
<!-- ================================================================ -->
<!-- 重置密码对话框 -->
<!-- ================================================================ -->
<el-dialog
v-model="resetPasswordDialogVisible"
title="重置密码"
width="400px"
destroy-on-close
>
<el-form v-if="resetPasswordAgent" label-position="top">
<el-form-item label="坐席">
<el-input :model-value="resetPasswordAgent.name" disabled />
</el-form-item>
<el-form-item label="新密码" required>
<el-input
v-model="resetPasswordForm.newPassword"
type="password"
placeholder="请输入新密码(6-128位)"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" required>
<el-input
v-model="resetPasswordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPasswordDialogVisible = false">取消</el-button>
<el-button type="warning" @click="handleResetPassword">确认重置</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -177,7 +217,7 @@ 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 { unbindOtp as unbindOtpApi, resetAgentPassword } from '@/api/admin'
import type { Agent, AgentFilterStatus } from '@/types'
import { SKILL_TAGS } from '@/types'
@@ -276,6 +316,48 @@ const addForm = reactive({
maxLoad: 5,
})
// ==========================================================================
// 重置密码对话框
// ==========================================================================
const resetPasswordDialogVisible = ref<boolean>(false)
const resetPasswordAgent = ref<Agent | null>(null)
const resetPasswordForm = reactive({
newPassword: '',
confirmPassword: '',
})
/** 打开重置密码对话框 */
function openResetPasswordDialog(agent: Agent): void {
resetPasswordAgent.value = agent
resetPasswordForm.newPassword = ''
resetPasswordForm.confirmPassword = ''
resetPasswordDialogVisible.value = true
}
/** 执行重置密码 */
async function handleResetPassword(): Promise<void> {
if (!resetPasswordAgent.value) return
// 验证密码
if (!resetPasswordForm.newPassword || resetPasswordForm.newPassword.length < 6) {
ElMessage.error('密码长度不能少于6位')
return
}
if (resetPasswordForm.newPassword !== resetPasswordForm.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
try {
await resetAgentPassword(resetPasswordAgent.value.id, resetPasswordForm.newPassword)
ElMessage.success('密码已重置')
resetPasswordDialogVisible.value = false
} catch (error) {
console.error('重置密码失败:', error)
ElMessage.error('重置密码失败')
}
}
const addFormRules: FormRules = {
userId: [{ required: true, message: '请输入企微用户ID', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
@@ -0,0 +1,442 @@
<template>
<!-- ================================================================== -->
<!-- 满意度评价统计页面 展示评价数据与详细记录 -->
<!-- ================================================================== -->
<div class="evaluation-stats">
<!-- 统计卡片区域 -->
<div class="stats-cards">
<!-- 总评价数 -->
<div class="stat-card">
<div class="stat-card-icon total-icon">
<el-icon><Document /></el-icon>
</div>
<div class="stat-card-content">
<div class="stat-card-value">{{ stats.total_count }}</div>
<div class="stat-card-label">总评价数</div>
</div>
</div>
<!-- 平均星级 -->
<div class="stat-card">
<div class="stat-card-icon star-icon">
<el-icon><Star /></el-icon>
</div>
<div class="stat-card-content">
<div class="stat-card-value">{{ stats.avg_star_rating }}</div>
<div class="stat-card-label">平均星级</div>
</div>
</div>
<!-- 满意占比 -->
<div class="stat-card">
<div class="stat-card-icon satisfied-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="stat-card-content">
<div class="stat-card-value">{{ satisfiedPercentage }}%</div>
<div class="stat-card-label">满意</div>
</div>
</div>
<!-- 一般占比 -->
<div class="stat-card">
<div class="stat-card-icon neutral-icon">
<el-icon><InfoFilled /></el-icon>
</div>
<div class="stat-card-content">
<div class="stat-card-value">{{ neutralPercentage }}%</div>
<div class="stat-card-label">一般</div>
</div>
</div>
<!-- 不满意占比 -->
<div class="stat-card">
<div class="stat-card-icon dissatisfied-icon">
<el-icon><CircleClose /></el-icon>
</div>
<div class="stat-card-content">
<div class="stat-card-value">{{ dissatisfiedPercentage }}%</div>
<div class="stat-card-label">不满意</div>
</div>
</div>
</div>
<!-- 详细数据区域 -->
<div class="detail-section">
<!-- 星级分布 -->
<el-card class="distribution-card" shadow="never">
<template #header>
<div class="card-header">
<span>星级分布</span>
</div>
</template>
<div class="star-bars">
<div v-for="item in stats.star_distribution" :key="item.label" class="star-bar-item">
<span class="star-label">{{ item.label }}</span>
<el-progress
:percentage="item.percentage"
:stroke-width="12"
:show-text="false"
:color="starColor(item.label)"
/>
<span class="star-count">{{ item.count }} ({{ item.percentage }}%)</span>
</div>
</div>
</el-card>
<!-- 表情分布 -->
<el-card class="distribution-card" shadow="never">
<template #header>
<div class="card-header">
<span>满意度分布</span>
</div>
</template>
<div class="emoji-bars">
<div v-for="item in stats.emoji_distribution" :key="item.label" class="emoji-bar-item">
<span class="emoji-label">
<el-icon v-if="item.label === '满意'" color="#67c23a"><CircleCheck /></el-icon>
<el-icon v-else-if="item.label === '一般'" color="#e6a23c"><InfoFilled /></el-icon>
<el-icon v-else color="#f56c6c"><CircleClose /></el-icon>
{{ item.label }}
</span>
<el-progress
:percentage="item.percentage"
:stroke-width="12"
:show-text="false"
:color="emojiColor(item.label)"
/>
<span class="emoji-count">{{ item.count }} ({{ item.percentage }}%)</span>
</div>
</div>
</el-card>
</div>
<!-- 评价记录列表 -->
<el-card class="table-card" shadow="never">
<template #header>
<div class="card-header">
<span>评价记录</span>
</div>
</template>
<!-- 表格 -->
<el-table :data="records" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="employee_name" label="评价员工" width="120" />
<el-table-column prop="star_rating" label="星级" width="100" align="center">
<template #default="{ row }">
<el-rate v-model="row.star_rating" disabled text-color="#ff9900" />
</template>
</el-table-column>
<el-table-column prop="emoji" label="满意度" width="100" align="center">
<template #default="{ row }">
<el-tag :type="emojiTagType(row.emoji)" size="small">
{{ emojiLabel(row.emoji) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="feedback_text" label="反馈内容" min-width="200">
<template #default="{ row }">
<span v-if="row.feedback_text" style="color: var(--text-secondary)">
{{ row.feedback_text }}
</span>
<span v-else style="color: var(--text-muted)"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="评价时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadStats"
@current-change="loadStats"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
// =============================================================================
// 满意度评价统计页面逻辑
// =============================================================================
import { ref, computed, onMounted } from 'vue'
import { Document, Star, CircleCheck, CircleClose, InfoFilled } from '@element-plus/icons-vue'
import { getEvaluationStats, type EvaluationStatsItem, type EvaluationRecord } from '@/api/admin'
import { ElMessage } from 'element-plus'
// ---------- 状态 ----------
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 统计数据
const stats = ref<{
total_count: number
avg_star_rating: number
star_distribution: EvaluationStatsItem[]
emoji_distribution: EvaluationStatsItem[]
}>({
total_count: 0,
avg_star_rating: 0,
star_distribution: [],
emoji_distribution: [],
})
// 评价记录
const records = ref<EvaluationRecord[]>([])
// ---------- 计算属性 ----------
const satisfiedPercentage = computed(() => {
const item = stats.value.emoji_distribution.find(e => e.label === '满意')
return item ? item.percentage : 0
})
const neutralPercentage = computed(() => {
const item = stats.value.emoji_distribution.find(e => e.label === '一般')
return item ? item.percentage : 0
})
const dissatisfiedPercentage = computed(() => {
const item = stats.value.emoji_distribution.find(e => e.label === '不满意')
return item ? item.percentage : 0
})
// ---------- 数据加载 ----------
async function loadStats() {
loading.value = true
try {
const { data } = await getEvaluationStats({
page: currentPage.value,
page_size: pageSize.value,
})
if (data.code === 0) {
stats.value = {
total_count: data.data.total_count,
avg_star_rating: data.data.avg_star_rating,
star_distribution: data.data.star_distribution || [],
emoji_distribution: data.data.emoji_distribution || [],
}
records.value = data.data.recent_evaluations || []
total.value = data.data.total_count
}
} catch (err: any) {
ElMessage.error('加载评价统计数据失败')
} finally {
loading.value = false
}
}
// ---------- 工具函数 ----------
function starColor(label: string): string {
const starNum = parseInt(label.replace('星', ''))
if (starNum >= 4) return '#67c23a'
if (starNum >= 3) return '#e6a23c'
return '#f56c6c'
}
function emojiColor(label: string): string {
if (label === '满意') return '#67c23a'
if (label === '一般') return '#e6a23c'
return '#f56c6c'
}
function emojiTagType(emoji: string): 'success' | 'warning' | 'danger' {
if (emoji === 'satisfied') return 'success'
if (emoji === 'neutral') return 'warning'
return 'danger'
}
function emojiLabel(emoji: string): string {
const map: Record<string, string> = {
satisfied: '满意',
neutral: '一般',
dissatisfied: '不满意',
}
return map[emoji] || emoji
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return '—'
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
const second = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
// ---------- 生命周期 ----------
onMounted(() => loadStats())
</script>
<style scoped>
.evaluation-stats {
padding: 0;
}
/* 统计卡片区域 */
.stats-cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-card);
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
border: 1px solid var(--border);
}
.stat-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.total-icon {
background: linear-gradient(135deg, #409eff, #67b3ff);
}
.star-icon {
background: linear-gradient(135deg, #e6a23c, #f0c78a);
}
.satisfied-icon {
background: linear-gradient(135deg, #67c23a, #85ce61);
}
.neutral-icon {
background: linear-gradient(135deg, #e6a23c, #ebb563);
}
.dissatisfied-icon {
background: linear-gradient(135deg, #f56c6c, #f78989);
}
.stat-card-content {
flex: 1;
}
.stat-card-value {
font-size: 28px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.stat-card-label {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
/* 详细数据区域 */
.detail-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
.distribution-card {
border-radius: 8px;
}
.card-header {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.star-bars,
.emoji-bars {
display: flex;
flex-direction: column;
gap: 12px;
}
.star-bar-item,
.emoji-bar-item {
display: grid;
grid-template-columns: 50px 1fr 120px;
align-items: center;
gap: 12px;
}
.star-label,
.emoji-label {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.star-count,
.emoji-count {
font-size: 13px;
color: var(--text-secondary);
text-align: right;
}
/* 表格区域 */
.table-card {
border-radius: 8px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.stats-cards {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-cards {
grid-template-columns: repeat(2, 1fr);
}
.detail-section {
grid-template-columns: 1fr;
}
.star-bar-item,
.emoji-bar-item {
grid-template-columns: 40px 1fr;
}
.star-count,
.emoji-count {
display: none;
}
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<!--
=============================================================================
企微IT智能服务台 知识库管理页
=============================================================================
说明知识库FAQ管理
- 分类标签筛选
- 搜索功能
- CRUD操作
-->
<template>
<div class="knowledge-page">
<!-- 页面标题 -->
<div class="page-title">知识库管理</div>
<div class="page-desc">FAQ手动维护 + 命中统计分类管理标签管理搜索功能</div>
<!-- 顶部操作栏 -->
<div class="knowledge-toolbar">
<!-- 搜索框 -->
<el-input
v-model="searchKeyword"
placeholder="搜索问题标题或答案内容..."
clearable
style="width: 300px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<!-- 分类筛选 -->
<div class="category-tabs">
<el-button
v-for="cat in categories"
:key="cat"
:type="knowledgeStore.activeCategory === cat ? 'primary' : 'default'"
size="small"
@click="handleCategoryChange(cat)"
>
{{ cat }} ({{ getCategoryCount(cat) }})
</el-button>
</div>
<!-- 新增按钮 -->
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
新增知识
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="knowledgeStore.loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<!-- 知识列表 -->
<div v-else-if="knowledgeStore.filteredItems.length > 0" class="knowledge-list">
<el-card
v-for="item in knowledgeStore.filteredItems"
:key="item.id"
class="knowledge-card"
>
<template #header>
<div class="card-header">
<div class="card-title">
<el-tag size="small" type="info">{{ item.category }}</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="card-actions">
<el-button size="small" @click="openEditDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(item)">删除</el-button>
</div>
</div>
</template>
<div class="card-content">
<p>{{ item.content }}</p>
</div>
<template #footer>
<div class="card-footer">
<el-tag v-for="tag in item.tags" :key="tag" size="small" class="tag-item">
{{ tag }}
</el-tag>
<span class="stats">
<el-icon><View /></el-icon> {{ item.view_count }}
<el-icon><Document /></el-icon> {{ item.use_count }}
</span>
</div>
</template>
</el-card>
</div>
<!-- 空状态 -->
<el-empty v-else description="暂无知识库条目" />
<!-- ================================================================ -->
<!-- 新增/编辑对话框 -->
<!-- ================================================================ -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑知识' : '新增知识'"
width="600px"
destroy-on-close
>
<el-form :model="form" label-position="top">
<el-form-item label="分类" required>
<el-select v-model="form.category" placeholder="请选择分类" style="width: 100%">
<el-option
v-for="cat in categories.slice(1)"
:key="cat"
:label="cat"
:value="cat"
/>
</el-select>
</el-form-item>
<el-form-item label="问题标题" required>
<el-input
v-model="form.title"
placeholder="请输入问题标题,如:VPN连接失败怎么办?"
maxlength="256"
show-word-limit
/>
</el-form-item>
<el-form-item label="答案内容" required>
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="请输入答案内容"
/>
</el-form-item>
<el-form-item label="标签(用逗号分隔)">
<el-input
v-model="form.tagsInput"
placeholder="如:VPN, 网络, 连接(用逗号分隔)"
@blur="handleTagsBlur"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useKnowledgeStore } from '@/stores/knowledge'
import type { KnowledgeBaseItem } from '@/api/admin'
// ==========================================================================
// Store
// ==========================================================================
const knowledgeStore = useKnowledgeStore()
// ==========================================================================
// 常量
// ==========================================================================
const categories = ['全部', '电脑', '软件', '外设', '网络', '安全', '资产', '其他']
// ==========================================================================
// 搜索和筛选
// ==========================================================================
const searchKeyword = ref<string>('')
function handleSearch(): void {
knowledgeStore.loadKnowledge(
knowledgeStore.activeCategory === '全部' ? undefined : knowledgeStore.activeCategory,
searchKeyword.value || undefined
)
}
function handleCategoryChange(cat: string): void {
knowledgeStore.setCategory(cat)
}
function getCategoryCount(cat: string): number {
if (cat === '全部') return knowledgeStore.items.length
return knowledgeStore.items.filter((item) => item.category === cat).length
}
// ==========================================================================
// 新增/编辑对话框
// ==========================================================================
const dialogVisible = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const loading = ref<boolean>(false)
const editingId = ref<string>('')
const form = ref({
category: '其他',
title: '',
content: '',
tagsInput: '',
tags: [] as string[],
})
function openCreateDialog(): void {
isEdit.value = false
editingId.value = ''
form.value = {
category: '其他',
title: '',
content: '',
tagsInput: '',
tags: [],
}
dialogVisible.value = true
}
function openEditDialog(item: KnowledgeBaseItem): void {
isEdit.value = true
editingId.value = item.id
form.value = {
category: item.category,
title: item.title,
content: item.content,
tagsInput: item.tags.join(', '),
tags: item.tags,
}
dialogVisible.value = true
}
function handleTagsBlur(): void {
if (form.value.tagsInput.trim()) {
form.value.tags = form.value.tagsInput.split(',').map((v) => v.trim()).filter((v) => v)
}
}
async function handleSubmit(): Promise<void> {
if (!form.value.title.trim()) {
ElMessage.warning('请输入问题标题')
return
}
if (!form.value.content.trim()) {
ElMessage.warning('请输入答案内容')
return
}
handleTagsBlur()
loading.value = true
try {
const data = {
category: form.value.category,
title: form.value.title,
content: form.value.content,
tags: form.value.tags,
}
let success: boolean
if (isEdit.value) {
success = await knowledgeStore.update(editingId.value, data)
} else {
success = await knowledgeStore.create(data)
}
if (success) {
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
} else {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
}
} finally {
loading.value = false
}
}
async function handleDelete(item: KnowledgeBaseItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定要删除知识 "${item.title}" 吗?`,
'删除确认',
{ type: 'warning' }
)
const success = await knowledgeStore.remove(item.id)
if (success) {
ElMessage.success('删除成功')
} else {
ElMessage.error('删除失败')
}
} catch {
// 用户取消
}
}
// ==========================================================================
// 初始化
// ==========================================================================
onMounted(() => {
knowledgeStore.loadKnowledge()
})
</script>
<style scoped>
/* 工具栏 */
.knowledge-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 16px;
flex-wrap: wrap;
}
/* 分类标签 */
.category-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
flex: 1;
}
/* 知识列表 */
.knowledge-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 知识卡片 */
.knowledge-card {
margin-bottom: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
display: flex;
align-items: center;
gap: 8px;
}
.title-text {
font-weight: 500;
}
.card-actions {
display: flex;
gap: 8px;
}
.card-content {
color: #666;
font-size: 14px;
line-height: 1.6;
}
.card-content p {
margin: 0;
white-space: pre-wrap;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-item {
margin-right: 4px;
}
.stats {
display: flex;
align-items: center;
gap: 4px;
color: #999;
font-size: 12px;
}
/* 加载状态 */
.loading-state {
padding: 24px 0;
}
</style>
@@ -0,0 +1,509 @@
<!--
=============================================================================
企微IT智能服务台 知识库优化建议审核页 (P2-13)
=============================================================================
说明知识库自动迭代建议的审核管理
- 查看待审核建议列表
- 审核通过/拒绝建议
- 统计分析
-->
<template>
<div class="knowledge-suggestions-page">
<!-- 页面标题 -->
<div class="page-title">知识库优化建议</div>
<div class="page-desc">AI分析标注数据和会话数据生成的优化建议审核通过后自动更新知识库</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<el-card class="stat-card">
<div class="stat-card-content">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总建议数</div>
</div>
</el-card>
<el-card class="stat-card stat-card-warning">
<div class="stat-card-content">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
</el-card>
<el-card class="stat-card stat-card-success">
<div class="stat-card-content">
<div class="stat-value">{{ stats.approved }}</div>
<div class="stat-label">已通过</div>
</div>
</el-card>
<el-card class="stat-card stat-card-danger">
<div class="stat-card-content">
<div class="stat-value">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</el-card>
</div>
<!-- 顶部操作栏 -->
<div class="toolbar">
<!-- 状态筛选 -->
<el-radio-group v-model="filters.status" @change="loadSuggestions">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="pending">待审核</el-radio-button>
<el-radio-button value="approved">已通过</el-radio-button>
<el-radio-button value="rejected">已拒绝</el-radio-button>
<el-radio-button value="applied">已应用</el-radio-button>
</el-radio-group>
<!-- 触发分析按钮 -->
<el-button type="primary" @click="triggerAnalysis">
<el-icon><Refresh /></el-icon>
触发AI分析
</el-button>
</div>
<!-- 建议列表 -->
<el-table :data="suggestions" v-loading="loading" style="width: 100%">
<el-table-column prop="suggestion_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.suggestion_type)">
{{ getTypeLabel(row.suggestion_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_type" label="来源" width="120">
<template #default="{ row }">
<span>{{ getSourceLabel(row.source_type) }}</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="生成理由" min-width="200" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">查看</el-button>
<template v-if="row.status === 'pending'">
<el-button size="small" type="success" @click="approveSuggestion(row)">
通过
</el-button>
<el-button size="small" type="danger" @click="openRejectDialog(row)">
拒绝
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<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"
@size-change="loadSuggestions"
@current-change="loadSuggestions"
/>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="建议详情" width="600px">
<el-descriptions :column="1" border v-if="currentSuggestion">
<el-descriptions-item label="类型">
<el-tag :type="getTypeTagType(currentSuggestion.suggestion_type)">
{{ getTypeLabel(currentSuggestion.suggestion_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="标题">{{ currentSuggestion.title }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ currentSuggestion.category }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentSuggestion.status)">
{{ getStatusLabel(currentSuggestion.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ getSourceLabel(currentSuggestion.source_type) }}</el-descriptions-item>
<el-descriptions-item label="生成理由">{{ currentSuggestion.reason }}</el-descriptions-item>
<el-descriptions-item label="答案内容">
<div class="content-box">{{ currentSuggestion.content }}</div>
</el-descriptions-item>
<el-descriptions-item v-if="currentSuggestion.tags?.length" label="标签">
<el-tag v-for="tag in currentSuggestion.tags" :key="tag" size="small" class="mr-2">
{{ tag }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="currentSuggestion.reject_reason" label="拒绝理由">
<span style="color: var(--el-color-danger)">{{ currentSuggestion.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
<template v-if="currentSuggestion?.status === 'pending'">
<el-button type="success" @click="approveSuggestion(currentSuggestion)">通过</el-button>
<el-button type="danger" @click="openRejectDialog(currentSuggestion)">拒绝</el-button>
</template>
</template>
</el-dialog>
<!-- 拒绝对话框 -->
<el-dialog v-model="rejectDialogVisible" title="拒绝建议" width="400px">
<el-form @submit.prevent="rejectSuggestion">
<el-form-item label="拒绝理由" required>
<el-input
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
placeholder="请输入拒绝理由"
maxlength="500"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" @click="rejectSuggestion" :disabled="!rejectForm.reject_reason">
确认拒绝
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { adminApi } from '@/api/admin'
// 状态筛选
const filters = reactive({
status: '',
})
// 列表数据
const loading = ref(false)
const suggestions = ref([])
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
// 统计数据
const stats = reactive({
total: 0,
pending: 0,
approved: 0,
rejected: 0,
applied: 0,
})
// 详情对话框
const detailDialogVisible = ref(false)
const currentSuggestion = ref(null)
// 拒绝对话框
const rejectDialogVisible = ref(false)
const rejectForm = reactive({
suggestion_id: '',
reject_reason: '',
})
// 加载建议列表
async function loadSuggestions() {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
}
if (filters.status) {
params.status = filters.status
}
const res = await adminApi.getKnowledgeSuggestions(params)
if (res.code === 0) {
suggestions.value = res.data.items
pagination.total = res.data.total
}
} catch (error) {
console.error('加载建议列表失败:', error)
ElMessage.error('加载建议列表失败')
} finally {
loading.value = false
}
}
// 加载统计数据
async function loadStats() {
try {
const res = await adminApi.getKnowledgeSuggestionStats()
if (res.code === 0) {
Object.assign(stats, res.data)
}
} catch (error) {
console.error('加载统计失败:', error)
}
}
// 触发AI分析
async function triggerAnalysis() {
try {
await ElMessageBox.confirm('将分析过去7天的标注数据和会话数据,生成优化建议。是否继续?', '触发分析', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info',
})
const res = await adminApi.triggerKnowledgeIterationAnalysis({ days: 7 })
if (res.code === 0) {
ElMessage.success('分析完成')
loadSuggestions()
loadStats()
} else {
ElMessage.error(res.message || '分析失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('触发分析失败:', error)
ElMessage.error('触发分析失败')
}
}
}
// 查看详情
function viewDetail(row) {
currentSuggestion.value = row
detailDialogVisible.value = true
}
// 通过建议
async function approveSuggestion(row) {
try {
await ElMessageBox.confirm('审核通过后,该建议将应用到知识库。是否继续?', '审核通过', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'success',
})
const res = await adminApi.approveKnowledgeSuggestion(row.id)
if (res.code === 0) {
ElMessage.success('审核通过')
loadSuggestions()
loadStats()
detailDialogVisible.value = false
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('审核通过失败:', error)
ElMessage.error('操作失败')
}
}
}
// 打开拒绝对话框
function openRejectDialog(row) {
rejectForm.suggestion_id = row.id
rejectForm.reject_reason = ''
rejectDialogVisible.value = true
}
// 拒绝建议
async function rejectSuggestion() {
if (!rejectForm.reject_reason) {
ElMessage.warning('请输入拒绝理由')
return
}
try {
const res = await adminApi.rejectKnowledgeSuggestion(rejectForm.suggestion_id, {
reject_reason: rejectForm.reject_reason,
})
if (res.code === 0) {
ElMessage.success('已拒绝')
loadSuggestions()
loadStats()
rejectDialogVisible.value = false
detailDialogVisible.value = false
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('拒绝建议失败:', error)
ElMessage.error('操作失败')
}
}
// 工具函数
function getTypeLabel(type) {
const map = {
new_faq: '新增FAQ',
update: '更新',
outdated: '标记过时',
}
return map[type] || type
}
function getTypeTagType(type) {
const map = {
new_faq: 'success',
update: 'warning',
outdated: 'info',
}
return map[type] || ''
}
function getStatusLabel(status) {
const map = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝',
applied: '已应用',
}
return map[status] || status
}
function getStatusTagType(status) {
const map = {
pending: 'warning',
approved: 'success',
rejected: 'danger',
applied: 'info',
}
return map[status] || ''
}
function getSourceLabel(source) {
const map = {
annotation: '标注数据',
conversation: '会话数据',
ai_uncertain: 'AI不确定',
}
return map[source] || source
}
function formatDate(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
onMounted(() => {
loadSuggestions()
loadStats()
})
</script>
<style scoped>
.knowledge-suggestions-page {
padding: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.page-desc {
color: var(--text-secondary, #909399);
margin-bottom: 20px;
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
text-align: center;
}
.stat-card-content {
padding: 10px 0;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--el-color-primary);
}
.stat-card-warning .stat-value {
color: var(--el-color-warning);
}
.stat-card-success .stat-value {
color: var(--el-color-success);
}
.stat-card-danger .stat-value {
color: var(--el-color-danger);
}
.stat-label {
font-size: 14px;
color: var(--text-secondary, #909399);
margin-top: 4px;
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
/* 分页 */
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* 内容框 */
.content-box {
max-height: 200px;
overflow-y: auto;
padding: 8px;
background: var(--el-fill-color-light);
border-radius: 4px;
white-space: pre-wrap;
}
.mr-2 {
margin-right: 8px;
}
</style>
+321 -5
View File
@@ -1,11 +1,11 @@
<!--
=============================================================================
IT智能服务台 管理员登录页 (v1.1, 2026-07-04)
IT智能服务台 管理员登录页 (v1.2, 2026-07-06)
=============================================================================
说明从用户ID+姓名登录改为账号密码+OTP表单登录
说明智能检测企微登录状态提供三种登录方式
- PRD v1.5 §4.5: 企微免密登录/企微扫码登录/账号密码+OTP
- 复用坐席端登录 APIPOST /api/agents/login
- 登录后检查 role === 'admin' admin 提示"无管理权限"
- 支持账号密码+OTP认证
- 企微免密登录检测到企微登录账号且有管理员角色免密直接进入
-->
<template>
<div class="login-page">
@@ -25,6 +25,50 @@ IT智能服务台 — 管理员登录页 (v1.1, 2026-07-04)
<p class="login-subtitle">管理后台</p>
</div>
<!-- 登录方式选择企微已登录时显示 -->
<div v-if="showLoginOptions" class="login-options">
<p class="login-options-title">选择登录方式</p>
<div class="login-options-btns">
<el-button
type="primary"
size="large"
class="option-btn"
:loading="wecomQuickLoading"
@click="handleWecomQuickLogin"
>
<el-icon><Key /></el-icon>
企微免密登录
</el-button>
<el-button
size="large"
class="option-btn"
@click="showQrLogin"
>
<el-icon><Key /></el-icon>
企微扫码登录
</el-button>
<el-button
size="large"
class="option-btn"
@click="showPasswordLogin"
>
<el-icon><Lock /></el-icon>
账号密码登录
</el-button>
</div>
</div>
<!-- 企微扫码登录面板 -->
<div v-if="showQrPanel" class="qr-login">
<p class="qr-title">企微扫码登录</p>
<div class="qr-code">
<img v-if="qrCode" :src="qrCode" alt="扫码登录二维码" />
<div v-else class="qr-loading">加载中...</div>
</div>
<p class="qr-hint">请使用企业微信扫码登录</p>
<el-button link type="primary" @click="showPasswordLogin">其他登录方式</el-button>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
@@ -105,7 +149,7 @@ IT智能服务台 — 管理员登录页 (v1.1, 2026-07-04)
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAdminStore } from '@/stores/admin'
@@ -138,6 +182,31 @@ const requireOtp = ref(false)
/** 错误信息 */
const errorMsg = ref<string>('')
// ==========================================================================
// 企微智能检测相关状态(PRD v1.5 §4.5
// ==========================================================================
/** 是否显示登录方式选择 */
const showLoginOptions = ref(false)
/** 是否显示扫码登录面板 */
const showQrPanel = ref(false)
/** 是否显示账号密码登录 */
const showPasswordPanel = ref(true)
/** 企微用户ID(用于免密登录) */
const wecomUserId = ref('')
/** 企微快捷登录loading */
const wecomQuickLoading = ref(false)
/** 企微二维码 */
const qrCode = ref('')
/** 企微检测中 */
const wecomChecking = ref(false)
/** 表单校验规则 */
const rules: FormRules = {
userId: [
@@ -156,6 +225,186 @@ const rules: FormRules = {
// 方法
// ==========================================================================
/**
* 检测企微客户端登录状态(PRD v1.5 §4.5
* 页面加载时自动检测
*/
async function checkWecomClient(): Promise<void> {
wecomChecking.value = true
try {
// 首先尝试使用 UA 检测
const isWxWork = /wxwork/i.test(navigator.userAgent)
// 不在企微环境,直接显示账号密码登录
if (!isWxWork) {
showLoginOptions.value = false
showPasswordPanel.value = true
wecomChecking.value = false
return
}
// 在企微客户端中,尝试使用企微 JS-SDK
if (typeof (window as any).wx !== 'undefined') {
try {
// 获取当前页面 URL
const currentUrl = window.location.href.split('#')[0]
// 调用后端接口获取 JS-SDK 签名配置
const apiClient = (await import('@/api')).default
const configResponse = await apiClient.get('/wecom/jsapi-config', {
params: { url: currentUrl }
})
const jsConfig = configResponse
console.log('[Admin Login] 获取到企微 JS-SDK 配置:', jsConfig)
// 使用 wx.config 配置企微 JS-SDK
await new Promise<void>((resolve) => {
(window as any).wx.config({
beta: true,
debug: false,
appId: jsConfig.corp_id,
timestamp: jsConfig.timestamp,
nonceStr: jsConfig.nonce_str,
signature: jsConfig.signature,
jsApiList: ['agentConfig']
})
resolve()
})
// 使用 wx.agentConfig 获取当前用户信息
const userInfo = await new Promise<any>((resolve, reject) => {
(window as any).wx.agentConfig({
corpid: jsConfig.corp_id,
agentid: jsConfig.agent_id,
timestamp: jsConfig.timestamp,
nonceStr: jsConfig.nonce_str,
signature: jsConfig.signature,
success: (res: any) => resolve(res),
fail: (res: any) => reject(new Error(res.errMsg || 'agentConfig 失败'))
})
})
// 获取用户ID
const userId = userInfo.userId
if (userId) {
console.log('[Admin Login] 获取到企微用户 ID:', userId)
wecomUserId.value = userId
// 检测角色
const roleResponse = await apiClient.get('/wecom/check-role', {
params: { userid: userId }
})
const roleData = roleResponse
console.log('[Admin Login] 用户角色:', roleData)
// 如果是管理员,显示免密登录选项
if (roleData.role === 'admin') {
showLoginOptions.value = true
showPasswordPanel.value = false
} else {
// 非管理员,显示普通登录
showLoginOptions.value = false
showPasswordPanel.value = true
}
}
} catch (e) {
console.warn('[Admin Login] 企微 JS-SDK 检测失败:', e)
// 检测失败,显示登录选项
showLoginOptions.value = true
showPasswordPanel.value = true
}
} else {
// 没有企微 JS-SDK
showLoginOptions.value = true
showPasswordPanel.value = true
}
} catch (error) {
console.error('企微客户端检测失败:', error)
showLoginOptions.value = false
showPasswordPanel.value = true
} finally {
wecomChecking.value = false
}
}
/**
* 企微免密登录
*/
async function handleWecomQuickLogin(): Promise<void> {
wecomQuickLoading.value = true
try {
const apiClient = (await import('@/api')).default
// 调用后端企微免密登录接口
const response = await apiClient.post('/auth_wecom/jsdk-login', {
userid: wecomUserId.value,
login_source: 'wecom_jsdk_admin'
})
const result = response
if (result?.token) {
const { token, employee_id, name } = result
// 存储 token
localStorage.setItem('admin_token', token)
if (employee_id) {
localStorage.setItem('admin_user_id', employee_id)
}
// 存储到 store
adminStore.token = token
adminStore.adminUserId = employee_id
ElMessage.success('登录成功')
router.push('/')
} else {
throw new Error('免密登录失败')
}
} catch (error) {
console.error('企微免密登录失败:', error)
ElMessage.error('免密登录失败,请使用其他方式')
} finally {
wecomQuickLoading.value = false
}
}
/**
* 显示扫码登录
*/
async function showQrLogin(): Promise<void> {
showLoginOptions.value = false
showQrPanel.value = true
showPasswordPanel.value = false
// 获取二维码
try {
const apiClient = (await import('@/api')).default
const response = await apiClient.post('/auth_qrcode/create')
const result = response
if (result.code === 0) {
qrCode.value = result.data.qrcode_png_base64
? `data:image/png;base64,${result.data.qrcode_png_base64}`
: result.data.qrcode_url
}
} catch (error) {
console.error('获取二维码失败:', error)
}
}
/**
* 显示账号密码登录
*/
function showPasswordLogin(): Promise<void> {
showLoginOptions.value = false
showQrPanel.value = false
showPasswordPanel.value = true
return Promise.resolve()
}
/**
* 处理登录
*/
@@ -185,6 +434,11 @@ async function handleLogin(): Promise<void> {
errorMsg.value = errMsg
}
}
// 页面加载时检测企微客户端状态
onMounted(() => {
checkWecomClient()
})
</script>
<style scoped>
@@ -274,4 +528,66 @@ async function handleLogin(): Promise<void> {
font-size: 12px;
color: var(--text-muted);
}
/* 登录方式选择 */
.login-options {
text-align: center;
padding: 20px 0;
}
.login-options-title {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.login-options-btns {
display: flex;
flex-direction: column;
gap: 12px;
}
.option-btn {
width: 100%;
height: 44px;
font-size: 14px;
}
/* 扫码登录 */
.qr-login {
text-align: center;
padding: 20px 0;
}
.qr-title {
font-size: 16px;
color: var(--text-primary);
margin-bottom: 16px;
}
.qr-code {
width: 180px;
height: 180px;
margin: 0 auto 16px;
background: var(--bg-secondary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
max-width: 100%;
max-height: 100%;
}
.qr-loading {
color: var(--text-muted);
}
.qr-hint {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 12px;
}
</style>
@@ -221,8 +221,8 @@ async function loadMatrix() {
loading.value = true
try {
const resp = await apiClient.get('/admin/roles/permissions/matrix')
if (resp.data?.code === 0 && resp.data.data) {
matrixData.value = resp.data.data
// 拦截器已统一返回 inner dataScheme A),失败会 reject,故成功即有效数据
matrixData.value = resp
} else {
ElMessage.error('拉取权限矩阵失败')
}
+135 -1
View File
@@ -29,7 +29,7 @@
</div>
<!-- 新增按钮 -->
<el-button type="primary">
<el-button type="primary" @click="createDialogVisible = true">
<el-icon><Plus /></el-icon>
新增模板
</el-button>
@@ -54,6 +54,61 @@
<!-- 空状态 -->
<el-empty v-else description="暂无待审核的快速回复模板" />
<!-- ================================================================ -->
<!-- 新增模板对话框 -->
<!-- ================================================================ -->
<el-dialog
v-model="createDialogVisible"
title="新增快速回复模板"
width="520px"
destroy-on-close
>
<el-form :model="createForm" label-position="top">
<el-form-item label="分类" required>
<el-select v-model="createForm.category" placeholder="请选择分类">
<el-option
v-for="cat in QUICK_REPLY_CATEGORIES"
:key="cat"
:label="cat"
:value="cat"
:disabled="cat === '全部'"
/>
</el-select>
</el-form-item>
<el-form-item label="模板标题" required>
<el-input
v-model="createForm.title"
placeholder="请输入模板标题,如:VPN连接失败排查"
maxlength="128"
show-word-limit
/>
</el-form-item>
<el-form-item label="模板内容" required>
<el-input
v-model="createForm.content"
type="textarea"
:rows="6"
placeholder="请输入模板内容,支持变量替换,如:{employee_name}、{department}等"
/>
</el-form-item>
<el-form-item label="可用变量(用逗号分隔)">
<el-input
v-model="createForm.variables"
placeholder="如:employee_name, department(用逗号分隔)"
@blur="handleVariablesBlur"
/>
</el-form-item>
<el-form-item label="排序权重">
<el-input-number v-model="createForm.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreateConfirm">确认创建</el-button>
</template>
</el-dialog>
<!-- ================================================================ -->
<!-- 驳回原因对话框 -->
<!-- ================================================================ -->
@@ -92,6 +147,7 @@ import QuickReplyCard from '@/components/QuickReplyCard.vue'
import { useQuickReplyStore } from '@/stores/quickReply'
import type { QuickReplyTemplate } from '@/types'
import { QUICK_REPLY_CATEGORIES } from '@/types'
import { createQuickReplyTemplate } from '@/api/admin'
// ==========================================================================
// Store
@@ -103,6 +159,19 @@ const quickReplyStore = useQuickReplyStore()
// ==========================================================================
const categories = [...QUICK_REPLY_CATEGORIES]
// ==========================================================================
// 新增模板对话框
// ==========================================================================
const createDialogVisible = ref<boolean>(false)
const createForm = ref({
category: '其他',
title: '',
content: '',
variables: [] as string[],
sort_order: 0,
})
const createLoading = ref<boolean>(false)
// ==========================================================================
// 驳回对话框
// ==========================================================================
@@ -125,6 +194,71 @@ function getCategoryCount(cat: string): number {
return quickReplyStore.replies.filter((r) => r.category === cat).length
}
// ==========================================================================
// 新增模板操作
// ==========================================================================
/** 处理变量输入(将逗号分隔的字符串转为数组) */
function handleVariablesBlur(): void {
const input = createForm.value.variables as unknown as string
if (typeof input === 'string' && input.trim()) {
createForm.value.variables = input.split(',').map((v) => v.trim()).filter((v) => v)
}
}
/** 确认创建 */
async function handleCreateConfirm(): Promise<void> {
if (!createForm.value.title.trim()) {
ElMessage.warning('请输入模板标题')
return
}
if (!createForm.value.content.trim()) {
ElMessage.warning('请输入模板内容')
return
}
createLoading.value = true
try {
// 处理变量
const variables = Array.isArray(createForm.value.variables)
? createForm.value.variables
: (createForm.value.variables as unknown as string)?.split(',').map((v: string) => v.trim()).filter((v: string) => v) || []
await createQuickReplyTemplate({
id: '',
category: createForm.value.category,
title: createForm.value.title,
content: createForm.value.content,
variables,
sort_order: createForm.value.sort_order,
status: 'approved',
version: 1,
submitted_by: '',
submitted_by_name: '',
created_at: '',
updated_at: '',
} as QuickReplyTemplate)
ElMessage.success('创建成功')
createDialogVisible.value = false
// 重置表单
createForm.value = {
category: '其他',
title: '',
content: '',
variables: [],
sort_order: 0,
}
// 刷新列表
quickReplyStore.loadReplies()
} catch (error) {
console.error('创建失败:', error)
ElMessage.error('创建失败')
} finally {
createLoading.value = false
}
}
// ==========================================================================
// 审核操作
// ==========================================================================