v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+2 -2
View File
@@ -19,8 +19,8 @@ const apiClient: AxiosInstance = axios.create({
// 基础 URL:所有请求会自动加上这个前缀
// 开发环境通过 Vite proxy 转发到后端
baseURL: '/api',
// 请求超时时间(10秒)
timeout: 10000,
// 请求超时时间(30秒,联软API测试等操作可能较慢
timeout: 30000,
// 默认请求头
headers: {
'Content-Type': 'application/json',
+111
View File
@@ -0,0 +1,111 @@
// =============================================================================
// 企微IT智能服务台 — 欢迎与引导配置 API
// =============================================================================
// 说明:封装欢迎页、引导视频、互动教学、主题模板的 API 调用
import apiClient from './index'
// ==========================================================================
// 类型定义
// ==========================================================================
/** 欢迎页配置 */
export interface WelcomePageConfig {
enabled: boolean
title: string
content: string
background_color: string
}
/** 引导视频配置 */
export interface WelcomeVideoConfig {
url: string
filename: string
uploaded_at: string
}
/** 互动教学步骤 */
export interface TutorialStep {
id: string
title: string
description: string
}
/** 互动教学配置 */
export interface TutorialConfig {
enabled: boolean
steps: TutorialStep[]
}
/** 主题模板配置 */
export interface ThemeConfig {
theme: 'default' | 'blue' | 'orange' | 'red'
}
/** 完整欢迎与引导配置 */
export interface WelcomeConfig {
welcome_page: WelcomePageConfig
welcome_video: WelcomeVideoConfig | null
tutorial: TutorialConfig
theme: ThemeConfig
}
// ==========================================================================
// API 函数
// ==========================================================================
/**
* 获取欢迎与引导配置
*/
export function getWelcomeConfig(): Promise<{ data: { code: number; data: WelcomeConfig; message: string } }> {
return apiClient.get('/admin/welcome-config')
}
/**
* 更新欢迎与引导配置
*/
export function updateWelcomeConfig(
data: WelcomeConfig
): Promise<{ data: { code: number; data: WelcomeConfig; message: string } }> {
return apiClient.put('/admin/welcome-config', data)
}
/**
* 上传引导视频
*/
export function uploadWelcomeVideo(
file: File
): Promise<{ data: { code: number; data: { url: string; filename: string; uploaded_at: string }; message: string } }> {
const formData = new FormData()
formData.append('file', file)
return apiClient.post('/admin/welcome-video', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
/**
* 删除引导视频
*/
export function deleteWelcomeVideo(): Promise<{ data: { code: number; data: null; message: string } }> {
return apiClient.delete('/admin/welcome-video')
}
// ==========================================================================
// 预设主题颜色映射
// ==========================================================================
export const THEME_COLORS: Record<string, string> = {
default: '#07C160', // 绿色
blue: '#1989FA', // 蓝色
orange: '#E6A23C', // 橙色
red: '#F56C6C', // 红色
}
export const THEME_LABELS: Record<string, string> = {
default: '默认绿色',
blue: '商务蓝色',
orange: '活力橙色',
red: '热情红色',
}
+1 -1
View File
@@ -111,7 +111,7 @@ async function handleSearch(): Promise<void> {
try {
const response = await globalSearch(query)
results.value = response.data.data.items
results.value = response.items
} catch {
results.value = []
} finally {
+5 -1
View File
@@ -80,6 +80,10 @@
<el-icon><Share /></el-icon>
<span>排查流程图</span>
</el-menu-item>
<el-menu-item index="/welcome-config">
<el-icon><Guide /></el-icon>
<span>欢迎与引导</span>
</el-menu-item>
<!-- 📊 监控与数据 -->
<div class="menu-section-title">📊 监控与数据</div>
@@ -177,7 +181,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, Star, MagicStick, Files, Upload, Filter } 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, MagicStick, Files, Upload, Filter, Guide } from '@element-plus/icons-vue'
// ==========================================================================
// 当前激活菜单
+8 -1
View File
@@ -140,7 +140,14 @@ const routes = [
meta: { title: '运行期日志', requiresAuth: true },
},
{
// P2 占位页:主题模板
// 欢迎与引导配置
path: 'welcome-config',
name: 'WelcomeConfig',
component: () => import('@/views/WelcomeConfig.vue'),
meta: { title: '欢迎与引导', requiresAuth: true },
},
{
// P2 占位页:主题模板(已整合到欢迎与引导配置)
path: 'themes',
name: 'Themes',
component: () => import('@/views/Placeholder.vue'),
+9 -4
View File
@@ -19,6 +19,7 @@ import router from '@/router'
// --------------------------------------------------------------------------
const TOKEN_KEY = 'admin_token'
const ADMIN_USER_ID_KEY = 'admin_user_id'
const ADMIN_INFO_KEY = 'admin_info'
// --------------------------------------------------------------------------
// Store 定义
@@ -28,8 +29,9 @@ export const useAdminStore = defineStore('admin', () => {
// 响应式状态
// ==========================================================================
/** 当前管理员信息 */
const adminInfo = ref<Agent | null>(null)
/** 当前管理员信息(尝试从 localStorage 恢复) */
const storedInfo = localStorage.getItem(ADMIN_INFO_KEY)
const adminInfo = ref<Agent | null>(storedInfo ? JSON.parse(storedInfo) : null)
/** 认证 token */
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
@@ -96,11 +98,13 @@ export const useAdminStore = defineStore('admin', () => {
localStorage.setItem(TOKEN_KEY, data.token || agentInfoData.token)
localStorage.setItem(ADMIN_USER_ID_KEY, inputUserId)
// 保存管理员信息
adminInfo.value = {
// 保存管理员信息(持久化到 localStorage
const adminInfoData = {
...agentInfoData,
role: 'admin',
} as Agent
adminInfo.value = adminInfoData
localStorage.setItem(ADMIN_INFO_KEY, JSON.stringify(adminInfoData))
// 跳转到仪表盘
router.push('/dashboard')
@@ -132,6 +136,7 @@ export const useAdminStore = defineStore('admin', () => {
// 清除 localStorage
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem(ADMIN_INFO_KEY)
// 跳转到登录页
router.push('/login')
+1 -1
View File
@@ -68,7 +68,7 @@ export const useAgentStore = defineStore('agent', () => {
loading.value = true
try {
const response = await apiGetAgents(status)
agents.value = response.data.data.items
agents.value = response.items
} catch (error) {
console.error('加载坐席列表失败:', error)
} finally {
+6 -6
View File
@@ -2,7 +2,7 @@
// 企微IT智能服务台 — 阶段5 自动化闭环 状态管理(Pinia Store,管理后台)
// =============================================================================
// 说明:管理自动化场景配置、规则版本、运营指标。
// 约定:api 返回包装对象,调用方统一从 res.data.data 取业务数据
// 约定:响应拦截器已剥离 axios 外壳与 {code,data,message} 信封,调用方直接使用返回值(即 data 字段本身)
// =============================================================================
import { defineStore } from 'pinia'
@@ -39,7 +39,7 @@ export const useAutomationStore = defineStore('automation', () => {
loading.value = true
try {
const res = await getAutomationConfigs()
scenarios.value = res.data.data
scenarios.value = res
} finally {
loading.value = false
}
@@ -50,7 +50,7 @@ export const useAutomationStore = defineStore('automation', () => {
payload: UpdateScenarioPayload,
): Promise<ScenarioConfig> {
const res = await updateAutomationConfig(id, payload)
const updated = res.data.data
const updated = res
// 本地同步,避免整表刷新
const idx = scenarios.value.findIndex((s) => s.id === id)
if (idx >= 0) scenarios.value[idx] = updated
@@ -63,7 +63,7 @@ export const useAutomationStore = defineStore('automation', () => {
async function loadVersions(scenarioKey: string): Promise<void> {
currentScenarioKey.value = scenarioKey
const res = await getConfigVersions(scenarioKey)
versions.value = res.data.data
versions.value = res
}
async function publishVersion(
@@ -71,7 +71,7 @@ export const useAutomationStore = defineStore('automation', () => {
payload: PublishVersionPayload,
): Promise<RuleVersion> {
const res = await publishConfigVersion(id, payload)
const created = res.data.data
const created = res
versions.value.unshift(created)
return created
}
@@ -81,7 +81,7 @@ export const useAutomationStore = defineStore('automation', () => {
// ------------------------------------------------------------------------
async function loadMetrics(): Promise<void> {
const res = await getAutomationMetrics()
metrics.value = res.data.data
metrics.value = res
}
return {
+3 -4
View File
@@ -53,7 +53,7 @@ export const useConfigStore = defineStore('config', () => {
loading.value = true
try {
const response = await getConfigGroups()
groups.value = response.data.data.groups
groups.value = response.groups
} catch (error) {
console.error('加载配置失败:', error)
} finally {
@@ -68,8 +68,7 @@ export const useConfigStore = defineStore('config', () => {
*/
async function updateConfigValue(key: string, value: string): Promise<boolean> {
try {
const response = await apiUpdateConfig(key, value)
const result = response.data.data
await apiUpdateConfig(key, value)
// 更新本地缓存中的值
for (const group of groups.value) {
@@ -96,7 +95,7 @@ export const useConfigStore = defineStore('config', () => {
historyKey.value = key
try {
const response = await getConfigHistory(key, limit)
historyItems.value = response.data.data.items
historyItems.value = response.items
} catch (error) {
console.error('加载变更历史失败:', error)
}
+1 -1
View File
@@ -59,7 +59,7 @@ export const useKnowledgeStore = defineStore('knowledge', () => {
loading.value = true
try {
const response = await getKnowledgeBase(category, searchKeyword)
items.value = response.data.data.items
items.value = response.items
} catch (error) {
console.error('加载知识库列表失败:', error)
} finally {
+1 -1
View File
@@ -58,7 +58,7 @@ export const useQuickReplyStore = defineStore('quickReply', () => {
loading.value = true
try {
const response = await getPendingQuickReplies(category)
replies.value = response.data.data.items
replies.value = response.items
} catch (error) {
console.error('加载快速回复列表失败:', error)
} finally {
@@ -106,10 +106,8 @@ async function loadPerformance() {
params.date_to = dateRange.value[1]
}
const { data } = await getAgentPerformance(params)
if (data.code === 0) {
agents.value = data.data.items || []
}
const data = await getAgentPerformance(params)
agents.value = data.items || []
} catch (err: any) {
ElMessage.error('加载绩效数据失败')
} finally {
+1 -1
View File
@@ -87,7 +87,7 @@ const currentMode = ref<string>('manual')
onMounted(async () => {
try {
const response = await getAssignmentMode()
const data = response.data.data
const data = response
currentMode.value = data.current_mode
if (data.modes && data.modes.length > 0) {
modes.value = data.modes
+3 -5
View File
@@ -188,11 +188,9 @@ function buildParams(): Record<string, any> {
async function loadLogs() {
loading.value = true
try {
const { data } = await getAuditLogs(buildParams())
if (data.code === 0) {
logs.value = data.data.items || []
pagination.total = data.data.total || 0
}
const data = await getAuditLogs(buildParams())
logs.value = data.items || []
pagination.total = data.total || 0
} catch (err: any) {
ElMessage.error('加载安全审计日志失败')
} finally {
+8 -10
View File
@@ -216,20 +216,18 @@ const dissatisfiedPercentage = computed(() => {
async function loadStats() {
loading.value = true
try {
const { data } = await getEvaluationStats({
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
stats.value = {
total_count: data.total_count,
avg_star_rating: data.avg_star_rating,
star_distribution: data.star_distribution || [],
emoji_distribution: data.emoji_distribution || [],
}
records.value = data.recent_evaluations || []
total.value = data.total_count
} catch (err: any) {
ElMessage.error('加载评价统计数据失败')
} finally {
+13 -10
View File
@@ -262,7 +262,7 @@ onMounted(async () => {
loading.value = true
try {
const response = await getIntegrations()
integrations.value = response.data.data.items
integrations.value = response.items
} catch {
// 使用默认 demo 数据
integrations.value = getDefaultIntegrations()
@@ -522,7 +522,7 @@ async function handleTestLianruanConnection(): Promise<void> {
// 再测试连接
const response = await testLianruanConnection()
testResult.value = response.data.data
testResult.value = response
testResultVisible.value = true
// 如果测试成功,更新本地状态
@@ -541,8 +541,9 @@ async function handleTestLianruanConnection(): Promise<void> {
}
}
}
} catch {
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
} catch (error: any) {
const msg = error?.message || error?.response?.data?.message || '请求失败,请检查网络连接'
testResult.value = { success: false, message: msg }
testResultVisible.value = true
} finally {
testingConnection.value = false
@@ -568,7 +569,7 @@ async function handleTestHuorongConnection(): Promise<void> {
// 再测试连接
const response = await testHuorongConnection()
testResult.value = response.data.data
testResult.value = response
testResultVisible.value = true
// 如果测试成功,更新本地状态
@@ -588,8 +589,9 @@ async function handleTestHuorongConnection(): Promise<void> {
}
}
}
} catch {
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
} catch (error: any) {
const msg = error?.message || error?.response?.data?.message || '请求失败,请检查网络连接'
testResult.value = { success: false, message: msg }
testResultVisible.value = true
} finally {
testingConnection.value = false
@@ -608,7 +610,7 @@ async function handleTest(integration: Integration): Promise<void> {
testingConnection.value = true
try {
const response = await testRagflowConnection()
testResult.value = response.data.data
testResult.value = response
testResultVisible.value = true
// 如果测试成功,更新本地状态
@@ -621,8 +623,9 @@ async function handleTest(integration: Integration): Promise<void> {
}
}
}
} catch {
testResult.value = { success: false, message: '请求失败,请检查网络连接' }
} catch (error: any) {
const msg = error?.message || error?.response?.data?.message || '请求失败,请检查网络连接'
testResult.value = { success: false, message: msg }
testResultVisible.value = true
} finally {
testingConnection.value = false
+6 -2
View File
@@ -200,11 +200,13 @@ async function handleLoginSuccess(token: string, userInfo?: any): Promise<void>
if (userInfo) {
localStorage.setItem('admin_user_id', userInfo.userid)
adminStore.adminUserId = userInfo.userid
adminStore.adminInfo = {
const adminInfoData = {
user_id: userInfo.userid,
name: userInfo.name,
status: 'online',
}
adminStore.adminInfo = adminInfoData
localStorage.setItem('admin_info', JSON.stringify(adminInfoData))
}
ElMessage.success('登录成功')
@@ -244,11 +246,13 @@ async function processOAuthCallback(code: string, state?: string): Promise<void>
adminStore.token = result.token
localStorage.setItem('admin_user_id', result.user_info.userid)
adminStore.adminUserId = result.user_info.userid
adminStore.adminInfo = {
const adminInfoData = {
user_id: result.user_info.userid,
name: result.user_info.name,
status: 'online',
}
adminStore.adminInfo = adminInfoData
localStorage.setItem('admin_info', JSON.stringify(adminInfoData))
ElMessage.success('登录成功')
router.push('/')
+1 -1
View File
@@ -128,7 +128,7 @@ onMounted(async () => {
loading.value = true
try {
const response = await getMonitorSessions()
const data = response.data.data
const data = response
Object.assign(stats, data.stats)
sessions.value = data.items
} catch {
+3 -5
View File
@@ -171,11 +171,9 @@ function buildParams(download = false): Record<string, any> {
async function loadLogs() {
loading.value = true
try {
const { data } = await getRuntimeLogs(buildParams())
if (data.code === 0) {
logs.value = data.data.items || []
pagination.total = data.data.total || 0
}
const data = await getRuntimeLogs(buildParams())
logs.value = data.items || []
pagination.total = data.total || 0
} catch (err: any) {
ElMessage.error('加载运行期日志失败')
} finally {
+19 -9
View File
@@ -20,6 +20,11 @@
<el-option label="服务中" value="serving" />
<el-option label="已结单" value="resolved" />
</el-select>
<el-select v-model="filters.is_archived" placeholder="归档状态" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="未归档" value="false" />
<el-option label="已归档" value="true" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
@@ -52,6 +57,12 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_archived" label="归档" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_archived" type="info" size="small">已归档</el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column prop="assigned_agent_name" label="负责坐席" width="100">
<template #default="{ row }">
{{ row.assigned_agent_name || '—' }}
@@ -166,6 +177,7 @@ const dateRange = ref<[string, string] | null>(null)
const filters = reactive({
keyword: '',
status: '',
is_archived: '',
})
const pagination = reactive({
@@ -184,16 +196,15 @@ async function loadConversations() {
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status) params.status = filters.status
if (filters.is_archived !== '') params.is_archived = filters.is_archived === 'true' // 字符串转布尔
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
}
const data = await getAuditConversations(params)
conversations.value = data.items || []
pagination.total = data.total || 0
} catch (err: any) {
ElMessage.error('加载会话列表失败')
} finally {
@@ -206,10 +217,8 @@ async function showDetail(row: any) {
drawerVisible.value = true
detailLoading.value = true
try {
const { data } = await getAuditConversationDetail(row.id)
if (data.code === 0) {
conversationDetail.value = data.data
}
const data = await getAuditConversationDetail(row.id)
conversationDetail.value = data
} catch (err: any) {
ElMessage.error('加载会话详情失败')
} finally {
@@ -226,6 +235,7 @@ function handleSearch() {
function handleReset() {
filters.keyword = ''
filters.status = ''
filters.is_archived = ''
dateRange.value = null
pagination.page = 1
loadConversations()
+3 -5
View File
@@ -149,11 +149,9 @@ function buildParams(): Record<string, any> {
async function loadLogs() {
loading.value = true
try {
const { data } = await getSystemLogs(buildParams())
if (data.code === 0) {
logs.value = data.data.items || []
pagination.total = data.data.total || 0
}
const data = await getSystemLogs(buildParams())
logs.value = data.items || []
pagination.total = data.total || 0
} catch (err: any) {
ElMessage.error('加载配置变更历史失败')
} finally {
@@ -503,7 +503,7 @@ async function loadStats(): Promise<void> {
try {
// 1. 从 _leak 接口获取高危终端数和终端总数
const leakResp = await getHuorongLeaks({ page: 1, per_page: 1 })
const leakData = leakResp.data.data
const leakData = leakResp
if (!leakData.error) {
stats.high_risk_terminals = leakData.risk_client || 0
if (leakData.all_client) {
@@ -513,7 +513,7 @@ async function loadStats(): Promise<void> {
// 2. 从 _virus_events 接口获取病毒事件统计(type=2=全部)
const virusResp = await getHuorongVirusEvents({ page: 1, per_page: 200 })
const virusData = virusResp.data.data
const virusData = virusResp
if (!virusData.error) {
const items: HuorongVirusEvent[] = virusData.items || []
// 统计有处理失败病毒事件的终端数
@@ -553,7 +553,7 @@ async function loadTerminals(): Promise<void> {
page: currentPage.value,
per_page: pageSize.value,
})
const data = response.data.data
const data = response
// 检查是否有错误(API未配置等)
if (data.error) {
@@ -588,7 +588,7 @@ async function loadLeaks(): Promise<void> {
page: currentPage.value,
per_page: pageSize.value,
})
const data = response.data.data
const data = response
if (data.error) {
connectionError.value = data.error
@@ -616,7 +616,7 @@ async function loadVirusEvents(): Promise<void> {
page: currentPage.value,
per_page: pageSize.value,
})
const data = response.data.data
const data = response
if (data.error) {
connectionError.value = data.error
@@ -692,7 +692,7 @@ async function showTerminalDetail(row: HuorongTerminal): Promise<void> {
try {
const response = await getHuorongTerminalDetail(row.client_id)
const data = response.data.data
const data = response
if (data.error) {
// API错误,用基本数据展示
+709
View File
@@ -0,0 +1,709 @@
<!--
=============================================================================
企微IT智能服务台 欢迎与引导配置页面
=============================================================================
说明管理员工端H5的欢迎页引导视频互动教学主题模板配置
-->
<template>
<div class="welcome-config-page">
<!-- 页面标题 -->
<div class="page-title">欢迎与引导配置</div>
<div class="page-desc">统一管理员工端H5的欢迎内容引导视频互动教学和主题模板</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<el-skeleton :rows="8" animated />
</div>
<!-- 配置内容 -->
<div v-else class="config-content">
<!-- Tab 导航 -->
<el-tabs v-model="activeTab" class="config-tabs">
<!-- Tab 1: 欢迎页 -->
<el-tab-pane label="欢迎页" name="welcome">
<div class="tab-content">
<el-form label-width="120px" label-position="top">
<!-- 启用开关 -->
<el-form-item label="启用欢迎页">
<el-switch
v-model="config.welcome_page.enabled"
@change="handleSave"
/>
<span class="form-tip">关闭后员工首次访问将不显示欢迎页</span>
</el-form-item>
<!-- 标题 -->
<el-form-item label="欢迎标题">
<el-input
v-model="config.welcome_page.title"
placeholder="请输入欢迎标题"
maxlength="50"
show-word-limit
:disabled="!config.welcome_page.enabled"
/>
</el-form-item>
<!-- 欢迎文案 -->
<el-form-item label="欢迎文案">
<el-input
v-model="config.welcome_page.content"
type="textarea"
placeholder="请输入欢迎文案"
:rows="4"
maxlength="500"
show-word-limit
:disabled="!config.welcome_page.enabled"
/>
</el-form-item>
<!-- 背景颜色 -->
<el-form-item label="背景颜色">
<div class="color-picker-wrapper">
<el-color-picker
v-model="config.welcome_page.background_color"
:disabled="!config.welcome_page.enabled"
/>
<el-input
v-model="config.welcome_page.background_color"
class="color-input"
:disabled="!config.welcome_page.enabled"
/>
</div>
<span class="form-tip">选择欢迎页的背景颜色</span>
</el-form-item>
<!-- 预览效果 -->
<el-form-item label="预览效果">
<div
class="welcome-preview"
:style="{ backgroundColor: config.welcome_page.background_color }"
>
<h2>{{ config.welcome_page.title || '欢迎标题' }}</h2>
<p>{{ config.welcome_page.content || '欢迎文案内容...' }}</p>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- Tab 2: 引导视频 -->
<el-tab-pane label="引导视频" name="video">
<div class="tab-content">
<!-- 当前视频信息 -->
<div v-if="config.welcome_video" class="video-info">
<el-card class="video-card">
<template #header>
<div class="video-card-header">
<span>当前视频</span>
<el-button
type="danger"
size="small"
@click="handleDeleteVideo"
>
删除视频
</el-button>
</div>
</template>
<div class="video-player">
<video
:src="config.welcome_video.url"
controls
class="video-element"
/>
</div>
<div class="video-meta">
<p><strong>文件名</strong>{{ config.welcome_video.filename }}</p>
<p><strong>上传时间</strong>{{ formatDate(config.welcome_video.uploaded_at) }}</p>
</div>
</el-card>
</div>
<!-- 无视频提示 -->
<el-empty v-else description="暂无引导视频" />
<!-- 上传视频 -->
<div class="upload-section">
<el-upload
ref="uploadRef"
class="video-uploader"
:auto-upload="false"
:limit="1"
accept=".mp4"
:on-change="handleVideoChange"
:on-exceed="handleExceed"
>
<el-button type="primary">
<el-icon><Upload /></el-icon>
选择视频
</el-button>
<template #tip>
<div class="upload-tip">
支持 .mp4 格式最大 50MB
</div>
</template>
</el-upload>
<el-button
v-if="uploadFile"
type="success"
:loading="uploading"
@click="handleUploadVideo"
>
{{ uploading ? '上传中...' : '上传视频' }}
</el-button>
</div>
</div>
</el-tab-pane>
<!-- Tab 3: 互动教学 -->
<el-tab-pane label="互动教学" name="tutorial">
<div class="tab-content">
<el-form label-width="120px" label-position="top">
<!-- 启用开关 -->
<el-form-item label="启用互动教学">
<el-switch
v-model="config.tutorial.enabled"
@change="handleSave"
/>
<span class="form-tip">开启后员工端将显示交互式使用教程</span>
</el-form-item>
<!-- 教程步骤列表 -->
<el-form-item label="教程步骤">
<div class="steps-container">
<div
v-for="(element, index) in config.tutorial.steps"
:key="element.id"
class="step-item"
>
<div class="step-number">{{ index + 1 }}</div>
<div class="step-content">
<el-input
v-model="element.title"
placeholder="步骤标题"
:disabled="!config.tutorial.enabled"
class="step-title-input"
/>
<el-input
v-model="element.description"
type="textarea"
placeholder="步骤说明"
:rows="2"
:disabled="!config.tutorial.enabled"
class="step-desc-input"
/>
</div>
<div class="step-actions">
<el-button
:icon="ArrowUp"
circle
size="small"
:disabled="!config.tutorial.enabled || index === 0"
@click="moveStepUp(index)"
/>
<el-button
:icon="ArrowDown"
circle
size="small"
:disabled="!config.tutorial.enabled || index === config.tutorial.steps.length - 1"
@click="moveStepDown(index)"
/>
<el-button
type="danger"
:icon="Delete"
circle
:disabled="!config.tutorial.enabled"
@click="removeStep(index)"
/>
</div>
</div>
<el-button
type="primary"
:icon="Plus"
:disabled="!config.tutorial.enabled"
@click="addStep"
>
添加步骤
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
<!-- Tab 4: 主题模板 -->
<el-tab-pane label="主题模板" name="theme">
<div class="tab-content">
<el-form label-width="120px" label-position="top">
<el-form-item label="选择主题">
<el-radio-group
v-model="config.theme.theme"
class="theme-radio-group"
@change="handleSave"
>
<el-radio value="default">
<div class="theme-option">
<div class="theme-color" style="background-color: #07C160" />
<span>默认绿色</span>
</div>
</el-radio>
<el-radio value="blue">
<div class="theme-option">
<div class="theme-color" style="background-color: #1989FA" />
<span>商务蓝色</span>
</div>
</el-radio>
<el-radio value="orange">
<div class="theme-option">
<div class="theme-color" style="background-color: #E6A23C" />
<span>活力橙色</span>
</div>
</el-radio>
<el-radio value="red">
<div class="theme-option">
<div class="theme-color" style="background-color: #F56C6C" />
<span>热情红色</span>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
<!-- 预览效果 -->
<el-form-item label="预览效果">
<div
class="theme-preview"
:style="{ backgroundColor: THEME_COLORS[config.theme.theme] || THEME_COLORS.default }"
>
<div class="theme-preview-content">
<h3>员工端H5主题预览</h3>
<p>当前主题{{ THEME_LABELS[config.theme.theme] }}</p>
<div class="preview-button">主要按钮</div>
</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存配置</el-button>
</el-form-item>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
// ==========================================================================
// 依赖导入
// ==========================================================================
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Upload, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import {
getWelcomeConfig,
updateWelcomeConfig,
uploadWelcomeVideo,
deleteWelcomeVideo,
THEME_COLORS,
THEME_LABELS,
} from '@/api/welcome'
import type { WelcomeConfig, TutorialStep } from '@/api/welcome'
// ==========================================================================
// 响应式状态
// ==========================================================================
const loading = ref<boolean>(true)
const uploading = ref<boolean>(false)
const uploadFile = ref<File | null>(null)
const activeTab = ref<string>('welcome')
// 配置文件
const config = reactive<WelcomeConfig>({
welcome_page: {
enabled: false,
title: '',
content: '',
background_color: '#07C160',
},
welcome_video: null,
tutorial: {
enabled: false,
steps: [],
},
theme: {
theme: 'default',
},
})
// ==========================================================================
// 生命周期
// ==========================================================================
onMounted(async () => {
await loadConfig()
})
// ==========================================================================
// 数据加载
// ==========================================================================
async function loadConfig(): Promise<void> {
try {
loading.value = true
const response = await getWelcomeConfig()
const data = response.data.data
// 合并配置
if (data.welcome_page) {
Object.assign(config.welcome_page, data.welcome_page)
}
if (data.welcome_video) {
config.welcome_video = data.welcome_video
}
if (data.tutorial) {
Object.assign(config.tutorial, data.tutorial)
}
if (data.theme) {
Object.assign(config.theme, data.theme)
}
} catch (error) {
console.error('加载配置失败:', error)
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
// ==========================================================================
// 配置保存
// ==========================================================================
async function handleSave(): Promise<void> {
try {
await updateWelcomeConfig(config)
ElMessage.success('配置已保存')
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存配置失败')
}
}
// ==========================================================================
// 视频上传
// ==========================================================================
function handleVideoChange(file: { raw: File }): void {
uploadFile.value = file.raw
}
function handleExceed(): void {
ElMessage.warning('只能上传一个视频,请先删除当前视频')
}
async function handleUploadVideo(): Promise<void> {
if (!uploadFile.value) return
try {
uploading.value = true
const response = await uploadWelcomeVideo(uploadFile.value)
const data = response.data.data
config.welcome_video = {
url: data.url,
filename: data.filename,
uploaded_at: data.uploaded_at,
}
uploadFile.value = null
ElMessage.success('视频上传成功')
} catch (error) {
console.error('上传视频失败:', error)
ElMessage.error('上传视频失败')
} finally {
uploading.value = false
}
}
async function handleDeleteVideo(): Promise<void> {
try {
await ElMessageBox.confirm('确定要删除当前视频吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await deleteWelcomeVideo()
config.welcome_video = null
uploadFile.value = null
ElMessage.success('视频已删除')
} catch (error) {
if (error !== 'cancel') {
console.error('删除视频失败:', error)
ElMessage.error('删除视频失败')
}
}
}
// ==========================================================================
// 互动教学步骤管理
// ==========================================================================
function addStep(): void {
const newStep: TutorialStep = {
id: `step_${Date.now()}`,
title: '',
description: '',
}
config.tutorial.steps.push(newStep)
}
function removeStep(index: number): void {
config.tutorial.steps.splice(index, 1)
}
function moveStepUp(index: number): void {
if (index > 0) {
const temp = config.tutorial.steps[index]
config.tutorial.steps[index] = config.tutorial.steps[index - 1]
config.tutorial.steps[index - 1] = temp
}
}
function moveStepDown(index: number): void {
if (index < config.tutorial.steps.length - 1) {
const temp = config.tutorial.steps[index]
config.tutorial.steps[index] = config.tutorial.steps[index + 1]
config.tutorial.steps[index + 1] = temp
}
}
// ==========================================================================
// 工具函数
// ==========================================================================
function formatDate(dateString: string): string {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<style scoped>
.welcome-config-page {
padding: 24px;
max-width: 900px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.loading-state {
padding: 24px 0;
}
.config-content {
background: var(--surface);
border-radius: 8px;
padding: 24px;
}
.config-tabs {
margin-top: 16px;
}
.tab-content {
padding: 24px 0;
}
.form-tip {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
}
/* 欢迎页预览 */
.welcome-preview {
padding: 24px;
border-radius: 8px;
color: #fff;
min-height: 120px;
}
.welcome-preview h2 {
margin: 0 0 12px;
font-size: 20px;
}
.welcome-preview p {
margin: 0;
font-size: 14px;
line-height: 1.6;
}
/* 颜色选择器 */
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.color-input {
width: 120px;
}
/* 视频上传 */
.video-info {
margin-bottom: 24px;
}
.video-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.video-player {
margin-bottom: 16px;
}
.video-element {
width: 100%;
max-height: 300px;
border-radius: 8px;
background: #000;
}
.video-meta p {
margin: 8px 0;
font-size: 14px;
color: var(--text-secondary);
}
.upload-section {
display: flex;
align-items: center;
gap: 16px;
}
.upload-tip {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
}
/* 互动教学步骤 */
.steps-container {
width: 100%;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 12px;
}
.step-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--accent);
color: #fff;
border-radius: 50%;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.step-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.step-title-input {
width: 300px;
}
.step-desc-input {
width: 100%;
}
/* 主题选择 */
.theme-radio-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.theme-option {
display: flex;
align-items: center;
gap: 8px;
}
.theme-color {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.theme-preview {
padding: 32px;
border-radius: 8px;
color: #fff;
}
.theme-preview-content h3 {
margin: 0 0 12px;
font-size: 18px;
}
.theme-preview-content p {
margin: 0 0 16px;
font-size: 14px;
}
.preview-button {
display: inline-block;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 14px;
}
</style>