12 Commits
haha ... master

Author SHA1 Message Date
雷校云
c751fd9a6e 代码逻辑检查 2026-03-06 17:13:06 +08:00
ddrwode
68882f94cf ha'ha 2026-03-06 16:35:38 +08:00
27942
bec9710643 haha 2026-03-06 10:05:25 +08:00
雷校云
6f35fde8c3 项目主题样式调整 2026-03-06 01:32:25 +08:00
雷校云
17e64c2c3f 项目主题样式调整 2026-03-06 01:22:22 +08:00
雷校云
18115cb30a 项目样式调整 2026-03-06 00:28:40 +08:00
雷校云
31461499ad 筛选管理弹窗组件修改 2026-03-05 23:27:36 +08:00
雷校云
2106a4a6ae 筛选管理页面接口对接 2026-03-05 20:53:54 +08:00
雷校云
dae0eeab69 复聊话术页面接口对接 2026-03-05 17:41:02 +08:00
雷校云
ff1f5b4cab 数据统计页面接口对接 2026-03-05 15:29:53 +08:00
雷校云
8b5bfa316a 联系方式页面增加搜索功能和导出表格功能 2026-03-04 15:18:33 +08:00
雷校云
a5defe482e 任务列表增加轮询调用 2026-03-03 15:48:42 +08:00
43 changed files with 4369 additions and 1203 deletions

View File

@@ -9,8 +9,7 @@ VITE_APP_BASE_API=/dev-api
VITE_APP_ENV = 'development'
# 接口地址
# VITE_APP_API_URL=http://192.168.0.61:8007 # 本地
VITE_APP_API_URL=http://26.151.107.60:8007 # 本地
VITE_APP_API_URL=http://8.137.99.82:9000
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
VITE_APP_WS_ENDPOINT=

View File

@@ -77,3 +77,20 @@ export const ApiAccountsDelete = (id: string) => {
method: 'delete'
})
}
// 获取招聘筛选项(来自网站同步快照)
export const ApiRecruitFilterOptions = (accountId: string) => {
return request({
url: `/api/filters/options?account_id=${accountId}`,
method: 'get'
})
}
// 触发筛选条件同步(派发任务给 Worker 抓取)
export const ApiRecruitFilterSync = (accountId: string) => {
return request({
url: `/api/filters/sync`,
method: 'post',
data: { account_id: accountId }
})
}

View File

@@ -5,9 +5,45 @@ import request from '@/utils/request'
* */
// 查询所有联系记录(含电话、微信号)
export const ApiContacts = (page: number, page_size: number) => {
export const ApiContacts = (data: any) => {
let start_date = ''
let end_date = ''
if (data.times && data.times.length) {
start_date = data.times[0]
end_date = data.times[1]
}
return request({
url: `/api/contacts?page=${page}&page_size=${page_size}`,
url: `/api/contacts?page=${data.pageNum}&page_size=${data.pageSize}&search=${data.search}&start_date=${start_date}&end_date=${end_date}`,
method: 'get'
})
}
// 导出联系记录为 Excel
export const ApiContactsExport = (data: any) => {
let start_date = ''
let end_date = ''
if (data.times && data.times.length) {
start_date = data.times[0]
end_date = data.times[1]
}
return request({
url: `/api/contacts/export?search=${data.search}&start_date=${start_date}&end_date=${end_date}`,
method: 'get'
})
}
// 获取总览统计数据
export const ApiStats = (data: any) => {
return request({
url: `/api/stats?period=${data.period}`,
method: 'get'
})
}
// 获取总览统计数据
export const ApiStatsDaily = (data: any) => {
return request({
url: `/api/stats/daily?days=${data.days}`,
method: 'get'
})
}

View File

@@ -0,0 +1,109 @@
import request from '@/utils/request'
/*
* 复聊管理
* */
// 获取复聊配置列表
export const ApiFollowupConfigs = () => {
return request({
url: `/api/followup-configs`,
method: 'get'
})
}
// 获取单个复聊配置
export const ApiFollowupConfigsId = (id: string) => {
return request({
url: `/api/followup-configs/${id}`,
method: 'get'
})
}
// 创建复聊配置
export const ApiFollowupConfigsAdd = (data: any) => {
const formData = new FormData()
formData.append('name', data.name)
formData.append('position', data.position)
formData.append('followup_days', data.followup_days)
formData.append('is_active', data.is_active)
return request({
url: `/api/followup-configs`,
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 更新复聊配置
export const ApiFollowupConfigsEditor = (data: any) => {
const formData = new FormData()
if (data.followup_days) formData.append('followup_days', data.followup_days)
formData.append('is_active', data.is_active)
return request({
url: `/api/followup-configs/${data.id}`,
method: 'put',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 删除复聊配置
export const ApiFollowupConfigsDelete = (id: string) => {
return request({
url: `/api/followup-configs/${id}`,
method: 'delete'
})
}
// 获取复聊话术列表
export const ApiFollowupScripts = (data: any) => {
return request({
url: `/api/followup-scripts?config_id=${data.config_id || ''}&day_number=${data.day_number || ''}`,
method: 'get'
})
}
// 创建复聊话术
export const ApiFollowupScriptsAdd = (data: any) => {
const formData = new FormData()
formData.append('config_id', data.config_id)
formData.append('day_number', data.day_number)
formData.append('order', data.order)
formData.append('content', data.content)
return request({
url: `/api/followup-scripts`,
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 更新复聊话术
export const ApiFollowupScriptsEditor = (data: any) => {
const formData = new FormData()
formData.append('content', data.content)
formData.append('order', data.order)
return request({
url: `/api/followup-scripts/${data.id}`,
method: 'put',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 删除复聊话术
export const ApiFollowupScriptsDelete = (id: string) => {
return request({
url: `/api/followup-scripts/${id}`,
method: 'delete'
})
}

View File

@@ -4,7 +4,7 @@ import request from '@/utils/request'
* 任务管理
* */
// 查询任务列表
// 查询指定账号的任务列表
export const ApiTasks = (boss_id: string, page: number, page_size: number) => {
return request({
url: `/api/tasks/${boss_id}?page=${page}&page_size=${page_size}`,
@@ -15,7 +15,7 @@ export const ApiTasks = (boss_id: string, page: number, page_size: number) => {
// 查询指定任务的状态和结果
export const ApiTasksTaskId = (task_id: string) => {
return request({
url: `/api/tasks/${task_id}`,
url: `/api/tasks/detail/${task_id}`,
method: 'get'
})
}
@@ -24,10 +24,13 @@ export const ApiTasksTaskId = (task_id: string) => {
export const ApiTasksAdd = (data: any) => {
const formData = new FormData()
formData.append('task_type', data.task_type)
// formData.append('worker_id', data.worker_id)
// formData.append('account_name', data.account_name)
formData.append('id', data.boss_id)
formData.append('params', data.params)
const rawParams = data?.params
if (typeof rawParams === 'string') {
formData.append('params', rawParams)
} else {
formData.append('params', JSON.stringify(rawParams || {}))
}
return request({
url: `/api/tasks`,
method: 'post',
@@ -38,31 +41,10 @@ export const ApiTasksAdd = (data: any) => {
})
}
// // 编辑申请用印
// export const BusinessEditApplication = (data: any) => {
// const formData = new FormData()
// formData.append('id', data.id)
// formData.append('Printingpurpose', data.Printingpurpose)
// formData.append('case_id', data.case_id)
// formData.append('Reason', data.Reason)
// formData.append('seal_number', data.seal_number)
// formData.append('seal_type', data.seal_type)
// if (isFile(data.file)) formData.append('file', data.file)
// formData.append('approvers', data.approvers)
// return request({
// url: `/business/editApplication`,
// method: 'post',
// data: formData,
// headers: {
// 'Content-Type': 'multipart/form-data'
// }
// })
// }
//
// // 删除指定账号
// export const ApiAccountsDelete = (id: string) => {
// return request({
// url: `/api/accounts/${id}`,
// method: 'delete'
// })
// }
// 取消指定任务
export const ApiTasksCancel = (task_id: string) => {
return request({
url: `/api/tasks/${task_id}/cancel`,
method: 'post'
})
}

View File

@@ -1,191 +0,0 @@
import request from '@/utils/request'
/**
* AI 命令请求参数
*/
export interface AiCommandRequest {
/** 用户输入的自然语言命令 */
command: string
/** 当前页面路由(用于上下文) */
currentRoute?: string
/** 当前激活的组件名称 */
currentComponent?: string
/** 额外上下文信息 */
context?: Record<string, any>
}
/**
* 函数调用参数
*/
export interface FunctionCall {
/** 函数名称 */
name: string
/** 函数描述 */
description?: string
/** 参数对象 */
arguments: Record<string, any>
}
/**
* AI 命令解析响应
*/
export interface AiCommandResponse {
/** 解析日志ID用于关联执行记录 */
parseLogId?: string
/** 是否成功解析 */
success: boolean
/** 解析后的函数调用列表 */
functionCalls: FunctionCall[]
/** AI 的理解和说明 */
explanation?: string
/** 置信度 (0-1) */
confidence?: number
/** 错误信息 */
error?: string
/** 原始 LLM 响应(用于调试) */
rawResponse?: string
}
/**
* AI 命令执行请求
*/
export interface AiExecuteRequest {
/** 关联的解析日志ID */
parseLogId?: string
/** 原始命令(用于审计) */
originalCommand?: string
/** 要执行的函数调用 */
functionCall: FunctionCall
/** 确认模式auto=自动执行, manual=需要用户确认 */
confirmMode?: 'auto' | 'manual'
/** 用户确认标志 */
userConfirmed?: boolean
/** 幂等性令牌(防止重复执行) */
idempotencyKey?: string
/** 当前页面路由 */
currentRoute?: string
}
/**
* AI 命令执行响应
*/
export interface AiExecuteResponse {
/** 是否执行成功 */
success: boolean
/** 执行结果数据 */
data?: any
/** 执行结果说明 */
message?: string
/** 影响的记录数 */
affectedRows?: number
/** 错误信息 */
error?: string
/** 记录ID用于追踪 */
recordId?: string
/** 需要用户确认 */
requiresConfirmation?: boolean
/** 确认提示信息 */
confirmationPrompt?: string
}
export interface AiCommandRecordPageQuery extends PageQuery {
keywords?: string
executeStatus?: string
parseSuccess?: boolean
userId?: number
isDangerous?: boolean
provider?: string
model?: string
functionName?: string
createTime?: [string, string]
}
export interface AiCommandRecordVO {
id: string
userId: number
username: string
originalCommand: string
provider?: string
model?: string
parseSuccess?: boolean
functionCalls?: string
explanation?: string
confidence?: number
parseErrorMessage?: string
inputTokens?: number
outputTokens?: number
totalTokens?: number
parseTime?: number
functionName?: string
functionArguments?: string
executeStatus?: string
executeResult?: string
executeErrorMessage?: string
affectedRows?: number
isDangerous?: boolean
requiresConfirmation?: boolean
userConfirmed?: boolean
executionTime?: number
ipAddress?: string
userAgent?: string
currentRoute?: string
createTime?: string
updateTime?: string
remark?: string
}
/**
* AI 命令 API
*/
class AiCommandApi {
/**
* 解析自然语言命令
*
* @param data 命令请求参数
* @returns 解析结果
*/
static parseCommand(data: AiCommandRequest): Promise<AiCommandResponse> {
return request<any, AiCommandResponse>({
url: '/api/v1/ai/command/parse',
method: 'post',
data
})
}
/**
* 执行已解析的命令
*
* @param data 执行请求参数
* @returns 执行结果数据(成功时返回,失败时抛出异常)
*/
static executeCommand(data: AiExecuteRequest): Promise<any> {
return request<any, any>({
url: '/api/v1/ai/command/execute',
method: 'post',
data
})
}
/**
* 获取命令记录分页列表
*/
static getCommandRecordPage(queryParams: AiCommandRecordPageQuery) {
return request<any, PageResult<AiCommandRecordVO[]>>({
url: '/api/v1/ai/command/records',
method: 'get',
params: queryParams
})
}
/**
* 撤销命令执行(如果支持)
*/
static rollbackCommand(recordId: string) {
return request({
url: `/api/v1/ai/command/rollback/${recordId}`,
method: 'post'
})
}
}
export default AiCommandApi

View File

@@ -1,86 +0,0 @@
import request from '@/utils/request'
const AUTH_BASE_URL = '/api/v1/auth'
const AuthAPI = {
/** 登录接口*/
login(data: LoginFormData) {
const formData = new FormData()
formData.append('username', 'admin')
formData.append('password', data.password)
formData.append('captchaKey', data.captchaKey)
formData.append('captchaCode', data.captchaCode)
return request<any, LoginResult>({
url: `${AUTH_BASE_URL}/login`,
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
/** 刷新 token 接口*/
refreshToken(refreshToken: string) {
return request<any, LoginResult>({
url: `${AUTH_BASE_URL}/refresh-token`,
method: 'post',
params: { refreshToken },
headers: {
Authorization: 'no-auth'
}
})
},
/** 退出登录接口 */
logout() {
return request({
url: `${AUTH_BASE_URL}/logout`,
method: 'delete'
})
},
/** 获取验证码接口*/
getCaptcha() {
return request<any, CaptchaInfo>({
url: `${AUTH_BASE_URL}/captcha`,
method: 'get'
})
}
}
export default AuthAPI
/** 登录表单数据 */
export interface LoginFormData {
/** 用户名 */
username: string
/** 密码 */
password: string
/** 验证码缓存key */
captchaKey: string
/** 验证码 */
captchaCode: string
/** 记住我 */
rememberMe: boolean
}
/** 登录响应 */
export interface LoginResult {
/** 访问令牌 */
accessToken: string
/** 刷新令牌 */
refreshToken: string
/** 令牌类型 */
tokenType: string
/** 过期时间(秒) */
expiresIn: number
}
/** 验证码信息 */
export interface CaptchaInfo {
/** 验证码缓存key */
captchaKey: string
/** 验证码图片Base64字符串 */
captchaBase64: string
}

View File

@@ -1,91 +0,0 @@
import type { InternalAxiosRequestConfig } from 'axios'
import { useUserStoreHook } from '@/store/modules/user-store'
import { AuthStorage, redirectToLogin } from '@/utils/auth'
/**
* 重试请求的回调函数类型
*/
type RetryCallback = () => void
/**
* Token刷新组合式函数
*/
export function useTokenRefresh() {
// Token 刷新相关状态
let isRefreshingToken = false
const pendingRequests: RetryCallback[] = []
/**
* 刷新 Token 并重试请求
*/
async function refreshTokenAndRetry(
config: InternalAxiosRequestConfig,
httpRequest: any
): Promise<any> {
return new Promise((resolve, reject) => {
// 封装需要重试的请求
const retryRequest = () => {
const newToken = AuthStorage.getAccessToken()
if (newToken && config.headers) {
config.headers.Authorization = `Bearer ${newToken}`
}
httpRequest(config).then(resolve).catch(reject)
}
// 将请求加入等待队列
pendingRequests.push(retryRequest)
// 如果没有正在刷新,则开始刷新流程
if (!isRefreshingToken) {
isRefreshingToken = true
useUserStoreHook()
.refreshToken()
.then(() => {
// 刷新成功,重试所有等待的请求
pendingRequests.forEach((callback) => {
try {
callback()
} catch (error) {
console.error('Retry request error:', error)
}
})
// 清空队列
pendingRequests.length = 0
})
.catch(async (error) => {
console.error('Token refresh failed:', error)
// 刷新失败,先 reject 所有等待的请求,再清空队列
const failedRequests = [...pendingRequests]
pendingRequests.length = 0
// 拒绝所有等待的请求
failedRequests.forEach(() => {
reject(new Error('Token refresh failed'))
})
// 跳转登录页
await redirectToLogin('登录状态已失效,请重新登录')
})
.finally(() => {
isRefreshingToken = false
})
}
})
}
/**
* 获取刷新状态(用于外部判断)
*/
function getRefreshStatus() {
return {
isRefreshing: isRefreshingToken,
pendingCount: pendingRequests.length
}
}
return {
refreshTokenAndRetry,
getRefreshStatus
}
}

View File

@@ -2,7 +2,7 @@ export { useStomp } from './websocket/useStomp'
export { useDictSync } from './websocket/useDictSync'
export type { DictMessage } from './websocket/useDictSync'
export { useOnlineCount } from './websocket/useOnlineCount'
export { useTokenRefresh } from './auth/useTokenRefresh'
export { useLayout } from './layout/useLayout'
export { useLayoutMenu } from './layout/useLayoutMenu'

View File

@@ -1,7 +1,6 @@
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onBeforeUnmount, nextTick } from 'vue'
import AiCommandApi from '@/api/ai'
/**
* AI 操作处理器(简化版)
@@ -11,17 +10,17 @@ import AiCommandApi from '@/api/ai'
export type AiActionHandler<T = any> =
| ((args: T) => Promise<void> | void)
| {
/** 执行函数 */
execute: (args: T) => Promise<void> | void
/** 是否需要确认(默认 true */
needConfirm?: boolean
/** 确认消息(支持函数或字符串) */
confirmMessage?: string | ((args: T) => string)
/** 成功消息(支持函数或字符串) */
successMessage?: string | ((args: T) => string)
/** 是否调用后端 API默认 false如果为 true 则自动调用 executeCommand */
callBackendApi?: boolean
}
/** 执行函数 */
execute: (args: T) => Promise<void> | void
/** 是否需要确认(默认 true */
needConfirm?: boolean
/** 确认消息(支持函数或字符串) */
confirmMessage?: string | ((args: T) => string)
/** 成功消息(支持函数或字符串) */
successMessage?: string | ((args: T) => string)
/** 是否调用后端 API默认 false如果为 true 则自动调用 executeCommand */
callBackendApi?: boolean
}
/**
* AI 操作配置
@@ -104,17 +103,8 @@ export function useAiAction(options: UseAiActionOptions = {}) {
// 2. 执行阶段
if (config.callBackendApi) {
// 自动调用后端 API
await AiCommandApi.executeCommand({
originalCommand: action.originalCommand || '',
confirmMode: 'manual',
userConfirmed: true,
currentRoute,
functionCall: {
name: fnCall.name,
arguments: fnCall.arguments
}
})
// AI 后端 API 暂未实现
ElMessage.warning('AI 后端接口暂未实现')
} else {
// 执行自定义函数
await config.execute(fnCall.arguments)
@@ -179,24 +169,8 @@ export function useAiAction(options: UseAiActionOptions = {}) {
}
}
try {
await AiCommandApi.executeCommand({
originalCommand,
confirmMode,
userConfirmed: true,
currentRoute,
functionCall: {
name: functionName,
arguments: args
}
})
ElMessage.success('操作执行成功')
} catch (error: any) {
if (error !== 'cancel') {
throw error
}
}
// AI 后端 API 暂未实现
ElMessage.warning('AI 后端接口暂未实现')
}
/**

View File

@@ -3,21 +3,11 @@
*/
export const enum ApiCodeEnum {
/**
* 成功
* 成功(后端 api_success 返回 code=0
*/
SUCCESS = '00000',
SUCCESS = 0,
/**
* 错误
* 未认证 / Token 无效(后端返回 HTTP 401
*/
ERROR = 'B0001',
/**
* 访问令牌无效或过期
*/
ACCESS_TOKEN_INVALID = 'A0230',
/**
* 刷新令牌无效或过期
*/
REFRESH_TOKEN_INVALID = 'A0231'
UNAUTHORIZED = 401,
}

View File

@@ -24,13 +24,30 @@ defineProps({
width: 100%;
height: $navbar-height;
background-color: $sidebar-logo-background;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); // 添加底部分隔线
padding: 0 20px; // 增加内边距
.title {
flex-shrink: 0;
margin-left: 10px;
font-size: 14px;
font-weight: bold;
color: $sidebar-logo-text-color;
//margin-left: 10px;
font-size: 22px; // 增大字体,匹配示例模板
font-weight: 700; // 加粗
color: #60a5fa; // 使用亮蓝色,匹配示例模板
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); // 添加文字阴影
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&::after {
content: '专业自动化系统'; // 添加副标题
display: block;
font-size: 12px;
font-weight: normal;
opacity: 0.8;
margin-top: 2px;
color: $sidebar-logo-text-color;
}
}
}
</style>

View File

@@ -72,7 +72,8 @@ const menuThemeProps = computed(() => {
theme.value === 'dark' || sidebarColorScheme.value === SidebarColor.CLASSIC_BLUE
return {
backgroundColor: isDarkOrClassicBlue ? variables['menu-background'] : undefined,
// 对于深蓝色侧边栏配色,设置背景为透明以显示伪元素的渐变效果
backgroundColor: isDarkOrClassicBlue ? 'transparent' : undefined,
textColor: isDarkOrClassicBlue ? variables['menu-text'] : undefined,
activeTextColor: isDarkOrClassicBlue ? variables['menu-active-text'] : undefined
}

View File

@@ -184,9 +184,51 @@ html.dark {
}
}
// 深蓝色侧边栏配色(示例模板风格)- 优化对比度
html.sidebar-color-blue {
.el-menu-item:hover {
background-color: $menu-hover;
.el-menu {
// 普通菜单项文字 - 提高对比度
.el-menu-item,
.el-sub-menu__title {
color: #e2e8f0 !important;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.menu-icon,
.el-icon {
color: #94a3b8 !important;
}
}
// 激活菜单项 - 更明显
.el-menu-item.is-active {
background-color: rgba(96, 165, 250, 0.2) !important;
border-left: 4px solid #60a5fa !important;
color: #60a5fa !important;
.menu-icon,
.el-icon {
color: #60a5fa !important;
}
}
// 子菜单标题悬停样式
.el-sub-menu__title:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
// 父菜单激活状态
.el-sub-menu.has-active-child > .el-sub-menu__title {
color: #60a5fa !important;
background-color: rgba(96, 165, 250, 0.15) !important;
.menu-icon,
.el-icon {
color: #60a5fa !important;
}
}
}
}

View File

@@ -53,7 +53,8 @@ const { routes } = useLayoutMenu()
left: 0;
z-index: 999;
width: $sidebar-width;
background-color: $menu-background;
// 对于深蓝色配色方案,不设置背景色以显示渐变效果
background-color: transparent;
transition: width 0.28s;
&--collapsed {
@@ -63,7 +64,8 @@ const { routes } = useLayoutMenu()
.layout-sidebar {
position: relative;
height: 100%;
background-color: var(--menu-background);
// 使用 CSS 变量作为后备,但会被全局样式的伪元素覆盖
background-color: transparent;
transition: width 0.28s;
&.has-logo {

View File

@@ -324,6 +324,44 @@ export const constantRoutes: RouteRecordRaw[] = [
}
}
]
},
{
path: '/data',
name: 'Data',
component: Layout,
meta: {
title: '数据统计',
icon: 'setting'
},
children: [
{
path: 'dataStatistics',
name: 'DataStatistics',
component: () => import('@/views/DataStatistics/index.vue'),
meta: {
title: '数据统计'
}
}
]
},
{
path: '/reconciliation',
name: 'Reconciliation',
component: Layout,
meta: {
title: '复聊管理',
icon: 'setting'
},
children: [
{
path: 'reconciliationManagement',
name: 'ReconciliationManagement',
component: () => import('@/views/ReconciliationManagement/index.vue'),
meta: {
title: '复聊管理'
}
}
]
}
// 注册平台登记
// {

View File

@@ -25,7 +25,7 @@ export const defaultSettings: AppSettings = {
// 语言
language: LanguageEnum.ZH_CN,
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
themeColor: '#4080FF',
themeColor: '#1e40af',
// 是否显示水印
showWatermark: false,
// 水印内容
@@ -48,20 +48,21 @@ export const authConfig = {
*
* 适用场景后端没有刷新接口或不需要自动刷新的项目可设为false
*/
enableTokenRefresh: true
enableTokenRefresh: false
} as const
// 主题色预设 - 经典配色方案
// 主题色预设 - 现代配色方案(基于示例模板风格)
// 注意:修改默认主题色时,需要同步修改 src/styles/variables.scss 中的 primary.base 值
export const themeColorPresets = [
'#4080FF', // Arco Design 蓝 - 现代感强
'#1890FF', // Ant Design 蓝 - 经典商务
'#409EFF', // Element Plus 蓝 - 清新自然
'#FA8C16', // 活力橙 - 温暖友好
'#722ED1', // 优雅紫 - 高端大气
'#13C2C2', // 青色 - 科技
'#52C41A', // 成功绿 - 活力清新
'#F5222D', // 警示红 - 醒目强烈
'#2F54EB', // 深蓝 - 稳重专业
'#EB2F96' // 品红 - 时尚个性
'#1e40af', // Primary Blue - 示例模板主色
'#3b82f6', // Tailwind Blue - 现代科技感
'#2563eb', // Deep Blue - 深邃专业
'#0ea5e9', // Sky Blue - 清新明亮
'#6366f1', // Indigo - 优雅紫色
'#14b8a6', // Teal - 青绿高级
'#f97316', // Orange - 活力温暖
'#8b5cf6', // Violet - 时尚创意
'#ec4899', // Pink - 年轻潮流
'#10b981', // Emerald - 自然清新
'#06b6d4' // Cyan - 科技未来感
]

View File

@@ -1,6 +1,5 @@
import { store } from '@/store'
import AuthAPI from '@/api/auth-api'
import type { UserInfo } from '@/api/system/user-api'
import { AuthStorage } from '@/utils/auth'
@@ -107,31 +106,6 @@ export const useUserStore = defineStore('user', () => {
setPermissionCharactersArr([])
}
/**
* 刷新 token
*/
function refreshToken() {
const refreshToken = AuthStorage.getRefreshToken()
if (!refreshToken) {
return Promise.reject(new Error('没有有效的刷新令牌'))
}
return new Promise<void>((resolve, reject) => {
AuthAPI.refreshToken(refreshToken)
.then((data) => {
const { accessToken, refreshToken: newRefreshToken } = data
// 更新令牌,保持当前记住我状态
AuthStorage.setTokens(accessToken, newRefreshToken, AuthStorage.getRememberMe())
resolve()
})
.catch((error) => {
console.log(' refreshToken 刷新失败', error)
reject(error)
})
})
}
const checkPermission = (permission: string): boolean => {
if (userInfo.value?.permission_data.includes('*:*:*')) {
return true
@@ -149,7 +123,6 @@ export const useUserStore = defineStore('user', () => {
logout,
resetAllState,
resetUserState,
refreshToken,
permissionCharactersArr,
setPermissionCharactersArr,
checkPermission

View File

@@ -1,45 +1,407 @@
$border: 1px solid var(--el-border-color-light);
// 全局变量
$border-radius-base: 12px; // 增大圆角,匹配示例模板
$border-radius-small: 8px;
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-base: 0 4px 6px rgba(0, 0, 0, 0.05); // 调整阴影
$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
$transition-base: all 0.3s ease; // 调整过渡时间
/* el-dialog */
.el-dialog {
border-radius: $border-radius-base;
box-shadow: $shadow-lg;
overflow: hidden;
.el-dialog__header {
padding: 15px 20px;
padding: 20px 24px;
margin: 0;
border-bottom: $border;
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
.el-dialog__title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
}
.el-dialog__body {
padding: 20px;
padding: 24px;
}
.el-dialog__footer {
padding: 15px;
padding: 16px 24px;
border-top: $border;
background-color: #f9fafb;
}
}
/** el-drawer */
.el-drawer {
border-radius: $border-radius-base;
box-shadow: $shadow-lg;
.el-drawer__header {
padding: 15px 20px;
padding: 20px 24px;
margin: 0;
color: inherit;
border-bottom: $border;
background: linear-gradient(135deg, #eff6ff 0%, #fff 100%);
> :first-child {
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
}
.el-drawer__body {
padding: 20px;
padding: 24px;
}
.el-drawer__footer {
padding: 15px;
padding: 16px 24px;
border-top: $border;
background-color: #f9fafb;
}
}
// 抽屉和对话框底部按钮区域
.dialog-footer {
display: flex;
gap: 8px;
gap: 12px;
justify-content: flex-end;
.el-button {
min-width: 88px;
transition: $transition-base;
}
}
// el-card 样式优化
.el-card {
border-radius: $border-radius-base;
border: none;
box-shadow: $shadow-sm;
transition: $transition-base;
overflow: hidden;
&:hover {
box-shadow: $shadow-base;
}
.el-card__header {
padding: 18px 20px;
border-bottom: $border;
background-color: #f9fafb;
font-weight: 600;
color: #1f2937;
}
.el-card__body {
padding: 20px;
}
}
// el-table 样式优化
.el-table {
border-radius: $border-radius-small;
overflow: hidden;
th.el-table__cell {
background-color: #f3f4f6;
color: #374151;
font-weight: 600;
border-bottom: $border;
font-size: 14px;
}
td.el-table__cell {
border-bottom: $border;
transition: $transition-base;
}
tr {
&:hover {
background-color: #f9fafb;
}
}
.el-table__empty-block {
padding: 60px 0;
color: #9ca3af;
}
}
// el-button 样式优化
.el-button {
border-radius: $border-radius-small;
font-weight: 500;
transition: $transition-base;
&--primary {
background: linear-gradient(135deg, #1e40af, #1e3a8a);
&:hover,
&:focus {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(30, 64, 175, 0.3);
}
&:active {
transform: translateY(0);
}
}
&--success {
background: linear-gradient(135deg, #10b981, #059669);
&:hover,
&:focus {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.3);
}
}
&--danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
&:hover,
&:focus {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(239, 68, 68, 0.3);
}
}
&--warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
&:hover,
&:focus {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(245, 158, 11, 0.3);
}
}
}
// el-input 样式优化
.el-input {
.el-input__wrapper {
border-radius: $border-radius-small;
transition: $transition-base;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
box-shadow: 0 0 0 2px rgba(30, 64, 175, 0.1);
}
&.is-focus {
box-shadow: 0 0 0 2px rgba(30, 64, 175, 0.2);
}
}
}
// el-select 样式优化
.el-select {
.el-input__wrapper {
border-radius: $border-radius-small;
}
}
// el-pagination 样式优化
.el-pagination {
padding: 20px 0;
.el-pager li {
border-radius: $border-radius-small;
font-weight: 500;
transition: $transition-base;
&:hover {
transform: scale(1.05);
}
&.is-active {
background-color: #1e40af;
color: #fff;
}
}
.el-pagination__jump {
.el-input__wrapper {
border-radius: $border-radius-small;
}
}
}
// el-switch 样式优化
.el-switch {
&.is-checked {
.el-switch__core {
background-color: #10b981;
}
}
.el-switch__core {
border-radius: 10px;
transition: $transition-base;
}
}
// el-message 样式优化
.el-message {
border-radius: $border-radius-base;
box-shadow: $shadow-lg;
padding: 16px 20px;
font-weight: 500;
&--success {
background: linear-gradient(135deg, #ecfdf5 0%, #fff 100%);
border: 1px solid #d1fae5;
}
&--error {
background: linear-gradient(135deg, #fef2f2 0%, #fff 100%);
border: 1px solid #fee2e2;
}
&--warning {
background: linear-gradient(135deg, #fffbeb 0%, #fff 100%);
border: 1px solid #fef3c7;
}
&--info {
background: linear-gradient(135deg, #f3f4f6 0%, #fff 100%);
border: 1px solid #e5e7eb;
}
}
// el-tag 样式优化
.el-tag {
border-radius: $border-radius-small;
border: none;
font-weight: 500;
padding: 4px 12px;
}
// el-form 样式优化
.el-form {
.el-form-item__label {
font-weight: 500;
color: #374151;
}
.el-form-item__content {
.el-input,
.el-select,
.el-date-editor {
width: 100%;
}
}
}
// el-dropdown 样式优化
.el-dropdown-menu {
border-radius: $border-radius-base;
box-shadow: $shadow-base;
padding: 8px 0;
.el-dropdown-menu__item {
padding: 10px 16px;
transition: $transition-base;
&:hover {
background-color: #eff6ff;
color: #1d4ed8;
}
}
}
// el-progress 样式优化
.el-progress {
.el-progress__text {
font-weight: 600;
font-size: 14px;
}
&--line {
.el-progress-bar {
border-radius: 10px;
.el-progress-bar__outer {
border-radius: 10px;
background-color: #e5e7eb;
}
.el-progress-bar__inner {
border-radius: 10px;
background: linear-gradient(90deg, #1e40af, #3b82f6); // 使用渐变色
}
}
}
}
// el-radio & checkbox 样式优化
.el-radio,
.el-checkbox {
.el-radio__input.is-checked,
.el-checkbox__input.is-checked {
.el-radio__inner,
.el-checkbox__inner {
background-color: #1e40af;
border-color: #1e40af;
}
}
.el-radio__input.is-checked + .el-radio__label,
.el-checkbox__input.is-checked + .el-checkbox__label {
color: #1e40af;
font-weight: 500;
}
}
// el-alert 样式优化
.el-alert {
border-radius: $border-radius-base;
border: none;
padding: 16px 20px;
font-weight: 500;
}
// el-collapse 样式优化
.el-collapse {
border: none;
.el-collapse-item__header {
border: none;
margin-bottom: 8px;
border-radius: $border-radius-base;
padding: 14px 18px;
background-color: #f9fafb;
font-weight: 500;
color: #374151;
transition: $transition-base;
&:hover {
background-color: #f3f4f6;
}
&.is-active {
background-color: #eff6ff;
color: #1d4ed8;
}
}
.el-collapse-item__wrap {
border: none;
margin-bottom: 8px;
border-radius: $border-radius-base;
background-color: #fff;
}
}

View File

@@ -1,19 +1,29 @@
@use "./reset";
@use "./element-plus";
@use "./template-style"; // 示例模板风格
@use "./ops-ui";
// Vxe Table
@use "./vxe-table";
@import url("./vxe-table.css");
// 全局变量
$border-radius-base: 12px; // 匹配示例模板的大圆角
$border-radius-small: 8px;
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$shadow-base: 0 4px 6px rgba(0, 0, 0, 0.05); // 柔和阴影
$transition-base: all 0.3s ease;
.app-container {
padding: 15px;
padding: 25px; // 增加内边距,更加疏朗
}
// 进度条颜色
#nprogress .bar {
background-color: var(--el-color-primary);
height: 3px;
}
// 混合布局左侧菜单的hover样式
// 混合布局左侧菜单的 hover 样式
.layout-mix .layout__sidebar--left .el-menu {
.el-menu-item {
&:hover {
@@ -30,24 +40,38 @@
}
}
// 深色主题或深蓝色侧边栏配色下的左侧菜单hover样式
// 深色主题或深蓝色侧边栏配色下的左侧菜单 hover 样式
html.dark .layout-mix .layout__sidebar--left .el-menu,
html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
.el-menu-item {
&:hover {
// 深色背景使用CSS变量
// 深色背景:使用 CSS 变量
background-color: var(--menu-hover) !important;
}
}
.el-sub-menu__title {
&:hover {
// 深色背景使用CSS变量
// 深色背景:使用 CSS 变量
background-color: var(--menu-hover) !important;
}
}
}
// 深蓝色侧边栏渐变背景(示例模板风格)- 修复空白问题
html.sidebar-color-blue {
// 侧边栏容器应用渐变背景
.layout__sidebar,
.layout-sidebar {
background: linear-gradient(180deg, #1e293b, #0f172a) !important;
}
// 菜单容器设置为透明
.el-menu.el-menu--vertical {
background-color: transparent !important;
}
}
// 窄屏时隐藏菜单文字,只显示图标
.hideSidebar {
// Top布局和Mix布局的水平菜单
@@ -76,46 +100,95 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
// 全局搜索区域样式
.search-container {
padding: 18px 16px 0;
margin-bottom: 16px;
padding: 25px 20px; // 增加内边距
margin-bottom: 25px;
background-color: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
border-radius: $border-radius-base;
box-shadow: $shadow-base; // 使用更明显的阴影
.search-buttons {
margin-right: 0;
}
.el-form-item {
margin-bottom: 18px;
margin-bottom: 20px;
}
}
// 表格区域样式
.data-table {
margin-bottom: 16px;
margin-bottom: 25px;
background-color: var(--el-bg-color-overlay);
border-radius: $border-radius-base;
padding: 25px;
box-shadow: $shadow-base;
// 表格工具栏区域
&__toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 20px;
&--actions,
&--tools {
display: flex;
gap: 8px;
gap: 12px; // 增大按钮间距
}
}
// 表格内容区域
&__content {
margin: 8px 0;
margin: 0;
}
// 分页区域
.el-pagination {
justify-content: flex-end;
margin-top: 16px;
margin-top: 25px;
}
}
// 菜单样式优化 - 提高对比度和可见性
html.sidebar-color-blue {
.el-menu {
// 普通菜单项文字 - 提高对比度
.el-menu-item,
.el-sub-menu__title {
color: #e2e8f0 !important;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.menu-icon,
.el-icon {
color: #94a3b8 !important;
}
}
// 激活菜单项 - 更明显
.el-menu-item.is-active {
background-color: rgba(96, 165, 250, 0.2) !important;
border-left: 4px solid #60a5fa !important;
color: #60a5fa !important;
.menu-icon,
.el-icon {
color: #60a5fa !important;
}
}
// 父菜单激活状态
.el-sub-menu.has-active-child > .el-sub-menu__title {
color: #60a5fa !important;
background-color: rgba(96, 165, 250, 0.15) !important;
.menu-icon,
.el-icon {
color: #60a5fa !important;
}
}
}
}

79
src/styles/ops-ui.scss Normal file
View File

@@ -0,0 +1,79 @@
:root {
--ops-radius-lg: 14px;
--ops-radius-md: 10px;
--ops-border-color: #e8edf5;
--ops-soft-bg: #f7f9fc;
--ops-hover-bg: #f5f9ff;
--ops-title-color: #1f2d3d;
--ops-text-regular: #303133;
--ops-text-secondary: #909399;
}
.ops-page {
.ops-card {
border: 1px solid var(--ops-border-color);
border-radius: var(--ops-radius-lg);
}
.ops-section-title {
color: var(--ops-title-color);
font-weight: 700;
letter-spacing: 0.25px;
}
.ops-table {
.el-table__header th {
background: var(--ops-soft-bg);
color: #475569;
}
.el-table__row:hover > td {
background: var(--ops-hover-bg) !important;
}
}
.ops-status-tag {
min-width: 64px;
justify-content: center;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.2px;
}
.ops-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.ops-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ops-btn {
--el-button-border-radius: 10px;
min-height: 32px;
padding: 0 14px;
font-weight: 600;
letter-spacing: 0.2px;
}
.ops-btn.el-button--small {
min-height: 28px;
padding: 0 10px;
}
}
@media (max-width: 768px) {
.ops-page {
.ops-btn {
min-height: 30px;
padding: 0 12px;
}
}
}

View File

@@ -0,0 +1,300 @@
/**
* 示例模板风格卡片样式
* 基于页面模板的现代简约设计风格
*/
// 全局变量
$card-border-radius: 12px;
$card-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
$card-hover-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
$transition-base: all 0.3s ease;
// 通用卡片容器
.page-card {
background: #ffffff;
border-radius: $card-border-radius;
padding: 25px;
margin-bottom: 25px;
box-shadow: $card-shadow;
border: 1px solid #e2e8f0;
transition: $transition-base;
&:hover {
box-shadow: $card-hover-shadow;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #e2e8f0;
h3 {
color: #1e40af; // 主题色
font-size: 18px;
font-weight: 600;
margin: 0;
}
}
}
// 统计卡片 - 仿示例模板的 stats-grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
.stat-card {
background: #ffffff;
padding: 20px;
border-radius: $card-border-radius;
border: 1px solid #e2e8f0;
transition: $transition-base;
&:hover {
transform: translateY(-5px);
box-shadow: $card-hover-shadow;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
.stat-icon {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
&.icon-blue {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
}
&.icon-green {
background: linear-gradient(135deg, #10b981, #059669);
}
&.icon-orange {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
&.icon-purple {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
}
}
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1e293b;
margin-bottom: 5px;
}
.stat-label {
color: #64748b;
font-size: 14px;
}
}
}
// 按钮样式 - 仿示例模板的渐变效果
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: $transition-base;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
&-primary {
background: linear-gradient(135deg, #1e40af, #1e3a8a);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(30, 64, 175, 0.3);
}
}
&-success {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(16, 185, 129, 0.3);
}
}
&-danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(239, 68, 68, 0.3);
}
}
&-warning {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(245, 158, 11, 0.3);
}
}
&-secondary {
background: #f8fafc;
color: #1e293b;
border: 1px solid #e2e8f0;
&:hover {
background: #e2e8f0;
}
}
&-sm {
padding: 6px 12px;
font-size: 12px;
}
}
// 表格样式 - 仿示例模板
.data-table-modern {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
th {
background: #f8fafc;
padding: 15px;
text-align: left;
font-weight: 600;
color: #64748b;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 15px;
border-bottom: 1px solid #e2e8f0;
}
tr {
&:hover {
background: #f8fafc;
}
&.selected {
background: rgba(30, 64, 175, 0.05);
}
}
}
// 进度条样式
.progress-bar-modern {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1e40af, #3b82f6);
border-radius: 4px;
transition: width 0.3s ease;
}
}
// 状态标签
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-align: center;
&.status-running {
background: #d1fae5;
color: #065f46;
}
&.status-paused {
background: #fef3c7;
color: #92400e;
}
&.status-stopped {
background: #fee2e2;
color: #991b1b;
}
}
// 状态指示点
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
&.status-online {
background-color: #10b981;
box-shadow: 0 0 10px #10b981;
}
&.status-offline {
background-color: #ef4444;
}
}
// 动画效果
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.pulse {
animation: pulse 2s infinite;
}
// 批量操作栏
.batch-controls {
background: #ffffff;
border-radius: $card-border-radius;
padding: 20px;
margin-top: 20px;
border: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
.batch-info {
display: flex;
align-items: center;
gap: 15px;
}
.batch-actions {
display: flex;
gap: 15px;
}
}

View File

@@ -2,49 +2,54 @@
$colors: (
"primary": (
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
"base": #4080ff,
"base": #1e40af,
),
"success": (
"base": #23c343,
"base": #10b981,
),
"warning": (
"base": #ff9a2e,
"base": #f59e0b,
),
"danger": (
"base": #f76560,
"base": #ef4444,
),
"info": (
"base": #a9aeb8,
"base": #0ea5e9,
),
),
$bg-color: (
"page": #f5f8fd,
"page": #f8fafc,
)
);
/** 全局SCSS变量 */
:root {
--menu-background: #fff; // 菜单背景色
--menu-text: #212121; // 菜单文字颜色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
--menu-active-text: var(
--el-menu-active-color
); // 菜单激活文字颜色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
--menu-hover: #e6f4ff; // 菜单悬停背景色 浅色主题-白色侧边栏配色下仅占位,实际颜色由 el-menu-item 组件决定
--sidebar-logo-background: #f5f5f5; // 侧边栏 Logo 背景色
--sidebar-logo-text-color: #333; // 侧边栏 Logo 文字颜色
--menu-background: #fff;
--menu-text: #1f2937;
--menu-active-text: var(--el-menu-active-color);
--menu-hover: #eff6ff;
--sidebar-logo-background: #f8fafc;
--sidebar-logo-text-color: #1e293b;
// 卡片和边框样式
--card-border-radius: 12px;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
--border-color: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #64748b;
}
/** 浅色主题-深蓝色侧边栏配色 */
/** 浅色主题 - 深蓝色侧边栏配色(仿示例模板风格) */
html.sidebar-color-blue {
--menu-background: #304156; // 菜单背景色
--menu-text: #bfcbd9; // 菜单文字颜色
--menu-active-text: var(--el-menu-active-color); // 菜单激活文字颜色
--menu-hover: #263445; // 菜单悬停背景色
--sidebar-logo-background: #2d3748; // 侧边栏 Logo 背景色
--sidebar-logo-text-color: #fff; // 侧边栏 Logo 文字颜色
--menu-background: linear-gradient(180deg, #1e293b, #0f172a);
--menu-text: #94a3b8;
--menu-active-text: #60a5fa;
--menu-hover: rgba(255, 255, 255, 0.08);
--sidebar-logo-background: #0f172a;
--sidebar-logo-text-color: #f1f5f9;
// 激活项的左边框颜色
--menu-active-border: #60a5fa;
}
/** 暗黑主题 */
@@ -87,7 +92,7 @@ $menu-hover: var(--menu-hover); // 菜单悬停背景色
$sidebar-logo-background: var(--sidebar-logo-background); // 侧边栏 Logo 背景色
$sidebar-logo-text-color: var(--sidebar-logo-text-color); // 侧边栏 Logo 文字颜色
$sidebar-width: 210px; // 侧边栏宽度
$sidebar-width: 260px; // 侧边栏宽度
$sidebar-width-collapsed: 54px; // 侧边栏收缩宽度
$navbar-height: 50px; // 导航栏高度
$navbar-height: 100px; // 导航栏高度
$tags-view-height: 34px; // TagsView 高度

View File

@@ -3,7 +3,7 @@ declare global {
* 响应数据
*/
interface ApiResponse<T = any> {
code: string
code: number
data: T
msg: string
}
@@ -99,7 +99,7 @@ declare global {
*/
interface ExcelResult {
/** 状态码 */
code: string
code: number
/** 无效数据条数 */
invalidCount: number
/** 有效数据条数 */
@@ -108,4 +108,4 @@ declare global {
messageList: Array<string>
}
}
export {}
export { }

View File

@@ -187,11 +187,19 @@ export function deepCloneByJSON(obj: any) {
}
/**
* 将 ISO 8601 时间格式转换为年月日时分秒格式
* 将 ISO 8601 时间格式转换为指定格式
* @param isoString ISO 8601 格式的时间字符串,如 "2026-02-28T14:10:51.966269"
* @returns 格式化后的时间字符串,如 "2026-02-28 14:10:51"
* @param format 格式化模板,默认 'YYYY-MM-DD HH:mm:ss'
* @returns 格式化后的时间字符串
*
* @example
* formatISOToDateTime('2026-02-28T14:10:51.966269') // '2026-02-28 14:10:51'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY-MM-DD') // '2026-02-28'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY/MM/DD HH:mm') // '2026/02/28 14:10'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'MM-DD HH:mm') // '02-28 14:10'
* formatISOToDateTime('2026-02-28T14:10:51.966269', 'YYYY 年 MM 月 DD 日') // '2026 年 02 月 28 日'
*/
export function formatISOToDateTime(isoString: string): string {
export function formatISOToDateTime(isoString: string, format = 'YYYY-MM-DD HH:mm:ss'): string {
if (!isoString) return ''
try {
@@ -210,7 +218,14 @@ export function formatISOToDateTime(isoString: string): string {
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
// 替换格式化模板中的占位符
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
} catch (error) {
console.error('日期格式化错误:', error)
return isoString

View File

@@ -2,11 +2,6 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from 'axio
import qs from 'qs'
import { ApiCodeEnum } from '@/enums/api/code-enum'
import { AuthStorage, redirectToLogin } from '@/utils/auth'
import { useTokenRefresh } from '@/composables/auth/useTokenRefresh'
import { authConfig } from '@/settings'
// 初始化token刷新组合式函数
const { refreshTokenAndRetry } = useTokenRefresh()
/**
* 创建 HTTP 请求实例
@@ -50,8 +45,8 @@ httpRequest.interceptors.response.use(
}
const { code, msg } = response.data
// 请求成功
if (code === ApiCodeEnum.SUCCESS || code == '0' || code == '200') {
// 请求成功:后端成功响应 code === 0
if (code === ApiCodeEnum.SUCCESS) {
return response.data as any
}
@@ -62,7 +57,7 @@ httpRequest.interceptors.response.use(
async (error) => {
console.error('Response interceptor error:', error)
const { config, response } = error
const { response } = error
// 网络错误或服务器无响应
if (!response) {
@@ -70,29 +65,16 @@ httpRequest.interceptors.response.use(
return Promise.reject(error)
}
const { code, message } = response.data as any
switch (code) {
case ApiCodeEnum.ACCESS_TOKEN_INVALID:
// Access Token 过期
if (authConfig.enableTokenRefresh) {
// 启用了token刷新尝试刷新
return refreshTokenAndRetry(config, httpRequest)
} else {
// 未启用token刷新直接跳转登录页
await redirectToLogin('登录已过期,请重新登录')
return Promise.reject(new Error(message || 'Access Token Invalid'))
}
case ApiCodeEnum.REFRESH_TOKEN_INVALID:
// Refresh Token 过期,跳转登录页
await redirectToLogin('登录已过期,请重新登录')
return Promise.reject(new Error(message || 'Refresh Token Invalid'))
default:
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message || 'Request Error'))
// HTTP 401Token 无效或过期,直接跳转登录页
if (response.status === 401) {
await redirectToLogin('登录已过期,请重新登录')
return Promise.reject(new Error('Token Invalid'))
}
// 其他错误
const msg = response.data?.msg || response.data?.message || '请求失败'
ElMessage.error(msg)
return Promise.reject(new Error(msg))
}
)

View File

@@ -0,0 +1,413 @@
<template>
<div class="recruit-task-form">
<el-alert
v-if="syncing"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
title="正在从网页同步筛选条件,请稍候..."
/>
<el-alert
v-else-if="syncedAt"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
:title="`筛选项同步时间:${syncedAt}`"
/>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px"
title="当前账号暂无筛选项,请先启动客户端同步筛选项"
/>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="auto" label-position="top">
<!-- 分组筛选条件排除"年龄"分组 -->
<el-form-item label="筛选条件(可多选)" prop="selected_filters">
<div v-if="optionsLoading" class="filter-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="displayGroups.length === 0" class="filter-empty">
暂无可选筛选项
</div>
<div v-else class="filter-groups">
<div v-for="group in displayGroups" :key="group.name" class="filter-group">
<div class="filter-group__title">{{ group.name }}</div>
<el-checkbox-group v-model="formData.selected_filters" class="filter-group__options">
<el-checkbox
v-for="opt in group.options"
:key="group.name + ':' + opt"
:label="opt"
:value="group.name + ':' + opt"
border
size="small"
/>
</el-checkbox-group>
</div>
</div>
</el-form-item>
<!-- 年龄区间独立控制不走浏览器筛选 -->
<el-form-item label="年龄范围(从候选人信息过滤,不在网页点击筛选)">
<div class="age-range">
<el-input-number
v-model="formData.age_min"
:min="16"
:max="65"
:step="1"
controls-position="right"
placeholder="最小"
style="width: 140px"
/>
<span class="age-range__sep"></span>
<el-input-number
v-model="formData.age_max"
:min="16"
:max="65"
:step="1"
controls-position="right"
placeholder="最大"
style="width: 140px"
/>
<span class="age-range__unit"></span>
<el-button link type="primary" @click="clearAgeRange" style="margin-left: 8px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="招聘人数" prop="greet_target">
<el-input-number
v-model="formData.greet_target"
:min="1"
:max="500"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="启用问候语数量" prop="greeting_count">
<el-input-number
v-model="formData.greeting_count"
:min="1"
:max="10"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="问候语内容(多条时随机发送)" prop="greeting_messages">
<div class="greeting-list">
<div v-for="(_, index) in formData.greeting_messages" :key="`greeting-${index}`" class="greeting-item">
<div class="greeting-item__label">问候语 {{ index + 1 }}</div>
<el-input
v-model="formData.greeting_messages[index]"
type="textarea"
:rows="2"
placeholder="请输入问候语"
/>
</div>
</div>
<div class="greeting-tip">
系统内置问候语{{ DEFAULT_GREETING_MESSAGE }}
</div>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { ApiRecruitFilterOptions, ApiRecruitFilterSync } from '@/api/BoosAccountManagement'
interface FilterGroup {
name: string
order: number
options: string[]
}
const DEFAULT_GREETING_MESSAGE = '当前岗位还在招,加个微信了解一下吗?'
const props = defineProps({
accountId: {
type: String,
default: ''
}
})
const formRef = ref()
const optionsLoading = ref(false)
const filterGroups = ref<FilterGroup[]>([])
const syncedAt = ref('')
const syncing = ref(false)
// 排除"年龄"相关的分组,展示给用户的分组
const displayGroups = computed(() => {
return filterGroups.value.filter(
(g) => !g.name.includes('年龄')
)
})
const formData = reactive({
selected_filters: [] as string[],
greet_target: 20,
age_min: null as number | null,
age_max: null as number | null,
greeting_count: 1,
greeting_messages: [DEFAULT_GREETING_MESSAGE] as string[]
})
const formRules = reactive<any>({
greet_target: [{ required: true, message: '请输入招聘人数', trigger: 'blur' }],
greeting_count: [{ required: true, message: '请输入问候语数量', trigger: 'blur' }]
})
const clearAgeRange = () => {
formData.age_min = null
formData.age_max = null
}
let pollTimer: ReturnType<typeof setInterval> | null = null
const fetchOptions = () => {
if (!props.accountId) return
optionsLoading.value = true
ApiRecruitFilterOptions(props.accountId)
.then((res: any) => {
filterGroups.value = (res.data?.groups || []) as FilterGroup[]
syncedAt.value = res.data?.synced_at || ''
// 没有筛选数据时自动触发同步
if (filterGroups.value.length === 0 && !syncing.value) {
triggerSync()
}
})
.finally(() => {
optionsLoading.value = false
})
}
const triggerSync = () => {
if (!props.accountId || syncing.value) return
syncing.value = true
ApiRecruitFilterSync(props.accountId)
.then(() => {
ElMessage.info('正在同步筛选条件,请稍候...')
startPolling()
})
.catch((err: any) => {
syncing.value = false
const msg = err?.response?.data?.msg || err?.msg || '同步请求失败'
ElMessage.warning(msg)
})
}
const startPolling = () => {
stopPolling()
let attempts = 0
const maxAttempts = 20
pollTimer = setInterval(() => {
attempts++
if (attempts > maxAttempts) {
stopPolling()
syncing.value = false
ElMessage.warning('同步超时,请稍后重试')
return
}
ApiRecruitFilterOptions(props.accountId)
.then((res: any) => {
const groups = (res.data?.groups || []) as FilterGroup[]
if (groups.length > 0) {
filterGroups.value = groups
syncedAt.value = res.data?.synced_at || ''
stopPolling()
syncing.value = false
ElMessage.success('筛选条件同步完成')
}
})
}, 3000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
normalizeGreetingSlots()
fetchOptions()
})
onUnmounted(() => {
stopPolling()
})
const getForm = () => {
// 内部 value 格式为 "groupName:optName",返回给后端时只取选项名部分
// 并过滤掉"不限",勾选不限等于不加筛选条件,无需传给后端
const filters = formData.selected_filters
.map((v) => {
const idx = v.indexOf(':')
return idx !== -1 ? v.slice(idx + 1) : v
})
.filter((opt) => opt !== '不限')
const greetings = formData.greeting_messages
.slice(0, Number(formData.greeting_count) || 1)
.map((text) => String(text || '').trim())
.filter((text) => !!text)
const greetingMessages = Array.from(new Set(greetings.length ? greetings : [DEFAULT_GREETING_MESSAGE]))
return {
selected_filters: filters,
greet_target: Number(formData.greet_target) || 20,
age_min: formData.age_min ?? undefined,
age_max: formData.age_max ?? undefined,
greeting_count: Number(formData.greeting_count) || 1,
greeting_messages: greetingMessages
}
}
const submit = (): Promise<boolean> => {
return new Promise((resolve, reject) => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
// 年龄范围校验
if (formData.age_min != null && formData.age_max != null && formData.age_min > formData.age_max) {
ElMessage.error('最小年龄不能大于最大年龄')
reject(false)
return
}
const hasGreeting = formData.greeting_messages
.slice(0, Number(formData.greeting_count) || 1)
.some((item) => String(item || '').trim().length > 0)
if (!hasGreeting) {
ElMessage.error('请至少填写 1 条问候语')
reject(false)
return
}
resolve(true)
} else {
ElMessage.error('请完善招聘参数')
reject(false)
}
})
})
}
const normalizeGreetingSlots = () => {
const normalizedCount = Math.max(1, Math.min(10, Number(formData.greeting_count) || 1))
formData.greeting_count = normalizedCount
if (formData.greeting_messages.length > normalizedCount) {
formData.greeting_messages = formData.greeting_messages.slice(0, normalizedCount)
return
}
while (formData.greeting_messages.length < normalizedCount) {
formData.greeting_messages.push(DEFAULT_GREETING_MESSAGE)
}
}
watch(
() => formData.greeting_count,
() => normalizeGreetingSlots()
)
defineExpose({
submit,
getForm
})
</script>
<style scoped lang="scss">
.recruit-task-form {
width: 100%;
padding-right: 12px;
}
.filter-loading {
display: flex;
align-items: center;
gap: 8px;
color: #909399;
font-size: 13px;
}
.filter-empty {
color: #c0c4cc;
font-size: 13px;
}
.filter-groups {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
max-height: 320px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 2px;
}
}
.filter-group {
&__title {
font-weight: 600;
font-size: 13px;
color: #303133;
margin-bottom: 8px;
padding-left: 2px;
border-left: 3px solid #409eff;
padding-left: 8px;
}
&__options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.age-range {
display: flex;
align-items: center;
gap: 8px;
&__sep {
color: #909399;
font-size: 13px;
}
&__unit {
color: #606266;
font-size: 13px;
}
}
.greeting-list {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.greeting-item {
&__label {
font-size: 12px;
color: #606266;
margin-bottom: 6px;
}
}
.greeting-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -14,7 +14,7 @@
>
<el-table-column label="任务类型" prop="task_type">
<template #default="scope">
<span>{{ scope.row.task_type === 'check_login' ? '检查登录' : '招聘' }}</span>
<span>{{ taskTypeLabel(scope.row.task_type) }}</span>
</template>
</el-table-column>
<el-table-column label="指定电脑" prop="worker_id" />
@@ -30,6 +30,10 @@
<template #default="scope">
<el-tag v-if="scope.row.status === 'success'" type="success">成功</el-tag>
<el-tag v-else-if="scope.row.status === 'failed'" type="danger">失败</el-tag>
<el-tag v-else-if="scope.row.status === 'pending'" type="primary">已创建</el-tag>
<el-tag v-else-if="scope.row.status === 'dispatched'" type="primary">已派发</el-tag>
<el-tag v-else-if="scope.row.status === 'running'" type="primary">执行中</el-tag>
<el-tag v-else-if="scope.row.status === 'cancelled'" type="info">已取消</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at">
@@ -54,7 +58,6 @@
</template>
<script setup lang="ts">
// 表格数据
import { ApiTasks } from '@/api/TaskManagement'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import { createTimer } from '@/utils/TimerManager'
@@ -72,19 +75,29 @@ const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10
})
// 获取数据
const taskTypeLabel = (taskType: string) => {
if (taskType === 'check_login') return '检查登录'
if (taskType === 'boss_recruit') return '招聘'
if (taskType === 'boss_reply') return '回复'
return taskType || '--'
}
function fetchData() {
ApiTasks(props.bossId, queryParams.pageNum, queryParams.pageSize).then((res: any) => {
roleList.value = res.data.results
total.value = res.data.total
})
}
defineExpose({
fetchData
})
onMounted(() => {
startTimer(fetchData, 5000)
})
onUnmounted(() => {
stopTimer()
})

View File

@@ -1,56 +1,54 @@
<template>
<div class="app-container">
<!-- 搜索区域 -->
<!-- <div class="search-container">-->
<!-- <el-form ref="queryFormRef" :model="queryParams" :inline="true">-->
<!-- <el-form-item prop="seal_type" label="用印用途">-->
<!-- <el-input-->
<!-- v-model="queryParams.seal_type"-->
<!-- placeholder="请输入"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item prop="CaseNumber" label="合同编号">-->
<!-- <el-input-->
<!-- v-model="queryParams.CaseNumber"-->
<!-- placeholder="请输入"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="创建时间" prop="times">-->
<!-- <el-date-picker-->
<!-- v-model="queryParams.times"-->
<!-- type="daterange"-->
<!-- value-format="YYYY-MM-DD"-->
<!-- placeholder="请选择创建时间"-->
<!-- range-separator="至"-->
<!-- start-placeholder="开始时间"-->
<!-- end-placeholder="结束时间"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item class="search-buttons">-->
<!-- <el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>-->
<!-- <el-button icon="refresh" @click="handleResetQuery">重置</el-button>-->
<!-- </el-form-item>-->
<!-- </el-form>-->
<!-- </div>-->
<el-card shadow="hover" class="data-table">
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<el-button type="success" icon="plus" @click="handleOpenDialog()">新增</el-button>
</div>
<div class="app-container account-page ops-page">
<div class="page-header">
<div>
<div class="page-header__title">BOSS 账号管理</div>
<div class="page-header__desc">统一管理账号登录状态Worker 在线状态和任务执行进度</div>
</div>
<div class="page-header__actions ops-actions">
<el-button type="primary" icon="plus" class="ops-btn" @click="handleOpenDialog()">
新增账号环境
</el-button>
<el-button icon="refresh" class="ops-btn" @click="fetchData()">刷新数据</el-button>
</div>
</div>
<div class="overview-grid">
<el-card
v-for="card in overviewCards"
:key="card.label"
shadow="hover"
class="overview-card ops-card"
:class="card.tone"
>
<div class="overview-card__icon">
<el-icon><component :is="card.icon" /></el-icon>
</div>
<div class="overview-card__content">
<p>{{ card.label }}</p>
<h3>{{ card.value }}</h3>
<span>{{ card.sub }}</span>
</div>
</el-card>
</div>
<el-card shadow="hover" class="table-panel ops-card">
<div class="table-panel__toolbar ops-toolbar">
<div class="table-tip">系统每 15 秒自动刷新账号与 Worker 状态</div>
<el-tag type="info" effect="light">已展开任务{{ expandedRowKeys.length }}</el-tag>
</div>
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
stripe
highlight-current-row
row-key="id"
border
empty-text="暂无账号数据"
:expanded-row-keys="expandedRowKeys"
class="data-table__content"
class="account-table ops-table"
@expand-change="handleExpandChange"
>
<el-table-column type="expand">
@@ -59,156 +57,107 @@
v-if="expandedRowKeys.includes(scope.row.id)"
ref="taskListRef"
:boss-id="scope.row.id"
></ViewTheTaskListDetails>
/>
</template>
</el-table-column>
<el-table-column label="环境名称" prop="browser_name" />
<el-table-column label="电脑标识" prop="worker_id" />
<el-table-column label="浏览器环境名称" prop="browser_name" />
<el-table-column label="登录昵称" prop="boss_username" />
<el-table-column label="电脑名称" prop="worker_name">
<el-table-column label="环境名称" prop="browser_name" min-width="150" />
<el-table-column label="账号ID" prop="id" width="110" />
<el-table-column label="电脑标识" prop="worker_id" min-width="150" />
<el-table-column label="登录昵称" prop="boss_username" min-width="120" />
<el-table-column label="电脑名称" prop="worker_name" min-width="120">
<template #default="scope">
<span v-if="scope.row.worker_name">
{{ scope.row.worker_name }}
</span>
<span v-else>--</span>
<span>{{ scope.row.worker_name || '--' }}</span>
</template>
</el-table-column>
<el-table-column
label="否已登录 BOSS"
prop="is_logged_in"
width="140"
align="center"
header-align="center"
>
<el-table-column label="是否登录 BOSS" prop="is_logged_in" width="130" align="center">
<template #default="scope">
<el-tag v-if="scope.row.is_logged_in" type="success">
{{ '在线' }}
</el-tag>
<el-tag v-else type="danger">
{{ '离线' }}
<el-tag
:type="scope.row.is_logged_in ? 'success' : 'danger'"
effect="light"
class="status-tag ops-status-tag"
>
{{ scope.row.is_logged_in ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="Worker 是否在线"
prop="worker_online"
width="140"
align="center"
header-align="center"
>
<el-table-column label="Worker 状态" prop="worker_online" width="120" align="center">
<template #default="scope">
<el-tag v-if="scope.row.worker_online" type="success">
{{ '在线' }}
</el-tag>
<el-tag v-else type="danger">
{{ '离线' }}
<el-tag
:type="scope.row.worker_online ? 'success' : 'danger'"
effect="light"
class="status-tag ops-status-tag"
>
{{ scope.row.worker_online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="任务状态"
prop="current_task_status"
width="100"
align="center"
header-align="center"
>
<el-table-column label="任务状态" prop="current_task_status" width="110" align="center">
<template #default="scope">
<el-tag v-if="scope.row.current_task_status === 'pending'" type="primary">
{{ '待派发' }}
</el-tag>
<el-tag v-else-if="scope.row.current_task_status === 'dispatched'" type="warning">
{{ '已派发' }}
</el-tag>
<el-tag v-else-if="scope.row.current_task_status === 'running'" type="warning">
{{ '执行中' }}
</el-tag>
<el-tag v-else-if="scope.row.current_task_status === 'success'" type="success">
{{ '成功' }}
</el-tag>
<el-tag v-else-if="scope.row.current_task_status === 'failed'" type="danger">
{{ '失败' }}
<el-tag
v-if="scope.row.current_task_status"
:type="getTaskStatusMeta(scope.row.current_task_status).type"
effect="light"
class="status-tag ops-status-tag"
>
{{ getTaskStatusMeta(scope.row.current_task_status).label }}
</el-tag>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="更新时间" prop="updated_at">
<el-table-column label="更新时间" prop="updated_at" min-width="170">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.updated_at || '') }}
{{ scope.row?.updated_at ? formatISOToDateTime(scope.row.updated_at) : '--' }}
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="300">
<el-table-column fixed="right" label="操作" width="360">
<template #default="scope">
<!-- <el-button-->
<!-- type="primary"-->
<!-- size="small"-->
<!-- link-->
<!-- icon="edit"-->
<!-- @click="onDetails(scope.row.id)"-->
<!-- >-->
<!-- 详情-->
<!-- </el-button>-->
<!-- <el-button-->
<!-- type="primary"-->
<!-- size="small"-->
<!-- link-->
<!-- icon="edit"-->
<!-- @click="onApiTasksAdd(scope.row.id)"-->
<!-- >-->
<!-- 发布任务-->
<!-- </el-button>-->
<el-button
type="primary"
size="small"
@click="onApiTasksAdd(scope.row.id, 'check_login', '')"
>
检查登录
</el-button>
<el-button
type="success"
size="small"
@click="onApiTasksAdd(scope.row.id, 'boss_recruit', '')"
>
招聘
</el-button>
<!-- <el-button-->
<!-- type="primary"-->
<!-- size="small"-->
<!-- link-->
<!-- icon="edit"-->
<!-- @click="onDetails(scope.row.id)"-->
<!-- >-->
<!-- 查看任务-->
<!-- </el-button>-->
<el-button
type="primary"
size="small"
link
icon="edit"
@click="handleExpandTask(scope.row.id)"
>
展开任务
</el-button>
<el-button
type="danger"
size="small"
link
icon="delete"
@click="onUserDeleteDepartment(scope.row.id)"
>
删除
</el-button>
<div class="action-group ops-actions">
<el-button
type="warning"
size="small"
class="ops-btn"
@click="onApiTasksAdd(scope.row.id, 'boss_reply', {})"
>
回复
</el-button>
<el-button
type="primary"
size="small"
class="ops-btn"
@click="onApiTasksAdd(scope.row.id, 'check_login', {})"
>
检查登录
</el-button>
<el-button
type="success"
size="small"
class="ops-btn"
@click="openRecruitDialog(scope.row.id)"
>
招聘
</el-button>
<el-button
type="primary"
size="small"
link
icon="edit"
@click="handleExpandTask(scope.row.id)"
>
{{ expandedRowKeys.includes(scope.row.id) ? '收起任务' : '展开任务' }}
</el-button>
<el-button
type="danger"
size="small"
link
icon="delete"
@click="onUserDeleteDepartment(scope.row.id)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
</div>
</template>
@@ -217,13 +166,14 @@
import { RolePageVO } from '@/api/system/role-api'
import { functionDialogBox } from '@/utils/functionDialogBox'
import BoosAccountForm from './components/BoosAccountForm.vue'
import RecruitTaskForm from './components/RecruitTaskForm.vue'
import { BusinessEditApplication } from '@/api/calibration/applicationForSealApproval'
import { ApiAccounts, ApiAccountsAdd, ApiAccountsDelete } from '@/api/BoosAccountManagement'
import ViewTheTaskListDetails from './components/ViewTheTaskListDetails.vue'
import { ApiTasksAdd } from '@/api/TaskManagement'
// import TaskForm from '@/views/TaskManagement/components/TaskForm.vue'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import { createTimer } from '@/utils/TimerManager'
import { User, Monitor, ChatDotRound, Message } from '@element-plus/icons-vue'
defineOptions({
name: 'Role',
@@ -231,60 +181,89 @@ defineOptions({
})
const { startTimer, stopTimer } = createTimer()
const queryFormRef = ref()
const dataTableRef = ref()
const taskListRef = ref()
const loading = ref(false)
const total = ref(0)
const roleList = ref<RolePageVO[]>([])
const expandedRowKeys = ref<Array<string | number>>([])
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
times: [],
seal_type: '',
CaseNumber: ''
const TASK_STATUS_MAP: Record<
string,
{
label: string
type: 'primary' | 'success' | 'warning' | 'info' | 'danger'
}
> = {
pending: { label: '待派发', type: 'info' },
dispatched: { label: '已派发', type: 'warning' },
running: { label: '执行中', type: 'primary' },
success: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' }
}
const getTaskStatusMeta = (status: string) =>
TASK_STATUS_MAP[status] || {
label: '--',
type: 'info'
}
const accountStats = computed(() => {
const list = roleList.value || []
return {
total: list.length,
loggedIn: list.filter((item: any) => Boolean(item.is_logged_in)).length,
workerOnline: list.filter((item: any) => Boolean(item.worker_online)).length,
runningTasks: list.filter((item: any) => item.current_task_status === 'running').length
}
})
// 表格数据
const roleList = ref<RolePageVO[]>()
const expandedRowKeys = ref<string[]>([])
// 弹窗
const dialog = reactive({
title: '',
visible: false
})
const overviewCards = computed(() => [
{
label: '账号总数',
value: accountStats.value.total,
sub: `已登录:${accountStats.value.loggedIn}`,
icon: User,
tone: 'tone-1'
},
{
label: '在线 Worker',
value: accountStats.value.workerOnline,
sub: `总 Worker${accountStats.value.total}`,
icon: Monitor,
tone: 'tone-2'
},
{
label: '运行中任务',
value: accountStats.value.runningTasks,
sub: '任务状态实时同步',
icon: ChatDotRound,
tone: 'tone-3'
},
{
label: '待处理账号',
value: Math.max(accountStats.value.total - accountStats.value.loggedIn, 0),
sub: '建议优先检查登录',
icon: Message,
tone: 'tone-4'
}
])
// 获取数据
function fetchData() {
loading.value = true
function fetchData(silent = false) {
if (!silent) {
loading.value = true
}
ApiAccounts()
.then((res: any) => {
roleList.value = res.data
// total.value = res.total
roleList.value = res.data || []
})
.finally(() => {
loading.value = false
if (!silent) {
loading.value = false
}
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// 重置查询
function handleResetQuery() {
if (queryFormRef.value) queryFormRef.value.resetFields()
queryParams.pageNum = 1
fetchData()
}
// 打开角色弹窗
function handleOpenDialog(data: any = null) {
dialog.visible = true
if (data) {
functionDialogBox(
BoosAccountForm,
@@ -314,7 +293,6 @@ function handleOpenDialog(data: any = null) {
}
}
// 提交角色表单
function handleSubmit(data: any) {
loading.value = true
const roleId = data.id
@@ -322,14 +300,14 @@ function handleSubmit(data: any) {
BusinessEditApplication(data)
.then(() => {
ElMessage.success('修改成功')
handleResetQuery()
fetchData()
})
.finally(() => (loading.value = false))
} else {
ApiAccountsAdd(data)
.then(() => {
ElMessage.success('操作成功')
handleResetQuery()
fetchData()
})
.finally(() => (loading.value = false))
}
@@ -346,7 +324,7 @@ const onUserDeleteDepartment = (id: string) => {
ApiAccountsDelete(id)
.then(() => {
ElMessage.success('删除成功')
handleResetQuery()
fetchData()
})
.finally(() => (loading.value = false))
},
@@ -356,70 +334,221 @@ const onUserDeleteDepartment = (id: string) => {
)
}
// const onDetails = (id: string) => {
// functionDialogBox(
// ViewTheTaskListDetails,
// {
// bossId: id
// },
// {
// title: '任务列表',
// width: '1200',
// footerVisible: false
// }
// )
// }
const onApiTasksAdd = (id: string, task_type: string, params: string) => {
// functionDialogBox(
// TaskForm,
// {},
// {
// title: '提交新任务',
// width: '900',
// ok(value: any) {
// ApiTasksAdd({ ...value, boss_id: id })
// .then(() => {
// ElMessage.success('提交成功')
// handleResetQuery()
// })
// .finally(() => (loading.value = false))
// }
// }
// )
const openRecruitDialog = (id: string) => {
functionDialogBox(
RecruitTaskForm,
{
accountId: id
},
{
title: '招聘参数',
width: '620',
ok(value: any) {
onApiTasksAdd(id, 'boss_recruit', value || {})
}
}
)
}
const onApiTasksAdd = (id: string, task_type: string, params: any = {}) => {
loading.value = true
ApiTasksAdd({ params, task_type, boss_id: id })
.then((res: any) => {
ElMessage.success(res.msg)
handleResetQuery()
fetchData()
refreshTaskList()
})
.finally(() => (loading.value = false))
}
function handleExpandChange(row: any, expandedRows: any[]) {
function handleExpandChange(_row: any, expandedRows: any[]) {
expandedRowKeys.value = expandedRows.map((item) => item.id)
}
const handleExpandTask = (rowId: string) => {
// 找到对应的行数据
const targetRow = roleList.value?.find((item) => item.id === rowId)
const handleExpandTask = (rowId: string | number) => {
const targetRow = roleList.value?.find((item: any) => item.id === rowId)
if (targetRow && dataTableRef.value) {
dataTableRef.value.toggleRowExpansion(targetRow)
}
}
const refreshTaskList = () => {
// 调用子组件的 fetchData 方法
taskListRef.value?.fetchData()
}
onMounted(() => {
// handleQuery()
// fetchData()
startTimer(() => {
handleQuery()
})
fetchData(true)
}, 15000)
})
onUnmounted(() => {
stopTimer()
})
</script>
<style lang="scss" scoped>
.account-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
&__title {
font-size: 22px;
font-weight: 700;
color: #1f2d3d;
letter-spacing: 0.2px;
}
&__desc {
margin-top: 4px;
font-size: 13px;
color: #909399;
}
&__actions {
display: flex;
align-items: center;
gap: 10px;
}
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.overview-card {
border: 1px solid #ebeef5;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
:deep(.el-card__body) {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
&__icon {
width: 46px;
height: 46px;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
font-size: 20px;
}
&__content {
p {
margin: 0;
font-size: 13px;
color: #909399;
}
h3 {
margin: 6px 0 4px;
font-size: 28px;
color: #111827;
line-height: 1;
}
span {
font-size: 12px;
color: #909399;
}
}
}
.tone-1 .overview-card__icon {
background: linear-gradient(135deg, #7f7fd5 0%, #9156e8 100%);
}
.tone-2 .overview-card__icon {
background: linear-gradient(135deg, #56bafd 0%, #16e9fd 100%);
}
.tone-3 .overview-card__icon {
background: linear-gradient(135deg, #f199ee 0%, #ef6b8b 100%);
}
.tone-4 .overview-card__icon {
background: linear-gradient(135deg, #36d1dc 0%, #5b86e5 100%);
}
.table-panel {
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 14px 16px 16px;
}
&__toolbar {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
.table-tip {
font-size: 12px;
color: #909399;
}
.account-table {
:deep(.el-table__header th) {
background: #f6f8fc;
}
:deep(.el-table__row:hover > td) {
background: #f5f9ff !important;
}
}
.action-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.status-tag {
min-width: 62px;
justify-content: center;
}
@media (max-width: 768px) {
.page-header {
align-items: flex-start;
}
.page-header__title {
font-size: 20px;
}
.page-header__actions,
.table-panel__toolbar {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>

View File

@@ -8,10 +8,10 @@
label-position="top"
>
<el-form-item label="任务类型" prop="task_type">
<!-- <el-input v-model="formData.task_type" placeholder="请输入" />-->
<el-select v-model="formData.task_type" placeholder="请选择">
<el-option label="检查登录" value="check_login" />
<el-option label="招聘" value="boss_recruit" />
<el-option label="回复" value="boss_reply" />
</el-select>
</el-form-item>
<el-form-item label="任务参数" prop="params">
@@ -33,7 +33,6 @@ const props = defineProps({
})
const formRef = ref()
// 表单数据
const formData = reactive<any>({
task_type: '',
worker_id: '',
@@ -56,7 +55,6 @@ const getForm = () => {
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
@@ -73,6 +71,7 @@ const submit = (): Promise<boolean> => {
})
})
}
defineExpose({
submit,
getForm
@@ -108,3 +107,4 @@ defineExpose({
gap: 20px;
}
</style>

View File

@@ -1,107 +1,97 @@
<template>
<div class="app-container">
<!-- 搜索区域 -->
<!-- <div class="search-container">-->
<!-- <el-form ref="queryFormRef" :model="queryParams" :inline="true">-->
<!-- <el-form-item prop="seal_type" label="用印用途">-->
<!-- <el-input-->
<!-- v-model="queryParams.seal_type"-->
<!-- placeholder="请输入"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item prop="CaseNumber" label="合同编号">-->
<!-- <el-input-->
<!-- v-model="queryParams.CaseNumber"-->
<!-- placeholder="请输入"-->
<!-- clearable-->
<!-- @keyup.enter="handleQuery"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="创建时间" prop="times">-->
<!-- <el-date-picker-->
<!-- v-model="queryParams.times"-->
<!-- type="daterange"-->
<!-- value-format="YYYY-MM-DD"-->
<!-- placeholder="请选择创建时间"-->
<!-- range-separator="至"-->
<!-- start-placeholder="开始时间"-->
<!-- end-placeholder="结束时间"-->
<!-- />-->
<!-- </el-form-item>-->
<!-- <el-form-item class="search-buttons">-->
<!-- <el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>-->
<!-- <el-button icon="refresh" @click="handleResetQuery">重置</el-button>-->
<!-- </el-form-item>-->
<!-- </el-form>-->
<!-- </div>-->
<el-card shadow="hover" class="data-table">
<!-- <div class="data-table__toolbar">-->
<!-- <div class="data-table__toolbar&#45;&#45;actions">-->
<!-- <el-button type="success" icon="plus" @click="handleOpenDialog()">新增</el-button>-->
<!-- </div>-->
<!-- </div>-->
<div class="app-container contacts-page ops-page">
<div class="page-header">
<div class="page-header__title">联系人信息</div>
<div class="page-header__desc">统一查看候选人联系状态回复进度和微信交换情况</div>
</div>
<el-card shadow="hover" class="search-panel ops-card">
<el-form ref="queryFormRef" :model="queryParams" :inline="true" class="search-form">
<el-form-item prop="search" label="姓名">
<el-input
v-model="queryParams.search"
placeholder="请输入候选人姓名"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="创建时间" prop="times">
<el-date-picker
v-model="queryParams.times"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item class="search-actions ops-actions">
<el-button type="primary" :icon="Search" class="ops-btn" @click="handleQuery">
搜索
</el-button>
<el-button :icon="Refresh" class="ops-btn" @click="handleResetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="hover" class="table-panel ops-card">
<div class="table-panel__toolbar ops-toolbar">
<div class="toolbar-summary">
<span class="summary-item">当前页 {{ pageCount }} </span>
<span class="summary-item summary-item--success">已回复 {{ repliedCount }} </span>
<span class="summary-item summary-item--wechat">已交换微信 {{ wechatCount }} </span>
</div>
<el-button type="success" :icon="Download" class="ops-btn" @click="onApiContactsExport">
导出联系人
</el-button>
</div>
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
stripe
highlight-current-row
border
class="data-table__content"
@selection-change="handleSelectionChange"
empty-text="暂无联系人数据"
class="contacts-table ops-table"
>
<el-table-column label="候选人姓名" prop="name" />
<el-table-column label="应聘岗位" prop="position" />
<el-table-column label="联系方式(电话号或微信号)" prop="contact" />
<el-table-column label="回复状态" prop="reply_status">
<el-table-column label="候选人姓名" prop="name" min-width="120" />
<el-table-column label="应聘岗位" prop="position" min-width="130" />
<el-table-column label="联系方式(电话号或微信号)" prop="contact" min-width="180" />
<el-table-column label="回复状态" prop="reply_status" width="110" align="center">
<template #default="scope">
<el-tag v-if="scope.row.reply_status === '已回复'" type="success">
{{ scope.row.reply_status }}
</el-tag>
<el-tag v-else type="danger">
{{ scope.row.reply_status }}
<el-tag
:type="scope.row.reply_status === '已回复' ? 'success' : 'danger'"
effect="light"
class="status-tag ops-status-tag"
>
{{ scope.row.reply_status || '未回复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="微信交换" prop="wechat_exchanged">
<el-table-column label="微信交换" prop="wechat_exchanged" width="110" align="center">
<template #default="scope">
<el-tag v-if="scope.row.wechat_exchanged" type="success">
{{ '已交换' }}
</el-tag>
<el-tag v-else type="danger">
{{ '未交换' }}
<el-tag
:type="scope.row.wechat_exchanged ? 'success' : 'info'"
effect="light"
class="status-tag ops-status-tag"
>
{{ scope.row.wechat_exchanged ? '已交换' : '未交换' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注信息" prop="notes" />
<el-table-column label="联系时间" prop="contacted_at">
<el-table-column label="备注信息" prop="notes" min-width="160" show-overflow-tooltip />
<el-table-column label="联系时间" prop="contacted_at" min-width="170">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.contacted_at || '') }}
{{ scope.row?.contacted_at ? formatISOToDateTime(scope.row.contacted_at) : '--' }}
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="170">
<template #default="scope">
{{ scope.row?.created_at ? formatISOToDateTime(scope.row.created_at) : '--' }}
</template>
</el-table-column>
<!-- <el-table-column fixed="right" label="操作" width="140">-->
<!-- <template #default="scope">-->
<!-- <el-button-->
<!-- type="primary"-->
<!-- size="small"-->
<!-- link-->
<!-- icon="edit"-->
<!-- @click="onDetails(scope.row.task_id)"-->
<!-- >-->
<!-- 详情-->
<!-- </el-button>-->
<!-- &lt;!&ndash; <el-button&ndash;&gt;-->
<!-- &lt;!&ndash; type="danger"&ndash;&gt;-->
<!-- &lt;!&ndash; size="small"&ndash;&gt;-->
<!-- &lt;!&ndash; link&ndash;&gt;-->
<!-- &lt;!&ndash; icon="delete"&ndash;&gt;-->
<!-- &lt;!&ndash; @click="onUserDeleteDepartment(scope.row.id)"&ndash;&gt;-->
<!-- &lt;!&ndash; >&ndash;&gt;-->
<!-- &lt;!&ndash; 删除&ndash;&gt;-->
<!-- &lt;!&ndash; </el-button>&ndash;&gt;-->
<!-- </template>-->
<!-- </el-table-column>-->
</el-table>
<pagination
@@ -116,158 +106,193 @@
</template>
<script setup lang="ts">
import { ApiContacts } from '@/api/ContactInformation'
import { ApiContacts, ApiContactsExport } from '@/api/ContactInformation'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
// const queryFormRef = ref()
const queryFormRef = ref()
const loading = ref(false)
const ids = ref<number[]>([])
const total = ref(0)
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
times: [],
seal_type: '',
CaseNumber: ''
search: ''
})
// 表格数据
const roleList = ref<any[]>()
const roleList = ref<any[]>([])
const pageCount = computed(() => roleList.value.length)
const repliedCount = computed(
() => roleList.value.filter((item: any) => item.reply_status === '已回复').length
)
const wechatCount = computed(
() => roleList.value.filter((item: any) => Boolean(item.wechat_exchanged)).length
)
// // 弹窗
// const dialog = reactive({
// title: '',
// visible: false
// })
// 获取数据
function fetchData() {
loading.value = true
ApiContacts(queryParams.pageNum, queryParams.pageSize)
ApiContacts(queryParams)
.then((res: any) => {
roleList.value = res.data.results
total.value = res.data.total
roleList.value = res.data.results || []
total.value = res.data.total || 0
})
.finally(() => {
loading.value = false
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// // 重置查询
// function handleResetQuery() {
// if (queryFormRef.value) queryFormRef.value?.resetFields()
// queryParams.pageNum = 1
// fetchData()
// }
// 行复选框选中
function handleSelectionChange(selection: any) {
ids.value = selection.map((item: any) => item.id)
function handleResetQuery() {
if (queryFormRef.value) {
queryFormRef.value.resetFields()
}
queryParams.pageNum = 1
fetchData()
}
// // 打开角色弹窗
// function handleOpenDialog(data: any = null) {
// dialog.visible = true
// if (data) {
// functionDialogBox(
// TaskForm,
// {
// newData: data
// },
// {
// title: '编辑申请用印',
// width: '900',
// ok(value: any) {
// handleSubmit({ id: data.id, ...value })
// }
// }
// )
// } else {
// functionDialogBox(
// TaskForm,
// {},
// {
// title: '提交新任务',
// width: '900',
// ok(value: any) {
// handleSubmit(value)
// }
// }
// )
// }
// }
// // 提交角色表单
// function handleSubmit(data: any) {
// loading.value = true
// const roleId = data.id
// if (roleId) {
// BusinessEditApplication(data)
// .then(() => {
// ElMessage.success('修改成功')
// handleResetQuery()
// })
// .finally(() => (loading.value = false))
// } else {
// ApiTasksAdd(data)
// .then(() => {
// ElMessage.success('新增成功')
// handleResetQuery()
// })
// .finally(() => (loading.value = false))
// }
// }
// const onUserDeleteDepartment = (id: string) => {
// ElMessageBox.confirm('确认删除已选中的数据项?', '警告', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(
// () => {
// loading.value = true
// BusinessDeleteApplication(id)
// .then(() => {
// ElMessage.success('删除成功')
// handleResetQuery()
// })
// .finally(() => (loading.value = false))
// },
// () => {
// ElMessage.info('已取消删除')
// }
// )
// }
// const onDetails = (task_id: string) => {
// ApiTasksTaskId(task_id).then((res: any) => {
// functionDialogBox(
// TaskDetails,
// {
// newData: res.data
// },
// {
// title: '详情',
// width: '900'
// }
// )
// })
// }
const onApiContactsExport = () => {
ApiContactsExport(queryParams).then((res: any) => {
if (res.data.download_url) {
const link = document.createElement('a')
link.href = res.data.download_url
link.download = res.data.filename || '联系人信息.xlsx'
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
})
}
onMounted(() => {
handleQuery()
})
</script>
<style lang="scss" scoped>
.contacts-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
font-size: 22px;
font-weight: 700;
color: #1f2d3d;
letter-spacing: 0.2px;
}
&__desc {
font-size: 13px;
color: #909399;
}
}
.search-panel,
.table-panel {
border: 1px solid #ebeef5;
}
.search-panel {
:deep(.el-card__body) {
padding: 16px 18px 2px;
}
}
.search-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
}
.search-actions {
:deep(.el-form-item__content) {
display: flex;
gap: 8px;
}
}
.table-panel {
:deep(.el-card__body) {
padding: 14px 16px 16px;
}
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
}
.toolbar-summary {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.summary-item {
font-size: 12px;
color: #5b6472;
background: #f4f7fb;
border: 1px solid #e8eef7;
padding: 4px 10px;
border-radius: 999px;
}
.summary-item--success {
background: rgba(103, 194, 58, 0.1);
color: #67c23a;
border-color: rgba(103, 194, 58, 0.28);
}
.summary-item--wechat {
background: rgba(64, 158, 255, 0.1);
color: #409eff;
border-color: rgba(64, 158, 255, 0.28);
}
.contacts-table {
:deep(.el-table__header th) {
background: #f6f8fc;
}
:deep(.el-table__row:hover > td) {
background: #f5f9ff !important;
}
}
.status-tag {
min-width: 62px;
justify-content: center;
}
@media (max-width: 768px) {
.page-header__title {
font-size: 20px;
}
.table-panel__toolbar {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="app-container ops-page">
<div class="chart-header">
<div class="chart-title">每日数据明细</div>
<el-radio-group v-model="queryParams.days" size="small" @change="handleTimeChange">
<el-radio-button v-for="item in timeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<el-card shadow="hover" class="daily-table-card ops-card">
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
highlight-current-row
stripe
border
class="daily-table ops-table"
empty-text="暂无统计数据"
>
<el-table-column label="联系人数" prop="contacts" />
<el-table-column label="打招呼人数" prop="greeted" />
<el-table-column label="回复人数" prop="replied" />
<el-table-column label="微信相关数量" prop="wechat"></el-table-column>
<el-table-column label="回复率" prop="reply_rate" />
<el-table-column label="日期时间" prop="date">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.date || '') }}
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ApiStatsDaily } from '@/api/ContactInformation'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import { ref } from 'vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
// const queryFormRef = ref()
const loading = ref(false)
const total = ref(0)
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
days: '1'
})
// 表格数据
const roleList = ref<any[]>()
const timeOptions = [
{ label: '今日', value: '1' },
{ label: '近 7 天', value: '7' },
{ label: '近 30 天', value: '30' }
]
// // 弹窗
// const dialog = reactive({
// title: '',
// visible: false
// })
// 获取数据
function fetchData() {
loading.value = true
ApiStatsDaily(queryParams)
.then((res: any) => {
roleList.value = res.data
// total.value = res.data.total
})
.finally(() => {
loading.value = false
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// // 重置查询
// function handleResetQuery() {
// if (queryFormRef.value) queryFormRef.value?.resetFields()
// queryParams.pageNum = 1
// fetchData()
// }
const handleTimeChange = () => {
handleQuery()
}
onMounted(() => {
handleQuery()
})
</script>
<style lang="scss" scoped>
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.chart-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.daily-table-card {
border: 1px solid #ebeef5;
}
:deep(.daily-table .el-table__header th) {
background: #f8faff;
color: #303133;
font-weight: 600;
}
:deep(.daily-table .el-table__row td) {
padding: 10px 0;
}
:deep(.daily-table .el-table__row:hover > td) {
background-color: #f5f9ff !important;
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div class="chart-container">
<div ref="chartRef" class="trend-chart"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { EChartsOption } from 'echarts'
import { ApiStatsDaily } from '@/api/ContactInformation'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
interface Props {
height?: string
}
withDefaults(defineProps<Props>(), {
height: '350px'
})
const data = ref<any>({
xAxis: [] as string[],
series: [
{
name: '联系人数',
data: []
},
{
name: '打招呼',
data: []
},
{
name: '已回复',
data: []
},
{
name: '微信交换',
data: []
}
]
})
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 颜色配置
const seriesColors = ['#5B9BFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
// 初始化图表
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(255, 255, 255, 0.96)',
borderColor: '#ebeef5',
borderWidth: 1,
textStyle: {
color: '#303133'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.value.xAxis,
axisTick: {
show: false
},
axisLine: {
show: true,
lineStyle: {
color: '#D7DDE3'
}
},
axisLabel: {
color: '#909399',
margin: 20,
rotate: 45,
interval: 0,
fontSize: 12
}
},
yAxis: {
type: 'value',
min: 0,
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
show: true,
lineStyle: {
color: '#E8E8E8',
type: 'solid'
}
},
axisLabel: {
color: '#909399',
fontSize: 12
}
},
series: data.value.series.map((item: any, index: number) => ({
name: item.name,
type: 'bar',
barMaxWidth: 26,
barGap: '10%',
data: item.data,
itemStyle: {
color: seriesColors[index % seriesColors.length],
borderRadius: [2, 2, 0, 0]
},
emphasis: {
itemStyle: {
opacity: 0.8
}
},
animationDuration: 500
})),
legend: {
data: data.value.series.map((item: any) => item.name),
bottom: 0,
icon: 'rect',
itemWidth: 14,
itemHeight: 14,
itemGap: 20,
textStyle: {
color: '#606266',
fontSize: 12
}
}
}
chartInstance.setOption(option)
}
const onApiStatsDaily = (days: string) => {
ApiStatsDaily({ days }).then((res: { data: any[] }) => {
data.value = {
xAxis: res.data?.map((item) => formatISOToDateTime(item.date, 'YYYY-MM-DD')),
series: [
{
name: '联系人数',
data: res.data?.map((item) => item.contacts)
},
{
name: '打招呼',
data: res.data?.map((item) => item.greeted)
},
{
name: '已回复',
data: res.data?.map((item) => item.replied)
},
{
name: '微信交换',
data: res.data?.map((item) => item.wechat)
}
]
}
})
}
// 监听数据变化
watch(
() => data.value,
(newData: any) => {
if (chartInstance) {
chartInstance.setOption({
xAxis: { data: newData.xAxis },
series: newData.series.map((item: any, index: number) => ({
name: item.name,
data: item.data,
itemStyle: {
color: seriesColors[index % seriesColors.length]
}
})),
legend: {
data: newData.series.map((item: any) => item.name)
}
})
}
},
{ deep: true }
)
// 监听窗口大小变化
const handleResize = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', handleResize)
})
defineExpose({
resize: handleResize,
onApiStatsDaily
})
</script>
<style scoped lang="scss">
.chart-container {
width: 100%;
padding: 12px 16px 8px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
border-radius: 10px;
border: 1px solid #eef2f7;
}
.trend-chart {
width: 100%;
height: v-bind(height);
min-height: 320px;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div>TaskDetails</div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="pre-registration-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="auto"
label-position="top"
>
<el-form-item label="任务类型" prop="task_type">
<el-select v-model="formData.task_type" placeholder="请选择">
<el-option label="检查登录" value="check_login" />
<el-option label="招聘" value="boss_recruit" />
<el-option label="回复" value="boss_reply" />
</el-select>
</el-form-item>
<el-form-item label="任务参数" prop="params">
<el-input v-model="formData.params" placeholder="请输入" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import { deepCloneByJSON } from '@/utils/auxiliaryFunction'
const props = defineProps({
newData: {
type: Object,
default: () => ({})
}
})
const formRef = ref()
const formData = reactive<any>({
task_type: '',
worker_id: '',
account_name: '',
params: ''
})
const formRules = reactive<any>({
task_type: [{ required: true, message: '请输入', trigger: 'blur' }],
params: [{ required: true, message: '请输入', trigger: 'blur' }]
})
onMounted(() => {
setFormData(props.newData)
})
const getForm = () => {
return formData
}
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
const submit = (): Promise<boolean> => {
return new Promise((resolve, reject) => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
resolve(true)
} else {
ElMessage.error('请完善必填信息')
reject(false)
}
})
})
}
defineExpose({
submit,
getForm
})
</script>
<style scoped lang="scss">
.pre-registration-form {
width: 100%;
padding-right: 20px;
overflow: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.section {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div class="app-container stats-page ops-page">
<div class="stats-top">
<div class="stats-top__title">招聘数据概览</div>
<div class="stats-top__desc">实时追踪打招呼回复微信交换Worker 与账号状态</div>
</div>
<div v-loading="statsLoading" class="stats-grid">
<el-card
v-for="card in statCards"
:key="card.key || card.label"
shadow="hover"
class="stats-card ops-card"
:class="[card.tone, { 'stats-card--active': card.key && activeMetric === card.key }]"
>
<div class="stats-card__icon">
<el-icon><component :is="card.icon" /></el-icon>
</div>
<div class="stats-card__content">
<p>{{ card.label }}</p>
<h3>{{ card.value }}</h3>
<span>{{ card.sub }}</span>
</div>
</el-card>
</div>
<el-card shadow="hover" class="section-card ops-card">
<div class="section-card__header">
<div class="section-card__title">数据趋势</div>
<el-radio-group v-model="currentTime" size="small" @change="handleTimeChange">
<el-radio-button v-for="item in timeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<DataTrendChart ref="dataTrendChartRef" />
</el-card>
<el-card shadow="hover" class="section-card ops-card">
<DailyDataBreakdown></DailyDataBreakdown>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ApiStats } from '@/api/ContactInformation'
import { Monitor, User, ChatDotRound, ChatLineRound } from '@element-plus/icons-vue'
import DataTrendChart from './components/DataTrendChart.vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DailyDataBreakdown from '@/views/DataStatistics/components/DailyDataBreakdown.vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
const dataTrendChartRef = ref()
const route = useRoute()
const router = useRouter()
const currentTime = ref('today')
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '近 7 天', value: 'week' },
{ label: '近 30 天', value: 'month' },
{ label: '全部', value: 'all' }
]
const apiStatsData = ref<any>({})
const statsLoading = ref(false)
const activeMetric = ref('')
const METRIC_WHITELIST = ['contacts', 'greetings', 'replied', 'wechat', 'workers', 'accounts']
const PERIOD_WHITELIST = ['today', 'week', 'month', 'all']
const normalizePeriod = (value: unknown) => {
const text = String(value || '').trim()
return PERIOD_WHITELIST.includes(text) ? text : 'today'
}
const resolveMetric = (value: unknown) => {
const text = String(value || '').trim()
return METRIC_WHITELIST.includes(text) ? text : ''
}
const periodToChartDays = (period: string) => {
if (period === 'today') return '1'
if (period === 'week') return '7'
if (period === 'month') return '30'
return '30'
}
const onApiStats = () => {
statsLoading.value = true
ApiStats({ period: currentTime.value })
.then((res: any) => {
apiStatsData.value = res.data || {}
})
.finally(() => {
statsLoading.value = false
})
}
const statCards = computed(() => [
{
key: 'contacts',
label: '总联系人数',
value: apiStatsData.value?.contacts?.total || 0,
sub: `今日:${apiStatsData.value?.contacts?.today || 0}`,
icon: User,
tone: 'tone-1'
},
{
key: 'greetings',
label: '打招呼人数',
value: apiStatsData.value?.greetings?.total || apiStatsData.value?.contacts?.greeted || 0,
sub: `今日:${apiStatsData.value?.greetings?.today || apiStatsData.value?.contacts?.today_greeted || 0}`,
icon: ChatDotRound,
tone: 'tone-2'
},
{
key: 'replied',
label: '已回复人数',
value: apiStatsData.value?.contacts?.replied || 0,
sub: `回复率:${apiStatsData.value?.contacts?.reply_rate || 0}%`,
icon: ChatDotRound,
tone: 'tone-5'
},
{
key: 'wechat',
label: '微信交换数',
value: apiStatsData.value?.wechat?.total || 0,
sub: `成功率:${apiStatsData.value?.wechat?.success_rate || 0}%`,
icon: ChatLineRound,
tone: 'tone-3'
},
{
key: 'workers',
label: '在线Worker',
value: apiStatsData.value?.workers?.online || 0,
sub: `总数:${apiStatsData.value?.workers?.total || 0}`,
icon: Monitor,
tone: 'tone-4'
},
{
key: 'accounts',
label: '已登录账号数',
value: apiStatsData.value?.accounts?.logged_in || 0,
sub: `总数:${apiStatsData.value?.accounts?.total || 0}`,
icon: User,
tone: 'tone-6'
}
])
const syncFromRoute = () => {
currentTime.value = normalizePeriod(route.query.period)
activeMetric.value = resolveMetric(route.query.metric)
onApiStats()
dataTrendChartRef.value?.onApiStatsDaily(periodToChartDays(currentTime.value))
}
const handleTimeChange = () => {
router.replace({
query: {
...route.query,
period: currentTime.value
}
})
}
onMounted(() => {
syncFromRoute()
})
watch(
() => [route.query.period, route.query.metric],
() => syncFromRoute()
)
</script>
<style lang="scss" scoped>
.stats-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.stats-top {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
font-size: 22px;
font-weight: 700;
color: #1f2d3d;
letter-spacing: 0.3px;
}
&__desc {
font-size: 13px;
color: #909399;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stats-card {
border: 1px solid #ebeef5;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
:deep(.el-card__body) {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
}
&__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
}
&__content {
p {
margin: 0;
font-size: 13px;
color: #909399;
}
h3 {
margin: 6px 0 4px;
font-size: 28px;
color: #111827;
font-weight: 700;
line-height: 1;
}
span {
font-size: 12px;
color: #909399;
}
}
}
.stats-card--active {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12);
}
.tone-1 .stats-card__icon {
background: linear-gradient(135deg, #7f7fd5 0%, #9156e8 100%);
}
.tone-2 .stats-card__icon {
background: linear-gradient(135deg, #f199ee 0%, #ef6b8b 100%);
}
.tone-3 .stats-card__icon {
background: linear-gradient(135deg, #56bafd 0%, #16e9fd 100%);
}
.tone-4 .stats-card__icon {
background: linear-gradient(135deg, #51eb90 0%, #49f7d2 100%);
}
.tone-5 .stats-card__icon {
background: linear-gradient(135deg, #a18cf5 0%, #7d7df5 100%);
}
.tone-6 .stats-card__icon {
background: linear-gradient(135deg, #36d1dc 0%, #5b86e5 100%);
}
.section-card {
border: 1px solid #ebeef5;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
&__title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
@media (max-width: 768px) {
.stats-top__title {
font-size: 20px;
}
.stats-card {
:deep(.el-card__body) {
padding: 14px;
}
&__content h3 {
font-size: 24px;
}
}
.section-card__header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="pre-registration-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="auto"
label-position="top"
>
<el-form-item v-if="!newData?.id" label="配置名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入" />
<!-- <el-select v-model="formData.task_type" placeholder="请选择">-->
<!-- <el-option label="检查登录" value="check_login" />-->
<!-- <el-option label="招聘" value="boss_recruit" />-->
<!-- </el-select>-->
</el-form-item>
<el-form-item v-if="!newData?.id" label="岗位类型" prop="position">
<el-input v-model="formData.position" placeholder="请输入" />
</el-form-item>
<el-form-item label="复聊间隔天数" prop="followup_days">
<el-input v-model="formData.followup_days" placeholder="请输入" />
</el-form-item>
<el-form-item label="是否启用" prop="is_active">
<el-switch v-model="formData.is_active" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import { deepCloneByJSON } from '@/utils/auxiliaryFunction'
const props = defineProps({
newData: {
type: Object,
default: () => ({})
}
})
const formRef = ref()
// 表单数据
const formData = reactive<any>({
name: '',
position: '',
followup_days: '',
is_active: false
})
const formRules = reactive<any>({
name: [{ required: true, message: '请输入', trigger: 'blur' }],
position: [{ required: true, message: '请输入', trigger: 'blur' }],
followup_days: [{ required: true, message: '请输入', trigger: 'blur' }],
is_active: [{ required: true, message: '请输入', trigger: 'blur' }]
})
onMounted(() => {
setFormData(props.newData)
})
const getForm = () => {
return formData
}
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
const submit = (): Promise<boolean> => {
return new Promise((resolve, reject) => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
resolve(true)
} else {
ElMessage.error('请完善必填信息')
reject(false)
}
})
})
}
defineExpose({
submit,
getForm
})
</script>
<style scoped lang="scss">
.pre-registration-form {
width: 100%;
padding-right: 20px;
overflow: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.section {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<div class="app-container">
<h3>复聊配置</h3>
<el-card shadow="hover" class="data-table">
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<el-button type="success" icon="plus" @click="handleOpenDialog()">新增</el-button>
</div>
</div>
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
highlight-current-row
row-key="id"
border
class="data-table__content"
:expanded-row-keys="expandedRowKeys"
@expand-change="handleExpandChange"
>
<el-table-column type="expand">
<template #default="scope">
<RevisedConversationScript
v-if="expandedRowKeys.includes(scope.row.id)"
:config-id="scope.row.id"
></RevisedConversationScript>
</template>
</el-table-column>
<el-table-column label="配置名称" prop="name" />
<el-table-column label="岗位类型" prop="position" />
<el-table-column label="复聊间隔" prop="followup_days" />
<el-table-column label="启用状态" prop="is_active">
<template #default="scope">
<el-switch v-model="scope.row.is_active" @change="onStatusModification(scope.row)" />
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.created_at || '') }}
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="220">
<template #default="scope">
<el-button
type="primary"
size="small"
link
icon="edit"
@click="handleOpenDialog(scope.row)"
>
编辑
</el-button>
<el-button
type="primary"
size="small"
link
icon="edit"
@click="handleExpandTask(scope.row.id)"
>
展开话术
</el-button>
<el-button
type="danger"
size="small"
link
icon="delete"
@click="onUserDeleteDepartment(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { RolePageVO } from '@/api/system/role-api'
import { functionDialogBox } from '@/utils/functionDialogBox'
import ReconfigurationSettingsForm from './components/ReconfigurationSettingsForm.vue'
import {
ApiFollowupConfigs,
ApiFollowupConfigsAdd,
ApiFollowupConfigsDelete,
ApiFollowupConfigsEditor
} from '@/api/ReconciliationManagement'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
import RevisedConversationScript from '../RevisedConversationScript/index.vue'
defineOptions({
name: 'Role',
inheritAttrs: false
})
const queryFormRef = ref()
const dataTableRef = ref()
const loading = ref(false)
const total = ref(0)
const expandedRowKeys = ref<string[]>([])
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
times: [],
seal_type: '',
CaseNumber: ''
})
// 表格数据
const roleList = ref<RolePageVO[]>()
// 弹窗
const dialog = reactive({
title: '',
visible: false
})
// 获取数据
function fetchData() {
loading.value = true
ApiFollowupConfigs()
.then((res: any) => {
roleList.value = res.data
// total.value = res.data.total
})
.finally(() => {
loading.value = false
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// 重置查询
function handleResetQuery() {
if (queryFormRef.value) queryFormRef.value.resetFields()
queryParams.pageNum = 1
fetchData()
}
// 打开角色弹窗
function handleOpenDialog(data: any = null) {
dialog.visible = true
if (data) {
functionDialogBox(
ReconfigurationSettingsForm,
{
newData: data
},
{
title: '编辑复聊配置',
width: '900',
ok(value: any) {
handleSubmit({ id: data.id, ...value })
}
}
)
} else {
functionDialogBox(
ReconfigurationSettingsForm,
{},
{
title: '创建复聊配置',
width: '900',
ok(value: any) {
handleSubmit(value)
}
}
)
}
}
// 提交角色表单
function handleSubmit(data: any) {
loading.value = true
const roleId = data.id
if (roleId) {
ApiFollowupConfigsEditor(data)
.then(() => {
ElMessage.success('修改成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
} else {
ApiFollowupConfigsAdd(data)
.then(() => {
ElMessage.success('新增成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
}
}
const onUserDeleteDepartment = (id: string) => {
ElMessageBox.confirm('确认删除已选中的数据项?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(
() => {
loading.value = true
ApiFollowupConfigsDelete(id)
.then(() => {
ElMessage.success('删除成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
},
() => {
ElMessage.info('已取消删除')
}
)
}
function handleExpandChange(row: any, expandedRows: any[]) {
expandedRowKeys.value = expandedRows.map((item) => item.id)
}
const handleExpandTask = (rowId: string) => {
// 找到对应的行数据
const targetRow = roleList.value?.find((item) => item.id === rowId)
if (targetRow && dataTableRef.value) {
dataTableRef.value.toggleRowExpansion(targetRow)
}
}
const onStatusModification = (data: any) => {
ElMessageBox.confirm(
data.is_active ? '确认启用已选中的数据项?' : '确认禁用已选中的数据项?',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(() => {
loading.value = true
ApiFollowupConfigsEditor({ is_active: data.is_active, id: data.id })
.then(() => {
ElMessage.success('修改成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
})
.catch(() => {
handleResetQuery()
})
}
onMounted(() => {
handleQuery()
})
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="pre-registration-form">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="auto"
label-position="top"
>
<el-form-item label="排序" prop="order">
<el-input v-model="formData.order" placeholder="请输入" />
</el-form-item>
<el-form-item label="话术内容" prop="content">
<el-input v-model="formData.content" type="textarea" placeholder="请输入" />
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import { deepCloneByJSON } from '@/utils/auxiliaryFunction'
const props = defineProps({
newData: {
type: Object,
default: () => ({})
}
})
const formRef = ref()
// 表单数据
const formData = reactive<any>({
order: '',
content: ''
})
const formRules = reactive<any>({
order: [{ required: true, message: '请输入', trigger: 'blur' }],
content: [{ required: true, message: '请输入', trigger: 'blur' }]
})
onMounted(() => {
setFormData(props.newData)
})
const getForm = () => {
return formData
}
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
const submit = (): Promise<boolean> => {
return new Promise((resolve, reject) => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
resolve(true)
} else {
ElMessage.error('请完善必填信息')
reject(false)
}
})
})
}
defineExpose({
submit,
getForm
})
</script>
<style scoped lang="scss">
.pre-registration-form {
width: 100%;
padding-right: 20px;
overflow: hidden;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
}
.section {
display: flex;
flex-direction: column;
gap: 20px;
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<div class="app-container">
<h3>复聊话术</h3>
<el-card shadow="hover" class="data-table">
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<el-button type="success" icon="plus" @click="handleOpenDialog()">新增</el-button>
</div>
<div class="data-table__toolbar--actions">
<el-select
v-model="queryParams.day_number"
placeholder="请选择"
style="width: 100px"
@change="handleQuery()"
>
<el-option v-for="item in 5" :key="item" :label="`第${item}天`" :value="item">
{{ `${item}` }}
</el-option>
</el-select>
</div>
</div>
<el-table
ref="dataTableRef"
v-loading="loading"
:data="roleList"
highlight-current-row
border
class="data-table__content"
@selection-change="handleSelectionChange"
>
<el-table-column label="第几天" prop="day_number">
<template #default="scope">{{ scope.row?.day_number }}</template>
</el-table-column>
<el-table-column label="排序" prop="order" />
<el-table-column label="话术内容" prop="content" />
<el-table-column label="创建时间" prop="created_at">
<template #default="scope">
{{ formatISOToDateTime(scope.row?.created_at || '') }}
</template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="140">
<template #default="scope">
<el-button
type="primary"
size="small"
link
icon="edit"
@click="handleOpenDialog(scope.row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
link
icon="delete"
@click="onUserDeleteDepartment(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="fetchData"
/>
</el-card>
</div>
</template>
<script setup lang="ts">
import { RolePageVO } from '@/api/system/role-api'
import { functionDialogBox } from '@/utils/functionDialogBox'
import RevisedConversationScriptForm from './components/RevisedConversationScriptForm.vue'
import {
ApiFollowupScripts,
ApiFollowupScriptsAdd,
ApiFollowupScriptsDelete,
ApiFollowupScriptsEditor
} from '@/api/ReconciliationManagement'
import { formatISOToDateTime } from '@/utils/auxiliaryFunction'
const props = defineProps({
configId: {
type: String,
default: ''
}
})
defineOptions({
name: 'Role',
inheritAttrs: false
})
const queryFormRef = ref()
const loading = ref(false)
const ids = ref<number[]>([])
const total = ref(0)
const queryParams = reactive<any>({
pageNum: 1,
pageSize: 10,
day_number: 1
})
// 表格数据
const roleList = ref<RolePageVO[]>()
// 弹窗
const dialog = reactive({
title: '',
visible: false
})
// 获取数据
function fetchData() {
loading.value = true
ApiFollowupScripts({
config_id: props.configId,
day_number: queryParams.day_number
})
.then((res: any) => {
roleList.value = res.data
// total.value = res.data.total
})
.finally(() => {
loading.value = false
})
}
// 查询(重置页码后获取数据)
function handleQuery() {
queryParams.pageNum = 1
fetchData()
}
// 重置查询
function handleResetQuery() {
if (queryFormRef.value) queryFormRef.value.resetFields()
queryParams.pageNum = 1
fetchData()
}
// 行复选框选中
function handleSelectionChange(selection: any) {
ids.value = selection.map((item: any) => item.id)
}
// 打开角色弹窗
function handleOpenDialog(data: any = null) {
dialog.visible = true
if (data) {
functionDialogBox(
RevisedConversationScriptForm,
{
newData: data
},
{
title: '编辑复聊话术',
width: '900',
ok(value: any) {
handleSubmit({ id: data.id, ...value })
}
}
)
} else {
functionDialogBox(
RevisedConversationScriptForm,
{},
{
title: '创建复聊话术',
width: '900',
ok(value: any) {
handleSubmit({ ...value, config_id: props.configId, day_number: queryParams.day_number })
}
}
)
}
}
// 提交角色表单
function handleSubmit(data: any) {
loading.value = true
const roleId = data.id
if (roleId) {
ApiFollowupScriptsEditor(data)
.then(() => {
ElMessage.success('修改成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
} else {
ApiFollowupScriptsAdd(data)
.then(() => {
ElMessage.success('新增成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
}
}
const onUserDeleteDepartment = (id: string) => {
ElMessageBox.confirm('确认删除已选中的数据项?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(
() => {
loading.value = true
ApiFollowupScriptsDelete(id)
.then(() => {
ElMessage.success('删除成功')
handleResetQuery()
})
.finally(() => (loading.value = false))
},
() => {
ElMessage.info('已取消删除')
}
)
}
onMounted(() => {
handleQuery()
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="app-container">
<ReconfigurationSettings></ReconfigurationSettings>
<!-- <RevisedConversationScript></RevisedConversationScript>-->
</div>
</template>
<script setup lang="ts">
import ReconfigurationSettings from './components/ReconfigurationSettings/index.vue'
// import RevisedConversationScript from './components/RevisedConversationScript/index.vue'
</script>

View File

@@ -8,10 +8,10 @@
label-position="top"
>
<el-form-item label="任务类型" prop="task_type">
<!-- <el-input v-model="formData.task_type" placeholder="请输入" />-->
<el-select v-model="formData.task_type" placeholder="请选择">
<el-option label="检查登录" value="check_login" />
<el-option label="招聘" value="boss_recruit" />
<el-option label="回复" value="boss_reply" />
</el-select>
</el-form-item>
<el-form-item label="任务参数" prop="params">
@@ -33,7 +33,6 @@ const props = defineProps({
})
const formRef = ref()
// 表单数据
const formData = reactive<any>({
task_type: '',
worker_id: '',
@@ -56,7 +55,6 @@ const getForm = () => {
const setFormData = (data: any) => {
if (data && Object.keys(data).length > 0) {
const data1 = deepCloneByJSON(data)
Object.assign(formData, data1)
}
}
@@ -73,6 +71,7 @@ const submit = (): Promise<boolean> => {
})
})
}
defineExpose({
submit,
getForm
@@ -108,3 +107,4 @@ defineExpose({
gap: 20px;
}
</style>

View File

@@ -1,93 +1,154 @@
<template>
<div class="dashboard-container">
<el-card shadow="never" class="mt-2">
<div class="flex flex-wrap">
<!-- 左侧问候语区域 -->
<div class="flex-1 flex items-start">
<!-- <img class="w80px h80px rounded-full" src="../../assets/user.png" />-->
<div class="ml-5">
<p>{{ greetings }}</p>
<div style="width: 100%">
<div
v-if="publicityMaterials"
style="height: 20px; margin: 0 0 14px 0"
@click="
userStore.checkPermission('HomePage:propagandaEit:editor') && onPropagandaEit()
"
>
<p class="text-sm text-gray">
{{ userStore.userInfo?.content }}
</p>
</div>
<el-input
v-else
v-model="propagandaEitValue"
placeholder="请输入"
style="width: 240px; height: 20px; margin: 0 0 14px 0"
class="mt-2"
clearable
@keyup.enter="handleQuery"
@blur="handleQuery"
></el-input>
<div class="dashboard-container ops-page">
<el-card shadow="hover" class="mt-2 hero-card ops-card">
<div class="hero-panel">
<div class="hero-left">
<p class="hero-greeting">{{ greetings }}</p>
<div class="hero-propaganda">
<div
v-if="publicityMaterials"
class="hero-propaganda__text"
@click="userStore.checkPermission('HomePage:propagandaEit:editor') && onPropagandaEit()"
>
<p>{{ userStore.userInfo?.content || '点击设置展示文案' }}</p>
</div>
<el-input
v-else
v-model="propagandaEitValue"
placeholder="请输入首页展示文案"
clearable
@keyup.enter="handleQuery"
@blur="handleQuery"
/>
</div>
</div>
<!-- 右侧图标区域 - PC端 -->
<div class="hidden sm:block" style="display: flex; gap: 20px">
<!-- 快捷入口 -->
<div
ref="quickScrollRef"
class="flex w-700px gap-2 overflow-hidden quick"
@wheel="handleWheel"
>
<div class="hero-right">
<div class="quick-header">
<div class="quick-header__title">快捷入口</div>
<div class="quick-header__meta ops-actions">
<el-tag type="info" effect="light">已启用 {{ quickData.length }} </el-tag>
<el-popover placement="left-start" width="300">
<template #reference>
<el-button circle class="quick-config-btn ops-btn" title="快捷键配置">
<el-icon><Operation /></el-icon>
</el-button>
</template>
<el-tree
ref="treeRef"
:props="{
label: 'title'
}"
node-key="name"
:default-checked-keys="defaultCheckedKeys"
:data="quickConfigurationList"
show-checkbox
@check-change="handleCheckChange"
/>
</el-popover>
</div>
</div>
<div ref="quickScrollRef" class="quick-list quick" @wheel="handleWheel">
<div
v-for="(item, index) in quickData"
:key="index"
class="flex-1 flex items-center justify-center flex-col cursor-pointer"
class="quick-item"
@click="navigateTo(item.name)"
>
<el-icon class="w-12 h-12 text-primary"><Plus /></el-icon>
<el-text type="primary" size="small" style="white-space: nowrap">
<el-icon class="quick-item__icon"><Plus /></el-icon>
<el-text type="primary" size="small" class="quick-item__text">
{{ item.title }}
</el-text>
</div>
<div v-if="!quickData.length" class="quick-empty">请先在右上角配置快捷入口</div>
</div>
<el-popover placement="right-start" width="300">
<template #reference>
<el-icon title="快捷键配置"><Operation /></el-icon>
</template>
<el-tree
ref="treeRef"
style="max-width: 600px"
:props="{
label: 'title'
}"
node-key="name"
:default-checked-keys="defaultCheckedKeys"
:data="quickConfigurationList"
show-checkbox
@check-change="handleCheckChange"
/>
</el-popover>
</div>
</div>
</el-card>
<!-- 数据统计 -->
<el-row :gutter="10" class="mt-5">
<!-- 在线用户数量 -->
<div class="section-title ops-section-title mt-5">招聘运营概览</div>
<el-row :gutter="10" class="mt-3">
<el-col :span="6" :xs="12" class="mb-xs-3">
<el-card
shadow="never"
class="stat-card stat-card--clickable ops-card"
@click="goToDataStatistics('contacts')"
>
<div class="stat-card__icon stat-card__icon--1">
<el-icon><User /></el-icon>
</div>
<div class="stat-card__content">
<p>联系人数</p>
<h3>{{ recruitStats.contacts?.total || 0 }}</h3>
<span>今日{{ recruitStats.contacts?.today || 0 }}</span>
</div>
</el-card>
</el-col>
<el-col :span="6" :xs="12" class="mb-xs-3">
<el-card
shadow="never"
class="stat-card stat-card--clickable ops-card"
@click="goToDataStatistics('greetings')"
>
<div class="stat-card__icon stat-card__icon--2">
<el-icon><Message /></el-icon>
</div>
<div class="stat-card__content">
<p>打招呼人数</p>
<h3>{{ recruitStats.greetings?.total || recruitStats.contacts?.greeted || 0 }}</h3>
<span>今日{{ recruitStats.greetings?.today || recruitStats.contacts?.today_greeted || 0 }}</span>
</div>
</el-card>
</el-col>
<el-col :span="6" :xs="12" class="mb-xs-3">
<el-card
shadow="never"
class="stat-card stat-card--clickable ops-card"
@click="goToDataStatistics('replied')"
>
<div class="stat-card__icon stat-card__icon--3">
<el-icon><ChatDotRound /></el-icon>
</div>
<div class="stat-card__content">
<p>已回复人数</p>
<h3>{{ recruitStats.contacts?.replied || 0 }}</h3>
<span>回复率{{ recruitStats.contacts?.reply_rate || 0 }}%</span>
</div>
</el-card>
</el-col>
<el-col :span="6" :xs="12" class="mb-xs-3">
<el-card
shadow="never"
class="stat-card stat-card--clickable ops-card"
@click="goToDataStatistics('wechat')"
>
<div class="stat-card__icon stat-card__icon--4">
<el-icon><ChatLineRound /></el-icon>
</div>
<div class="stat-card__content">
<p>微信交换数</p>
<h3>{{ recruitStats.wechat?.total || 0 }}</h3>
<span>成功率{{ recruitStats.wechat?.success_rate || 0 }}%</span>
</div>
</el-card>
</el-col>
</el-row>
<div class="section-title ops-section-title mt-5">消息与日程</div>
<el-row :gutter="12" class="mt-3">
<el-col :span="12" :xs="24" class="mb-xs-3">
<el-card shadow="never" class="h-full flex flex-col">
<el-card shadow="hover" class="panel-card ops-card h-full">
<template #header>
<div class="flex-x-between">
<div class="panel-card__header">
<el-badge
v-if="userRoxyexhibitionList?.length > 0"
:value="userRoxyexhibitionTotal"
:max="99"
>
<span>办事项</span>
<span class="panel-card__title">办事项</span>
</el-badge>
<span v-else>办事项</span>
<span v-else class="panel-card__title">办事项</span>
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
<span class="text-xs">查看更多</span>
<el-icon class="text-xs">
@@ -96,21 +157,15 @@
</el-link>
</div>
</template>
<div style="height: 150px; overflow: hidden; overflow-y: auto">
<div class="panel-list panel-list--short">
<div
v-for="(item, index) in userRoxyexhibitionList"
:key="index"
style="
display: flex;
align-items: center;
padding: 10px 5px;
gap: 10px;
"
class="panel-item panel-item--clickable"
@click="handleReadNotice(item)"
>
<el-tag>{{ item.type }}</el-tag>
<el-text>{{ item.title }}</el-text>
<el-tag effect="light" class="ops-status-tag">{{ item.type }}</el-tag>
<el-text class="panel-item__title">{{ item.title }}</el-text>
<el-tooltip
class="box-item"
effect="light"
@@ -118,53 +173,40 @@
:content="item.content"
placement="top"
>
<el-text
type="info"
style="flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
>
{{ item.content }}
</el-text>
<el-text type="info" class="panel-item__content">{{ item.content }}</el-text>
</el-tooltip>
</div>
<div v-if="!userRoxyexhibitionList?.length" class="list-empty">暂无待办事项</div>
</div>
</el-card>
</el-col>
<!-- 浏览量(PV) -->
<el-col :span="12" :xs="24">
<el-card>
<el-col :span="12" :xs="24" class="mb-xs-3">
<el-card shadow="hover" class="panel-card ops-card h-full">
<template #header>
<div>重要日程提示</div>
<div class="panel-card__header">
<span class="panel-card__title">重要日程提示</span>
<el-tag effect="light" type="info"> {{ businessScheduleDetailList?.length || 0 }} </el-tag>
</div>
</template>
<div
style="
height: 150px;
overflow: hidden;
overflow-y: auto;
gap: 5px;
display: flex;
flex-direction: column;
"
>
<div class="panel-list panel-list--short panel-list--column">
<div
v-for="(item, index) in businessScheduleDetailList"
:key="index"
style="
display: flex;
align-items: center;
padding: 10px 10px;
gap: 10px;
border-radius: 8px;
"
:class="{
'blink-animation': !(
item.state === '已完成' || isCurrentTimeLessThan(item.end_time)
)
}"
:class="[
'panel-item',
'schedule-item',
{
'schedule-item--danger': !(item.state === '已完成' || isCurrentTimeLessThan(item.end_time)),
'blink-animation': !(item.state === '已完成' || isCurrentTimeLessThan(item.end_time))
}
]"
>
<el-text
:type="
item.state === '已完成' || isCurrentTimeLessThan(item.end_time) ? '' : 'danger'
"
class="panel-item__title"
>
{{ item.title }}
</el-text>
@@ -175,38 +217,33 @@
:content="item.remark"
placement="top"
>
<el-text
type="info"
style="flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
>
{{ item.remark }}
</el-text>
<el-text type="info" class="panel-item__content">{{ item.remark }}</el-text>
</el-tooltip>
<span>
<el-tag type="info">{{ item.tiems }}</el-tag>
</span>
<el-tag type="info" effect="light" size="small" class="ops-status-tag">{{ item.tiems }}</el-tag>
</div>
<div v-if="!businessScheduleDetailList?.length" class="list-empty">暂无日程数据</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="10" class="mt-5">
<el-col :xs="24" :span="12">
<el-card>
<el-row :gutter="12" class="mt-5">
<el-col :xs="24" :span="12" class="mb-xs-3">
<el-card shadow="hover" class="panel-card ops-card">
<template #header>
<div class="flex-x-between">
<span>通知/公示</span>
<div class="panel-card__header">
<span class="panel-card__title">通知 / 公示</span>
<el-tag effect="light" type="info"> {{ businessBulletindetailList?.length || 0 }} </el-tag>
</div>
</template>
<div style="height: 490px; overflow: hidden; overflow-y: auto">
<div class="panel-list panel-list--tall">
<div
v-for="(item, index) in businessBulletindetailList"
:key="index"
style="display: flex; align-items: center; padding: 10px 5px; gap: 10px"
class="panel-item panel-item--clickable"
@click="businessBulletindetailClick(item)"
>
<el-text>{{ item.title }}</el-text>
<el-text class="panel-item__title">{{ item.title }}</el-text>
<el-tooltip
class="box-item"
effect="light"
@@ -214,35 +251,30 @@
:content="item.content"
placement="top"
>
<el-text
type="info"
style="flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
>
{{ item.content }}
</el-text>
<el-text type="info" class="panel-item__content">{{ item.content }}</el-text>
</el-tooltip>
<span>
<el-tag type="info">{{ item.times }}</el-tag>
</span>
<el-tag type="info" effect="light" size="small" class="ops-status-tag">{{ item.times }}</el-tag>
</div>
<div v-if="!businessBulletindetailList?.length" class="list-empty">暂无通知数据</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :span="12">
<el-card>
<el-card shadow="hover" class="panel-card ops-card">
<template #header>
<div class="flex-x-between">
<span>制度公示</span>
<div class="panel-card__header">
<span class="panel-card__title">制度公示</span>
<el-tag effect="light" type="info"> {{ regulationDisclosureList?.length || 0 }} </el-tag>
</div>
</template>
<div style="height: 490px; overflow: hidden; overflow-y: auto">
<div class="panel-list panel-list--tall">
<div
v-for="(item, index) in regulationDisclosureList"
:key="index"
style="display: flex; align-items: center; padding: 10px 5px; gap: 10px"
class="panel-item panel-item--clickable"
@click="businessBulletindetailClick(item)"
>
<el-text>{{ item.title }}</el-text>
<el-text class="panel-item__title">{{ item.title }}</el-text>
<el-tooltip
class="box-item"
effect="light"
@@ -250,17 +282,11 @@
:content="item.content"
placement="top"
>
<el-text
type="info"
style="flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
>
{{ item.content }}
</el-text>
<el-text type="info" class="panel-item__content">{{ item.content }}</el-text>
</el-tooltip>
<span>
<el-tag type="info">{{ item.times }}</el-tag>
</span>
<el-tag type="info" effect="light" size="small" class="ops-status-tag">{{ item.times }}</el-tag>
</div>
<div v-if="!regulationDisclosureList?.length" class="list-empty">暂无制度公示</div>
</div>
</el-card>
</el-col>
@@ -274,6 +300,7 @@ import {
UserApprovalStatusCheck,
UserRoxyexhibition
} from '@/api/calibration/approval'
import { ApiStats } from '@/api/ContactInformation'
// import ImageIconComponent from '@/components/ImageIconComponent/index.vue'
defineOptions({
@@ -282,7 +309,7 @@ defineOptions({
})
import { useUserStore } from '@/store/modules/user-store'
import { Plus, Operation, ArrowRight } from '@element-plus/icons-vue'
import { Plus, Operation, ArrowRight, User, ChatDotRound, ChatLineRound, Message } from '@element-plus/icons-vue'
import { BusinessScheduleDetail } from '@/api/calibration/lmportantScheduleManagement'
import { BusinessBulletindetail } from '@/api/calibration/announcementManagement'
import { BusinessLawdisplay } from '@/api/calibration/lawFirmStandardDocuments'
@@ -314,6 +341,7 @@ const businessScheduleDetailList = ref<any[]>([])
const businessBulletindetailList = ref<any[]>([])
const regulationDisclosureList = ref<any[]>([])
const businessLawdisplayList = ref<any[]>([])
const recruitStats = ref<any>({})
// 当前时间(用于计算问候语)
const currentDate = new Date()
@@ -340,6 +368,16 @@ const navigateTo = (name: string) => {
})
}
const goToDataStatistics = (metric: 'contacts' | 'greetings' | 'replied' | 'wechat') => {
router.push({
name: 'DataStatistics',
query: {
period: 'all',
metric
}
})
}
const onUserRoxyexhibition = () => {
UserRoxyexhibition({
pageNum: 1,
@@ -384,6 +422,12 @@ const onBusinessLawdisplay = () => {
})
}
const onRecruitStats = () => {
ApiStats({ period: 'all' }).then((res: any) => {
recruitStats.value = res.data || {}
})
}
const handleCheckChange = () => {
treeRef.value?.getCheckedNodes()
quickData.value = treeRef.value?.getCheckedNodes()
@@ -511,6 +555,7 @@ const businessBulletindetailClick = (data: any) => {
}
onMounted(() => {
onRecruitStats()
onUserRoxyexhibition()
onBusinessScheduleDetail()
onBusinessBulletindetail()
@@ -554,6 +599,329 @@ onMounted(() => {
}
}
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s;
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 14px;
}
&--clickable {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(64, 158, 255, 0.12);
border-color: #d9ecff;
}
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12);
}
&__icon--1 {
background: linear-gradient(135deg, #7f7fd5 0%, #9156e8 100%);
}
&__icon--2 {
background: linear-gradient(135deg, #f199ee 0%, #ef6b8b 100%);
}
&__icon--3 {
background: linear-gradient(135deg, #56bafd 0%, #16e9fd 100%);
}
&__icon--4 {
background: linear-gradient(135deg, #51eb90 0%, #49f7d2 100%);
}
&__content {
p {
margin: 0;
font-size: 13px;
color: #909399;
}
h3 {
margin: 4px 0;
font-size: 24px;
color: #303133;
line-height: 1;
}
span {
font-size: 12px;
color: #909399;
}
}
}
.section-title {
font-size: 18px;
font-weight: 700;
color: #1f2d3d;
letter-spacing: 0.3px;
}
.hero-card {
border: 1px solid #ebeef5;
:deep(.el-card__body) {
padding: 16px 18px;
}
}
.hero-panel {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 18px;
}
.hero-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
}
.hero-greeting {
margin: 0;
color: #1f2d3d;
font-size: 17px;
font-weight: 600;
}
.hero-propaganda {
max-width: 440px;
&__text {
min-height: 34px;
padding: 8px 10px;
border-radius: 8px;
border: 1px dashed #d8dee8;
background: #f7f9fc;
cursor: pointer;
transition: all 0.2s;
p {
margin: 0;
font-size: 13px;
color: #616f85;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {
border-color: #b8d6ff;
background: #eef5ff;
}
}
}
.hero-right {
width: 54%;
min-width: 320px;
display: flex;
flex-direction: column;
gap: 10px;
}
.quick-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
&__title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
&__meta {
display: flex;
align-items: center;
gap: 8px;
}
}
.quick-config-btn {
width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
color: #409eff;
border: 1px solid #d9ecff;
background: #ecf5ff;
}
.quick-list {
display: flex;
align-items: stretch;
gap: 10px;
min-height: 98px;
}
.quick-item {
min-width: 96px;
flex: 1;
border: 1px solid #e8edf5;
border-radius: 10px;
background: #f9fbfe;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
transform: translateY(-2px);
border-color: #b8d6ff;
background: #eef5ff;
box-shadow: 0 8px 16px rgba(64, 158, 255, 0.12);
}
}
.quick-item__icon {
width: 34px;
height: 34px;
border-radius: 9px;
color: #409eff;
background: rgba(64, 158, 255, 0.12);
}
.quick-item__text {
margin-top: 8px;
white-space: nowrap;
}
.quick-empty {
width: 100%;
min-height: 90px;
border: 1px dashed #d8dee8;
border-radius: 10px;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
}
.panel-card {
border: 1px solid #ebeef5;
:deep(.el-card__header) {
padding: 14px 16px;
border-bottom: 1px solid #f0f2f5;
}
:deep(.el-card__body) {
padding: 12px 14px;
}
}
.panel-card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel-card__title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.panel-list {
overflow-y: auto;
}
.panel-list--short {
height: 176px;
}
.panel-list--tall {
height: 490px;
}
.panel-list--column {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 8px;
border-radius: 8px;
}
.panel-item--clickable {
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #f5f9ff;
}
}
.panel-item__title {
max-width: 170px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.panel-item__content {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.schedule-item {
border: 1px solid #f2f3f5;
background: #fafbfd;
}
.schedule-item--danger {
border-color: rgba(245, 108, 108, 0.24);
}
.list-empty {
height: 100%;
min-height: 88px;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 13px;
}
.quick {
overflow-x: auto;
scroll-behavior: smooth;
@@ -585,4 +953,31 @@ onMounted(() => {
.blink-animation {
animation: blinkBackground 1s infinite;
}
@media (max-width: 768px) {
.hero-panel {
flex-direction: column;
}
.hero-right {
width: 100%;
min-width: 0;
}
.hero-greeting {
font-size: 16px;
}
.quick-item {
min-width: 88px;
}
.panel-list--tall {
height: 360px;
}
.panel-item__title {
max-width: 120px;
}
}
</style>