feat: 新增电工APP端与定时任务管理功能

- 新增电工APP独立鉴权中间件与登录注册接口
- 新增定时任务管理模块,支持任务配置、执行日志与手动触发
- 新增运维费定价与电工派单成本价目管理
- 新增电工资质证照管理与到期提醒定时任务
- 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
andyesfly committed 2026-10-02 16:08:00 +08:00
1 parent 1803ad679c
commit f4454ad014
57 files changed
+9388 -200

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
import request from '@/utils/request';
export interface DispatchPriceInfo {
id: number;
priceCode: string;
dispatchType: string;
priceType?: string;
priceMode: number;
unitPrice?: number;
unit?: string;
status: number;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface DispatchPriceQuery {
page?: number;
pageSize?: number;
keyword?: string;
dispatchType?: string;
priceMode?: number;
status?: number;
}
export interface DispatchPriceListResult {
list: DispatchPriceInfo[];
total: number;
page: number;
pageSize: number;
}
export interface DispatchPricePayload {
dispatchType?: string;
priceType?: string;
priceMode?: number;
unitPrice?: number;
unit?: string;
status?: number;
remark?: string;
}
export const dispatchPriceApi = {
getDispatchPriceList: (params: DispatchPriceQuery) =>
request.get<DispatchPriceListResult>('/dispatch-prices', { params }),
getDispatchPriceDetail: (id: number) =>
request.get<DispatchPriceInfo>(`/dispatch-prices/${id}`),
createDispatchPrice: (data: DispatchPricePayload) =>
request.post('/dispatch-prices', data),
updateDispatchPrice: (id: number, data: DispatchPricePayload) =>
request.put(`/dispatch-prices/${id}`, data),
deleteDispatchPrice: (id: number) =>
request.delete(`/dispatch-prices/${id}`),
updateDispatchPriceStatus: (id: number, status: number) =>
request.put(`/dispatch-prices/${id}/status`, { status }),
};
+113
View File
@@ -0,0 +1,113 @@
import request from '@/utils/request';
export interface JobParamField {
key: string;
label: string;
type: 'number' | 'string';
min?: number;
max?: number;
placeholder?: string;
}
export interface JobItem {
id: number;
jobKey: string;
jobName: string;
cronExpression: string;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
status: number;
remark?: string;
lastRunAt?: string | null;
lastRunStatus?: number | null;
lastRunCostMs?: number | null;
lastError?: string | null;
createdAt?: string;
updatedAt?: string;
description?: string;
paramFields?: JobParamField[];
registered: boolean;
nextRunTime?: string | null;
}
export interface JobLogItem {
id: number;
jobId: number;
jobKey: string;
jobName: string;
triggerType: number;
status: number;
attempt: number;
startedAt?: string | null;
finishedAt?: string | null;
costMs?: number | null;
message?: string | null;
errorStack?: string | null;
createdAt?: string | null;
}
export interface JobRunResult {
status: number;
message: string;
costMs: number;
attempt: number;
}
export interface JobQuery {
page?: number;
pageSize?: number;
jobName?: string;
status?: number;
}
export interface JobLogQuery {
page?: number;
pageSize?: number;
status?: number;
triggerType?: number;
startDate?: string;
endDate?: string;
}
export interface UpdateJobPayload {
cronExpression: string;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
remark?: string;
}
export interface JobListResult {
list: JobItem[];
total: number;
page: number;
pageSize: number;
}
export interface JobLogListResult {
list: JobLogItem[];
total: number;
page: number;
pageSize: number;
}
export const jobApi = {
getJobList: (params: JobQuery) =>
request.get<JobListResult>('/jobs', { params }),
updateJob: (id: number, data: UpdateJobPayload) =>
request.put<JobItem>(`/jobs/${id}`, data),
changeJobStatus: (id: number, status: 0 | 1) =>
request.put<JobItem>(`/jobs/${id}/status`, { status }),
runJob: (id: number) =>
request.post<JobRunResult>(`/jobs/${id}/run`),
getJobLogs: (id: number, params: JobLogQuery) =>
request.get<JobLogListResult>(`/jobs/${id}/logs`, { params }),
clearJobLogs: (days = 90) =>
request.delete<{ deleted: number }>('/jobs/logs/clear', { params: { days } }),
};
+42
View File
@@ -0,0 +1,42 @@
import request from '@/utils/request';
export interface NoticeInfo {
id: number;
noticeType: string;
title: string;
content?: string;
bizType?: string;
bizId?: number;
// 是否已读:0 未读 / 1 已读
isRead: number;
createdAt?: string;
created_at?: string;
}
export interface NoticeQuery {
page?: number;
pageSize?: number;
isRead?: number;
}
export interface NoticeListResult {
list: NoticeInfo[];
total: number;
page: number;
pageSize: number;
}
export interface NoticeUnreadCount {
count: number;
}
// 站内通知(消息中心)
export const noticeApi = {
getList: (params: NoticeQuery) => request.get<NoticeListResult>('/notices', { params }),
getUnreadCount: () => request.get<NoticeUnreadCount>('/notices/unread-count'),
readAll: () => request.put('/notices/read-all'),
markRead: (id: number) => request.put(`/notices/${id}/read`),
};
+61
View File
@@ -0,0 +1,61 @@
import request from '@/utils/request';
export interface ServicePriceInfo {
id: number;
priceCode: string;
priceName: string;
serviceType?: string;
priceMode: number;
unitPrice: number;
unit?: string;
status: number;
remark?: string;
createdAt: string;
updatedAt: string;
}
export interface ServicePriceQuery {
page?: number;
pageSize?: number;
keyword?: string;
priceMode?: number;
serviceType?: string;
status?: number;
}
export interface ServicePriceListResult {
list: ServicePriceInfo[];
total: number;
page: number;
pageSize: number;
}
export interface ServicePricePayload {
priceName?: string;
serviceType?: string;
priceMode?: number;
unitPrice?: number;
unit?: string;
status?: number;
remark?: string;
}
export const servicePriceApi = {
getServicePriceList: (params: ServicePriceQuery) =>
request.get<ServicePriceListResult>('/service-prices', { params }),
getServicePriceDetail: (id: number) =>
request.get<ServicePriceInfo>(`/service-prices/${id}`),
createServicePrice: (data: ServicePricePayload) =>
request.post('/service-prices', data),
updateServicePrice: (id: number, data: ServicePricePayload) =>
request.put(`/service-prices/${id}`, data),
deleteServicePrice: (id: number) =>
request.delete(`/service-prices/${id}`),
updateServicePriceStatus: (id: number, status: number) =>
request.put(`/service-prices/${id}/status`, { status }),
};
+128
View File
@@ -0,0 +1,128 @@
import request from '@/utils/request';
// 资质状态:none 无证书 / normal 正常 / expiring 临期 / expired 已过期
export type WorkerCertStatus = 'none' | 'normal' | 'expiring' | 'expired';
// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
export type WorkerAuditStatus = 0 | 1 | 2;
// 证书行(详情接口返回完整证书,保存时按此结构提交)
export interface WorkerCert {
id?: number;
workerId?: number;
certType: string;
certNo: string;
issueAuthority?: string;
issueDate?: string;
expireDate?: string;
certImageUrl?: string;
}
// 列表接口返回的证书摘要
export interface WorkerCertSummary {
id: number;
certType: string;
certNo: string;
expireDate?: string;
}
export interface WorkerRecord {
id: number;
workerName: string;
phone?: string;
idCard?: string;
workerTitle?: string;
rating?: number | string;
certInfo?: string;
certVerified?: boolean;
completedCount?: number;
// 在岗状态:1 在岗 / 0 离职
status: number;
auditStatus: number;
auditRemark?: string;
auditBy?: number;
auditAt?: string;
lastLoginAt?: string;
certStatus?: WorkerCertStatus;
certs?: WorkerCertSummary[];
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
}
export interface WorkerQuery {
page?: number;
pageSize?: number;
keyword?: string;
status?: number;
auditStatus?: number;
certStatus?: WorkerCertStatus;
}
export interface WorkerListResult {
list: WorkerRecord[];
total: number;
page: number;
pageSize: number;
}
// KPI:电工总数 / 待审核 / 临期 / 已过期
export interface WorkerStats {
total: number;
pendingAudit: number;
expiring: number;
expired: number;
}
export interface WorkerCertPayload {
certType: string;
certNo: string;
issueAuthority?: string;
issueDate?: string;
expireDate?: string;
certImageUrl?: string;
}
export interface WorkerPayload {
workerName: string;
phone: string;
idCard?: string;
workerTitle?: string;
status?: number;
rating?: number;
certInfo?: string;
// 新增时是否同时开通电工 APP 账号(默认密码为手机号后 6 位)
withAccount?: boolean;
// 存在时整体覆盖该电工的全部证书
certs?: WorkerCertPayload[];
}
export interface WorkerAuditPayload {
approved: boolean;
remark?: string;
}
// 管理端电工登记接口(不修改原 worker.ts,勘察派单页仍使用 workerApi)
export const workerRegistryApi = {
getList: (params: WorkerQuery) => request.get<WorkerListResult>('/workers', { params }),
getStats: () => request.get<WorkerStats>('/workers/stats'),
getDetail: (id: number) => request.get<WorkerRecord>(`/workers/${id}`),
create: (data: WorkerPayload) => request.post('/workers', data),
update: (id: number, data: Partial<WorkerPayload>) => request.put(`/workers/${id}`, data),
remove: (id: number) => request.delete(`/workers/${id}`),
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
request.put(`/workers/${id}/certs/${certId}`, data),
removeCert: (id: number, certId: number) => request.delete(`/workers/${id}/certs/${certId}`),
};
+237 -1
View File
@@ -48,6 +48,13 @@
</div>
<div class="header-right">
<!-- 消息中心铃铛:未读数为 0 时角标自动隐藏 -->
<a-badge :count="unreadCount" :overflow-count="99" size="small">
<div class="notice-bell" title="消息中心" @click="openNoticeDrawer">
<BellOutlined />
</div>
</a-badge>
<!-- 用户信息下拉菜单 -->
<a-dropdown :trigger="['click']">
<a-space class="user-info">
@@ -89,12 +96,43 @@
</router-view>
</a-layout-content>
</a-layout>
<!-- 消息中心抽屉 -->
<a-drawer v-model:open="noticeVisible" title="消息中心" placement="right" :width="440">
<div class="notice-drawer">
<div class="notice-toolbar">
<span class="notice-summary">共 {{ noticeTotal }} 条通知,{{ unreadCount }} 条未读</span>
<a-button type="link" size="small" :disabled="!unreadCount" @click="handleReadAll">全部已读</a-button>
</div>
<a-spin :spinning="noticeLoading">
<div v-if="noticeList.length" class="notice-list">
<div
v-for="item in noticeList"
:key="item.id"
class="notice-item"
:class="{ unread: item.isRead === 0 }"
@click="handleNoticeClick(item)"
>
<div class="notice-item-head">
<span v-if="item.isRead === 0" class="notice-dot"></span>
<span class="notice-title">{{ item.title }}</span>
<span class="notice-time">{{ formatNoticeTime(item) }}</span>
</div>
<p class="notice-content">{{ item.content }}</p>
</div>
</div>
<a-empty v-else description="暂无通知" />
</a-spin>
</div>
</a-drawer>
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, watch, h, type Component, type VNode } from 'vue'
import { ref, computed, watch, h, onMounted, onBeforeUnmount, type Component, type VNode } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
DashboardOutlined,
SettingOutlined,
@@ -115,8 +153,10 @@ import {
LogoutOutlined,
LockOutlined,
ThunderboltOutlined,
BellOutlined,
} from '@ant-design/icons-vue'
import { usePermissionStore, type MenuNode } from '@/stores/permission'
import { noticeApi, type NoticeInfo } from '@/api/notice'
const router = useRouter()
const route = useRoute()
@@ -290,6 +330,92 @@ function handleUserMenuClick({ key }: { key: string }) {
permissionStore.logout()
}
}
// ==================== 消息中心 ====================
const noticeVisible = ref(false)
const noticeLoading = ref(false)
const noticeList = ref<NoticeInfo[]>([])
const noticeTotal = ref(0)
const unreadCount = ref(0)
const NOTICE_POLL_INTERVAL = 60000
let noticeTimer: ReturnType<typeof setInterval> | null = null
// 未读数轮询(顶栏红点)
async function fetchUnreadCount() {
try {
const res = await noticeApi.getUnreadCount()
if (res.code === 200) unreadCount.value = res.data?.count || 0
} catch (error) {
// 未读数拉取失败不阻断主界面
}
}
async function fetchNoticeList() {
noticeLoading.value = true
try {
const res = await noticeApi.getList({ page: 1, pageSize: 20 })
if (res.code === 200 && res.data) {
noticeList.value = res.data.list || []
noticeTotal.value = res.data.total || 0
}
} catch (error) {
// 列表拉取失败不阻断抽屉
} finally {
noticeLoading.value = false
}
}
function openNoticeDrawer() {
noticeVisible.value = true
fetchNoticeList()
}
async function handleNoticeClick(item: NoticeInfo) {
if (item.isRead === 0) {
try {
await noticeApi.markRead(item.id)
item.isRead = 1
fetchUnreadCount()
} catch (error) {
// 标记失败不阻断后续跳转
}
}
// 电工资质到期提醒 → 跳转电工登记管理
if (item.bizType === 'worker_cert') {
noticeVisible.value = false
router.push('/dispatch/worker')
}
}
async function handleReadAll() {
try {
const res = await noticeApi.readAll()
if (res.code === 200) {
message.success('已全部标记为已读')
noticeList.value = noticeList.value.map((n) => ({ ...n, isRead: 1 }))
unreadCount.value = 0
}
} catch (error) {
// 由请求拦截器统一提示
}
}
function formatNoticeTime(item: NoticeInfo) {
const time = item.createdAt || item.created_at
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : ''
}
onMounted(() => {
fetchUnreadCount()
noticeTimer = setInterval(fetchUnreadCount, NOTICE_POLL_INTERVAL)
})
onBeforeUnmount(() => {
if (noticeTimer) {
clearInterval(noticeTimer)
noticeTimer = null
}
})
</script>
<style scoped lang="less">
@@ -458,6 +584,28 @@ function handleUserMenuClick({ key }: { key: string }) {
}
.header-right {
display: flex;
align-items: center;
gap: 18px;
.notice-bell {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 18px;
color: #424655;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
}
.user-info {
cursor: pointer;
padding: 4px 8px;
@@ -548,4 +696,92 @@ function handleUserMenuClick({ key }: { key: string }) {
}
}
}
// ===== 消息中心抽屉(抽屉内容被 teleport 到 body,样式不可嵌套在 .admin-layout 内) =====
.notice-drawer {
display: flex;
flex-direction: column;
gap: 12px;
.notice-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #eef1f6;
.notice-summary {
font-size: 12px;
color: #5b6b7e;
}
}
.notice-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notice-item {
padding: 12px 14px;
border: 1px solid #eef1f6;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #dbe4ee;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
&.unread {
background: #f4f7ff;
border-color: #d6e4ff;
}
.notice-item-head {
display: flex;
align-items: center;
gap: 8px;
}
.notice-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ff4d4f;
flex-shrink: 0;
}
.notice-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #0d1c2c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-time {
font-size: 11px;
color: #8a97a8;
flex-shrink: 0;
font-family: Consolas, Menlo, monospace;
}
.notice-content {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.7;
color: #5b6b7e;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
</style>
+25 -1
View File
@@ -154,9 +154,27 @@ const routes: RouteRecordRaw[] = [
{
path: "dispatch",
name: "Dispatch",
component: () => import("@/views/Placeholder.vue"),
redirect: "/dispatch/price",
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
},
{
path: "dispatch/price",
name: "DispatchPrice",
component: () => import("@/views/dispatch/price/index.vue"),
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
},
{
path: "dispatch/cost",
name: "DispatchCost",
component: () => import("@/views/dispatch/cost/index.vue"),
meta: { requiresAuth: true, title: "电工派单成本价", permission: "dispatch:cost" }
},
{
path: "dispatch/worker",
name: "DispatchWorker",
component: () => import("@/views/dispatch/worker/index.vue"),
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
},
{
path: "device",
name: "Device",
@@ -194,6 +212,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/system/dict/index.vue"),
meta: { requiresAuth: true, title: "数据字典", permission: "system:dict" }
},
{
path: "system/job",
name: "SystemJob",
component: () => import("@/views/system/job/index.vue"),
meta: { requiresAuth: true, title: "定时任务", permission: "system:job" }
},
{
path: "log/operation",
name: "OperationLog",
+114
View File
@@ -233,12 +233,58 @@
</div>
</div>
</div>
<!-- 电工资质到期提醒 -->
<div class="remind-grid">
<div class="card">
<div class="card-head">
<div>
<div class="card-title-row">
<h3 class="card-title">电工资质到期提醒</h3>
<span class="card-tag">CERT ALERT</span>
</div>
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
</div>
<a class="card-link" @click="goWorkerRegistry">
查看全部
<ArrowRightOutlined />
</a>
</div>
<div class="table-scroll">
<table class="dw-table">
<thead>
<tr>
<th>电工姓名</th>
<th>证书类型</th>
<th>有效期至</th>
<th class="ta-r">到期状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in certReminders" :key="row.key">
<td class="strong">{{ row.workerName }}</td>
<td>{{ row.certType }}</td>
<td class="mono muted">{{ row.expireDate }}</td>
<td class="ta-r">
<span class="tag" :class="row.expired ? 'tag-danger' : 'tag-warn'">
{{ row.expired ? `已过期 ${Math.abs(row.days)} 天` : `剩余 ${row.days} 天` }}
</span>
</td>
</tr>
</tbody>
</table>
<div class="table-empty" v-if="!certReminders.length">暂无临期或已过期的电工资质</div>
</div>
</div>
</div>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import {
Button,
Spin,
@@ -258,8 +304,11 @@ import {
ArrowRightOutlined,
} from '@ant-design/icons-vue';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
import { workerRegistryApi } from '@/api/workerRegistry';
const router = useRouter();
const AButton = Button;
const ASpin = Spin;
@@ -272,6 +321,63 @@ let donutChart: echarts.ECharts | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const lastUpdateTime = ref('--');
// ===== 电工资质到期提醒 =====
interface CertReminderRow {
key: string;
workerId: number;
workerName: string;
certType: string;
expireDate: string;
days: number;
expired: boolean;
}
const certReminders = ref<CertReminderRow[]>([]);
// 取临期与已过期各前 5 条电工,展开证书后按到期天数排序,已过期的排前面
async function loadCertReminders() {
try {
const [expiredRes, expiringRes] = await Promise.all([
workerRegistryApi.getList({ page: 1, pageSize: 5, certStatus: 'expired' }),
workerRegistryApi.getList({ page: 1, pageSize: 5, certStatus: 'expiring' }),
]);
const today = dayjs().startOf('day');
const buildRows = (list: any[], expiredFlag: boolean): CertReminderRow[] => {
const rows: CertReminderRow[] = [];
for (const worker of list || []) {
for (const cert of worker.certs || []) {
if (!cert.expireDate) continue;
const expire = String(cert.expireDate).slice(0, 10);
const days = dayjs(expire).startOf('day').diff(today, 'day');
// 已过期组取 < 0,临期组取 0 ~ 30 天
const inRange = expiredFlag ? days < 0 : days >= 0 && days <= 30;
if (!inRange) continue;
rows.push({
key: `${worker.id}-${cert.id}`,
workerId: Number(worker.id),
workerName: worker.workerName,
certType: cert.certType || '资质证书',
expireDate: expire,
days,
expired: expiredFlag,
});
}
}
rows.sort((a, b) => a.days - b.days);
return rows.slice(0, 5);
};
const expiredRows = buildRows(expiredRes.code === 200 ? expiredRes.data.list : [], true);
const expiringRows = buildRows(expiringRes.code === 200 ? expiringRes.data.list : [], false);
certReminders.value = [...expiredRows, ...expiringRows];
} catch (error) {
// 提醒数据加载失败不影响主看板
certReminders.value = [];
}
}
function goWorkerRegistry() {
router.push('/dispatch/worker');
}
const totalStatus = computed(() =>
(stats.value?.customerStatus || []).reduce((s, it) => s + it.value, 0)
);
@@ -396,6 +502,7 @@ async function loadStats() {
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN');
updateCharts();
}
await loadCertReminders();
} catch (error) {
console.error('加载仪表盘数据失败:', error);
message.error('加载数据失败,请稍后重试');
@@ -721,6 +828,12 @@ function disposeCharts() {
.card { } // col-7 / col-5
}
// 电工资质到期提醒
.remind-grid {
margin-top: 16px;
.card { }
}
.live-alerts { font-size: 11px; font-weight: 700; color: var(--dw-error); background: rgba(220,38,38,.1); padding: 2px 8px; border-radius: 6px; animation: dw-pulse 1.8s infinite; }
// ===== 表格 =====
@@ -760,6 +873,7 @@ function disposeCharts() {
&-primary { color: var(--dw-primary); background: rgba(0,84,205,.12); }
&-warn { color: var(--dw-warn); background: rgba(224,133,43,.14); }
&-tertiary { color: var(--dw-tertiary); background: rgba(0,104,95,.12); }
&-danger { color: var(--dw-error); background: rgba(220,38,38,.12); }
}
// ===== 看板 =====
+864
View File
@@ -0,0 +1,864 @@
<template>
<div class="cost-page">
<!-- 顶部标题 + 操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>运营派单</span>
<span class="sep">/</span>
<span class="cur">电工派单成本价</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">电工派单成本价</span>
<span class="sub-title">DISPATCH COST PRICE</span>
</div>
<p class="desc">维护共享电工派单的成本价目,按派单类型区分单价或总价提成,支持按次、按工时两种计价方式。</p>
</div>
<div class="header-actions">
<a-button v-if="canCreate" type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增成本价
</a-button>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="价格编码"
allow-clear
maxlength="64"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">派单类型</label>
<a-select v-model:value="searchForm.dispatchType" placeholder="全部类型" allow-clear>
<a-select-option value="勘察">勘察</a-select-option>
<a-select-option value="安装">安装</a-select-option>
<a-select-option value="预警">预警</a-select-option>
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">计价方式</label>
<a-select v-model:value="searchForm.priceMode" placeholder="全部方式" allow-clear>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">停用</a-select-option>
</a-select>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">电工派单成本价清单</span>
<span class="count">{{ total }}</span>
</div>
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'priceCode'">
<span class="mono">{{ record.priceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'dispatchType'">
{{ record.dispatchType || '-' }}
</template>
<template v-else-if="column.key === 'priceType'">
{{ record.priceType || '-' }}
</template>
<template v-else-if="column.key === 'priceMode'">
<a-tag :color="getPriceModeColor(record.priceMode)">{{ getPriceModeText(record.priceMode) }}</a-tag>
</template>
<template v-else-if="column.key === 'unitPrice'">
<span class="mono">{{ formatPrice(record.unitPrice) }}</span>
</template>
<template v-else-if="column.key === 'unit'">
{{ record.unit || '-' }}
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="停用"
@change="(checked: boolean) => handleStatusChange(record, checked)"
/>
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="canUpdate" type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm
v-if="canDelete"
title="确定要删除该成本价吗?"
ok-text="确定"
cancel-text="取消"
ok-button-props="{ danger: true }"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无成本价数据" />
</template>
</a-table>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="640px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<!-- 价格编码信息条(编辑态只读展示) -->
<div v-if="isEdit && editId" class="no-bar">
<div class="no-item">
<span class="no-label">价格编码</span>
<span class="no-value mono">{{ editCode || '-' }}</span>
</div>
</div>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<div class="form-section">
<div class="section-head">
<span class="section-no">01</span>
<div class="section-text">
<span class="section-name">成本价信息</span>
<span class="section-sub">Cost Price Information</span>
</div>
</div>
<a-form-item label="派单类型" name="dispatchType">
<a-select v-model:value="formData.dispatchType" placeholder="请选择派单类型" style="width: 100%">
<a-select-option value="勘察">勘察</a-select-option>
<a-select-option value="安装">安装</a-select-option>
<a-select-option value="预警">预警</a-select-option>
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="价格类型" name="priceType">
<a-select v-model:value="formData.priceType" placeholder="请选择价格类型" allow-clear style="width: 100%">
<a-select-option value="单价">单价</a-select-option>
<a-select-option value="总价提成">总价提成</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="计价方式" name="priceMode">
<a-select
v-model:value="formData.priceMode"
placeholder="请选择计价方式"
style="width: 100%"
@change="handlePriceModeChange"
>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="单价" name="unitPrice">
<a-input-number
v-model:value="formData.unitPrice"
:min="0"
:precision="2"
placeholder="请输入单价"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="单位" name="unit">
<a-input
v-model:value="formData.unit"
placeholder="如:次 / 小时"
maxlength="16"
@change="handleUnitManualChange"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import {
dispatchPriceApi,
type DispatchPriceInfo,
type DispatchPriceQuery,
} from '@/api/dispatchPrice';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
// 按钮权限
const canCreate = computed(() => permissionStore.hasPermission('cost:create'));
const canUpdate = computed(() => permissionStore.hasPermission('cost:update'));
const canDelete = computed(() => permissionStore.hasPermission('cost:delete'));
// 计价方式 → 默认单位
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时' };
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<DispatchPriceInfo[]>([]);
const total = ref(0);
const searchForm = reactive<DispatchPriceQuery>({
page: 1,
pageSize: 10,
keyword: '',
dispatchType: undefined as any,
priceMode: undefined as any,
status: undefined as any,
});
const columns = [
{ title: '价格编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
{ title: '派单类型', dataIndex: 'dispatchType', key: 'dispatchType', ellipsis: true, width: 110 },
{ title: '价格类型', dataIndex: 'priceType', key: 'priceType', ellipsis: true, width: 110 },
{ title: '计价方式', key: 'priceMode', align: 'center' as const, width: 110 },
{ title: '单价', key: 'unitPrice', align: 'right' as const, width: 110 },
{ title: '单位', dataIndex: 'unit', key: 'unit', align: 'center' as const, width: 80 },
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 弹窗表单 ====================
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const editCode = ref('');
// 用户是否手动修改过单位(手动修改后不再随计价方式联动覆盖)
const unitManuallyEdited = ref(false);
const formData = reactive({
dispatchType: undefined as string | undefined,
priceType: undefined as string | undefined,
priceMode: undefined as number | undefined,
unitPrice: undefined as number | undefined,
unit: '',
remark: '',
status: 1,
});
const formRules = {
dispatchType: [{ required: true, message: '请选择派单类型', trigger: 'change' }],
priceMode: [{ required: true, message: '请选择计价方式', trigger: 'change' }],
unitPrice: [{ required: true, message: '请输入单价', trigger: 'change', type: 'number' as const }],
unit: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
remark: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
};
// ==================== 生命周期 ====================
onMounted(() => {
fetchList();
});
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res: any = await dispatchPriceApi.getDispatchPriceList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取电工派单成本价列表失败');
} finally {
loading.value = false;
}
}
// ==================== 搜索重置 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.dispatchType = undefined as any;
searchForm.priceMode = undefined as any;
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 新增/编辑 ====================
function handleAdd() {
isEdit.value = false;
editId.value = null;
editCode.value = '';
modalTitle.value = '新增成本价';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: DispatchPriceInfo) {
isEdit.value = true;
editId.value = record.id;
editCode.value = record.priceCode;
modalTitle.value = '编辑成本价';
resetForm();
formData.dispatchType = record.dispatchType || undefined;
formData.priceType = record.priceType || undefined;
formData.priceMode = record.priceMode;
formData.unitPrice = Number(record.unitPrice ?? 0);
formData.unit = record.unit || '';
formData.remark = record.remark || '';
formData.status = record.status;
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
unitManuallyEdited.value = true;
modalVisible.value = true;
}
function resetForm() {
formData.dispatchType = undefined;
formData.priceType = undefined;
formData.priceMode = undefined;
formData.unitPrice = undefined;
formData.unit = '';
formData.remark = '';
formData.status = 1;
unitManuallyEdited.value = false;
if (formRef.value) {
formRef.value.resetFields();
}
}
// 计价方式变化:未手动改过单位时按计价方式联动单位
function handlePriceModeChange(v: any) {
if (!unitManuallyEdited.value) {
formData.unit = PRICE_MODE_UNIT[Number(v)] || '';
}
}
// 用户手动修改单位后置标记
function handleUnitManualChange() {
unitManuallyEdited.value = true;
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData = {
dispatchType: formData.dispatchType,
priceType: formData.priceType || undefined,
priceMode: formData.priceMode as number,
unitPrice: formData.unitPrice as number,
unit: formData.unit || undefined,
status: formData.status,
remark: formData.remark || undefined,
};
let res: any;
if (isEdit.value && editId.value) {
res = await dispatchPriceApi.updateDispatchPrice(editId.value, submitData);
} else {
res = await dispatchPriceApi.createDispatchPrice(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await dispatchPriceApi.deleteDispatchPrice(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
// 行内状态切换
async function handleStatusChange(record: DispatchPriceInfo, checked: boolean) {
const status = checked ? 1 : 0;
try {
const res: any = await dispatchPriceApi.updateDispatchPriceStatus(record.id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已停用');
fetchList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
// ==================== 辅助 ====================
function getPriceModeText(mode?: number): string {
const textMap: Record<number, string> = { 1: '按次', 2: '按工时' };
return textMap[mode ?? 0] || '未知';
}
function getPriceModeColor(mode?: number): string {
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange' };
return colorMap[mode ?? 0] || 'default';
}
function formatPrice(value?: number | string | null): string {
return Number(value ?? 0).toFixed(2);
}
</script>
<style scoped>
.cost-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
/* 弹窗 */
.no-bar {
display: flex;
gap: 24px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 8px;
background: #f4f7ff;
border: 1px solid #dbe4ee;
}
.no-item {
display: flex;
align-items: baseline;
gap: 10px;
}
.no-label {
font-size: 12px;
color: #5b6b7e;
}
.no-value {
font-size: 14px;
font-weight: 700;
color: #0054cd;
}
.form-section {
margin-bottom: 4px;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f6;
}
.section-no {
width: 26px;
height: 26px;
border-radius: 7px;
background: #0054cd;
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Consolas, Menlo, monospace;
flex-shrink: 0;
}
.section-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.section-name {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.section-sub {
font-size: 11px;
color: #8a97a8;
letter-spacing: 0.5px;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
:deep(.ant-modal-title) {
color: #0d1c2c;
font-weight: 700;
}
</style>
+871
View File
@@ -0,0 +1,871 @@
<template>
<div class="price-page">
<!-- 顶部标题 + 操作 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>运营派单</span>
<span class="sep">/</span>
<span class="cur">运维费定价</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">运维费定价</span>
<span class="sub-title">SERVICE PRICE</span>
</div>
<p class="desc">统一维护面向客户的运维服务价目,支持按次、按工时与材料三种计价方式,可启停用与检索。</p>
</div>
<div class="header-actions">
<a-button v-if="canCreate" type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增价目
</a-button>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="价目编码 / 价目名称"
allow-clear
maxlength="64"
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
<div class="filter-item">
<label class="filter-label">计价方式</label>
<a-select v-model:value="searchForm.priceMode" placeholder="全部方式" allow-clear>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
<a-select-option :value="3">材料</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">服务类型</label>
<a-select v-model:value="searchForm.serviceType" placeholder="全部类型" allow-clear>
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
<a-select-option value="报警处理">报警处理</a-select-option>
<a-select-option value="巡检">巡检</a-select-option>
<a-select-option value="其他">其他</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<a-select v-model:value="searchForm.status" placeholder="全部状态" allow-clear>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">停用</a-select-option>
</a-select>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">运维费价目清单</span>
<span class="count">{{ total }}</span>
</div>
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'priceCode'">
<span class="mono">{{ record.priceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'priceName'">
<span class="price-name">{{ record.priceName }}</span>
</template>
<template v-else-if="column.key === 'serviceType'">
{{ record.serviceType || '-' }}
</template>
<template v-else-if="column.key === 'priceMode'">
<a-tag :color="getPriceModeColor(record.priceMode)">{{ getPriceModeText(record.priceMode) }}</a-tag>
</template>
<template v-else-if="column.key === 'unitPrice'">
<span class="mono">{{ formatPrice(record.unitPrice) }}</span>
</template>
<template v-else-if="column.key === 'unit'">
{{ record.unit || '-' }}
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="启用"
un-checked-children="停用"
@change="(checked: boolean) => handleStatusChange(record, checked)"
/>
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || '-' }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button v-if="canUpdate" type="link" size="small" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-popconfirm
v-if="canDelete"
title="确定要删除该价目吗?"
ok-text="确定"
cancel-text="取消"
ok-button-props="{ danger: true }"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<template #emptyText>
<a-empty description="暂无价目数据" />
</template>
</a-table>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="640px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<!-- 价目编码信息条(编辑态只读展示) -->
<div v-if="isEdit && editId" class="no-bar">
<div class="no-item">
<span class="no-label">价目编码</span>
<span class="no-value mono">{{ editCode || '-' }}</span>
</div>
</div>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<div class="form-section">
<div class="section-head">
<span class="section-no">01</span>
<div class="section-text">
<span class="section-name">价目信息</span>
<span class="section-sub">Price Information</span>
</div>
</div>
<a-form-item label="价目名称" name="priceName">
<a-input v-model:value="formData.priceName" placeholder="请输入价目名称" maxlength="64" />
</a-form-item>
<a-form-item label="服务类型" name="serviceType">
<a-select v-model:value="formData.serviceType" placeholder="请选择服务类型" allow-clear style="width: 100%">
<a-select-option value="维修">维修</a-select-option>
<a-select-option value="维保">维保</a-select-option>
<a-select-option value="报警处理">报警处理</a-select-option>
<a-select-option value="巡检">巡检</a-select-option>
<a-select-option value="其他">其他</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="计价方式" name="priceMode">
<a-select
v-model:value="formData.priceMode"
placeholder="请选择计价方式"
style="width: 100%"
@change="handlePriceModeChange"
>
<a-select-option :value="1">按次</a-select-option>
<a-select-option :value="2">按工时</a-select-option>
<a-select-option :value="3">材料</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="单价" name="unitPrice">
<a-input-number
v-model:value="formData.unitPrice"
:min="0"
:precision="2"
placeholder="请输入单价"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="单位" name="unit">
<a-input
v-model:value="formData.unit"
placeholder="如:次 / 小时 / 个"
maxlength="16"
@change="handleUnitManualChange"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import {
servicePriceApi,
type ServicePriceInfo,
type ServicePriceQuery,
} from '@/api/servicePrice';
import { useTableScroll } from '@/composables/useTableScroll';
import { usePermissionStore } from '@/stores/permission';
const { wrapperRef, tableHeight } = useTableScroll();
const permissionStore = usePermissionStore();
// 按钮权限
const canCreate = computed(() => permissionStore.hasPermission('price:create'));
const canUpdate = computed(() => permissionStore.hasPermission('price:update'));
const canDelete = computed(() => permissionStore.hasPermission('price:delete'));
// 计价方式 → 默认单位
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时', 3: '个' };
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<ServicePriceInfo[]>([]);
const total = ref(0);
const searchForm = reactive<ServicePriceQuery>({
page: 1,
pageSize: 10,
keyword: '',
priceMode: undefined as any,
serviceType: undefined as any,
status: undefined as any,
});
const columns = [
{ title: '价目编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
{ title: '价目名称', dataIndex: 'priceName', key: 'priceName', ellipsis: true },
{ title: '服务类型', dataIndex: 'serviceType', key: 'serviceType', ellipsis: true, width: 110 },
{ title: '计价方式', key: 'priceMode', align: 'center' as const, width: 110 },
{ title: '单价', key: 'unitPrice', align: 'right' as const, width: 110 },
{ title: '单位', dataIndex: 'unit', key: 'unit', align: 'center' as const, width: 80 },
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 弹窗表单 ====================
const modalVisible = ref(false);
const modalTitle = ref('');
const modalLoading = ref(false);
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const editCode = ref('');
// 用户是否手动修改过单位(手动修改后不再随计价方式联动覆盖)
const unitManuallyEdited = ref(false);
const formData = reactive({
priceName: '',
serviceType: undefined as string | undefined,
priceMode: undefined as number | undefined,
unitPrice: undefined as number | undefined,
unit: '',
remark: '',
status: 1,
});
const formRules = {
priceName: [
{ required: true, message: '请输入价目名称', trigger: 'blur' },
{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' },
],
priceMode: [{ required: true, message: '请选择计价方式', trigger: 'change' }],
unitPrice: [{ required: true, message: '请输入单价', trigger: 'change', type: 'number' as const }],
unit: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
remark: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
};
// ==================== 生命周期 ====================
onMounted(() => {
fetchList();
});
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res: any = await servicePriceApi.getServicePriceList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取运维费定价列表失败');
} finally {
loading.value = false;
}
}
// ==================== 搜索重置 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.priceMode = undefined as any;
searchForm.serviceType = undefined as any;
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 新增/编辑 ====================
function handleAdd() {
isEdit.value = false;
editId.value = null;
editCode.value = '';
modalTitle.value = '新增价目';
resetForm();
modalVisible.value = true;
}
function handleEdit(record: ServicePriceInfo) {
isEdit.value = true;
editId.value = record.id;
editCode.value = record.priceCode;
modalTitle.value = '编辑价目';
resetForm();
formData.priceName = record.priceName;
formData.serviceType = record.serviceType || undefined;
formData.priceMode = record.priceMode;
formData.unitPrice = Number(record.unitPrice);
formData.unit = record.unit || '';
formData.remark = record.remark || '';
formData.status = record.status;
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
unitManuallyEdited.value = true;
modalVisible.value = true;
}
function resetForm() {
formData.priceName = '';
formData.serviceType = undefined;
formData.priceMode = undefined;
formData.unitPrice = undefined;
formData.unit = '';
formData.remark = '';
formData.status = 1;
unitManuallyEdited.value = false;
if (formRef.value) {
formRef.value.resetFields();
}
}
// 计价方式变化:未手动改过单位时按计价方式联动单位
function handlePriceModeChange(v: any) {
if (!unitManuallyEdited.value) {
formData.unit = PRICE_MODE_UNIT[Number(v)] || '';
}
}
// 用户手动修改单位后置标记
function handleUnitManualChange() {
unitManuallyEdited.value = true;
}
async function handleSubmit() {
try {
await formRef.value.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const submitData = {
priceName: formData.priceName,
serviceType: formData.serviceType || undefined,
priceMode: formData.priceMode as number,
unitPrice: formData.unitPrice as number,
unit: formData.unit || undefined,
status: formData.status,
remark: formData.remark || undefined,
};
let res: any;
if (isEdit.value && editId.value) {
res = await servicePriceApi.updateServicePrice(editId.value, submitData);
} else {
res = await servicePriceApi.createServicePrice(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(id: number) {
try {
const res: any = await servicePriceApi.deleteServicePrice(id);
if (res.code === 200) {
message.success('删除成功');
searchForm.page = 1;
fetchList();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
// 行内状态切换
async function handleStatusChange(record: ServicePriceInfo, checked: boolean) {
const status = checked ? 1 : 0;
try {
const res: any = await servicePriceApi.updateServicePriceStatus(record.id, status);
if (res.code === 200) {
message.success(status === 1 ? '已启用' : '已停用');
fetchList();
} else {
message.error(res.message || '状态更新失败');
}
} catch (error) {
message.error('状态更新失败');
}
}
// ==================== 辅助 ====================
function getPriceModeText(mode?: number): string {
const textMap: Record<number, string> = { 1: '按次', 2: '按工时', 3: '材料' };
return textMap[mode ?? 0] || '未知';
}
function getPriceModeColor(mode?: number): string {
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange', 3: 'green' };
return colorMap[mode ?? 0] || 'default';
}
function formatPrice(value?: number | string | null): string {
return Number(value ?? 0).toFixed(2);
}
</script>
<style scoped>
.price-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
line-height: 1.6;
}
.header-actions {
flex-shrink: 0;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.price-name {
font-weight: 600;
color: #0d1c2c;
}
/* 弹窗 */
.no-bar {
display: flex;
gap: 24px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 8px;
background: #f4f7ff;
border: 1px solid #dbe4ee;
}
.no-item {
display: flex;
align-items: baseline;
gap: 10px;
}
.no-label {
font-size: 12px;
color: #5b6b7e;
}
.no-value {
font-size: 14px;
font-weight: 700;
color: #0054cd;
}
.form-section {
margin-bottom: 4px;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f6;
}
.section-no {
width: 26px;
height: 26px;
border-radius: 7px;
background: #0054cd;
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-family: Consolas, Menlo, monospace;
flex-shrink: 0;
}
.section-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.section-name {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.section-sub {
font-size: 11px;
color: #8a97a8;
letter-spacing: 0.5px;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
background: #0947ab;
border-color: #0947ab;
}
:deep(.ant-modal-title) {
color: #0d1c2c;
font-weight: 700;
}
</style>
File diff suppressed because it is too large. Load diff
+649
View File
@@ -0,0 +1,649 @@
<template>
<div class="job-management">
<a-card :bordered="false" class="search-card">
<a-form layout="inline" :model="queryParams">
<a-form-item label="任务名称">
<a-input
v-model:value="queryParams.jobName"
placeholder="请输入任务名称"
allow-clear
style="width: 180px"
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="queryParams.status"
placeholder="全部"
allow-clear
style="width: 120px"
>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">停用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<a-card :bordered="false" class="table-card">
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="jobList"
:loading="tableLoading"
:pagination="paginationConfig"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'jobName'">
<span>{{ record.jobName }}</span>
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
</template>
<template v-else-if="column.key === 'params'">
<a-tooltip :title="formatParams(record.params, true)">
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
</a-tooltip>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
:loading="statusLoadingId === record.id"
checked-children="启用"
un-checked-children="停用"
@change="(checked: boolean) => handleStatusChange(record, checked)"
/>
</template>
<template v-else-if="column.key === 'lastRunAt'">
{{ formatDateTime(record.lastRunAt) }}
</template>
<template v-else-if="column.key === 'lastRunStatus'">
<a-tag v-if="record.lastRunStatus" :color="runStatusColor(record.lastRunStatus)">
{{ runStatusText(record.lastRunStatus) }}
</a-tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'lastRunCostMs'">
{{ record.lastRunCostMs === null || record.lastRunCostMs === undefined ? '-' : `${record.lastRunCostMs} ms` }}
</template>
<template v-else-if="column.key === 'nextRunTime'">
{{ formatDateTime(record.nextRunTime) }}
</template>
<template v-else-if="column.key === 'retry'">
{{ `${record.retryTimes ?? 0} 次 / ${record.retryInterval ?? 0} 秒` }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleEdit(record)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleRun(record)">
<template #icon><PlayCircleOutlined /></template>
立即执行
</a-button>
<a-button type="link" size="small" @click="handleOpenLogs(record)">
<template #icon><ProfileOutlined /></template>
日志
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
</a-card>
<a-modal
v-model:open="editVisible"
title="编辑任务"
:confirm-loading="saving"
:mask-closable="false"
width="640px"
@ok="handleSave"
@cancel="handleCancelEdit"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-form-item label="任务名称">
<span>{{ currentJob?.jobName || '-' }}</span>
</a-form-item>
<a-form-item label="cron 表达式" name="cronExpression">
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
</a-form-item>
<a-form-item
v-for="field in paramFields"
:key="field.key"
:label="field.label"
:name="['params', field.key]"
:rules="getParamRules(field)"
>
<a-input-number
v-if="field.type === 'number'"
v-model:value="formData.params[field.key]"
:min="field.min"
:max="field.max"
:placeholder="field.placeholder || `请输入${field.label}`"
style="width: 100%"
/>
<a-input
v-else
v-model:value="formData.params[field.key]"
:placeholder="field.placeholder || `请输入${field.label}`"
/>
</a-form-item>
<a-form-item label="重试次数" name="retryTimes">
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
</a-form-item>
<a-form-item label="重试间隔(秒)" name="retryInterval">
<a-input-number v-model:value="formData.retryInterval" :min="10" :max="3600" style="width: 100%" />
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注"
:maxlength="255"
show-count
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
<a-drawer
v-model:open="logVisible"
:title="`执行日志 - ${currentJob?.jobName || ''}`"
width="900"
>
<div class="log-toolbar">
<a-select
v-model:value="logQuery.status"
placeholder="全部状态"
allow-clear
style="width: 140px"
@change="handleLogSearch"
>
<a-select-option :value="1">成功</a-select-option>
<a-select-option :value="2">失败</a-select-option>
<a-select-option :value="3">跳过</a-select-option>
</a-select>
<a-button danger @click="handleClearLogs">
<template #icon><DeleteOutlined /></template>
清理日志
</a-button>
</div>
<a-table
:columns="logColumns"
:data-source="logList"
:loading="logLoading"
:pagination="logPaginationConfig"
:expandable="logExpandable"
row-key="id"
size="small"
@change="handleLogTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'createdAt'">
{{ formatDateTime(record.createdAt) }}
</template>
<template v-else-if="column.key === 'triggerType'">
{{ triggerTypeText(record.triggerType) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="runStatusColor(record.status)">{{ runStatusText(record.status) }}</a-tag>
</template>
<template v-else-if="column.key === 'costMs'">
{{ record.costMs === null || record.costMs === undefined ? '-' : `${record.costMs} ms` }}
</template>
<template v-else-if="column.key === 'message'">
<a-tooltip :title="record.message || ''">
<span class="ellipsis-text">{{ record.message || '-' }}</span>
</a-tooltip>
</template>
</template>
<template #expandedRowRender="{ record }">
<pre class="error-stack">{{ record.errorStack }}</pre>
</template>
</a-table>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
ReloadOutlined,
EditOutlined,
PlayCircleOutlined,
ProfileOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
import { useTableScroll } from '@/composables/useTableScroll'
const { wrapperRef, tableHeight } = useTableScroll()
const tableLoading = ref(false)
const saving = ref(false)
const statusLoadingId = ref<number | null>(null)
const jobList = ref<JobItem[]>([])
const total = ref(0)
const queryParams = reactive({
page: 1,
pageSize: 10,
jobName: '',
status: undefined as number | undefined,
})
const columns = [
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
{ title: '上次状态', key: 'lastRunStatus', width: 100, align: 'center' as const },
{ title: '耗时', key: 'lastRunCostMs', width: 100 },
{ title: '下次执行时间', key: 'nextRunTime', width: 170 },
{ title: '重试配置', key: 'retry', width: 140 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
]
const paginationConfig = reactive({
current: queryParams.page,
pageSize: queryParams.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t: number) => `共 ${t} 条记录`,
})
const editVisible = ref(false)
const currentJob = ref<JobItem | null>(null)
const paramFields = ref<JobParamField[]>([])
const formRef = ref()
const formData = reactive({
cronExpression: '',
params: {} as Record<string, any>,
retryTimes: 0,
retryInterval: 60,
remark: '',
})
const formRules = {
cronExpression: [
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
],
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
}
const logVisible = ref(false)
const logLoading = ref(false)
const logList = ref<JobLogItem[]>([])
const logTotal = ref(0)
const logQuery = reactive({
page: 1,
pageSize: 10,
status: undefined as number | undefined,
triggerType: undefined as number | undefined,
})
const logColumns = [
{ title: '执行时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
{ title: '触发方式', dataIndex: 'triggerType', key: 'triggerType', width: 90 },
{ title: '状态', key: 'status', width: 80, align: 'center' as const },
{ title: '尝试', dataIndex: 'attempt', key: 'attempt', width: 70 },
{ title: '耗时', key: 'costMs', width: 90 },
{ title: '摘要', dataIndex: 'message', key: 'message' },
]
const logExpandable = {
rowExpandable: (record: JobLogItem) => !!record.errorStack,
}
const logPaginationConfig = reactive({
current: logQuery.page,
pageSize: logQuery.pageSize,
total: logTotal.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (t: number) => `共 ${t} 条记录`,
})
function formatDateTime(time?: string | null): string {
if (!time) return '-'
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
function formatParams(params?: Record<string, any>, full = false): string {
if (!params || !Object.keys(params).length) return '-'
const text = Object.entries(params)
.map(([key, value]) => `${key}=${value === null || value === undefined ? '' : value}`)
.join(full ? '\n' : ', ')
return text || '-'
}
function runStatusText(status: number): string {
return ({ 1: '成功', 2: '失败', 3: '跳过' } as Record<number, string>)[status] || '-'
}
function runStatusColor(status: number): string {
return ({ 1: 'green', 2: 'red', 3: 'orange' } as Record<number, string>)[status] || 'default'
}
function triggerTypeText(type: number): string {
return ({ 1: '定时', 2: '手动', 3: '重试' } as Record<number, string>)[type] || '-'
}
function getParamRules(field: JobParamField) {
if (field.type !== 'number') return []
return [
{
validator: (_rule: any, value: any) => {
if (value === undefined || value === null || value === '') return Promise.resolve()
if (field.min !== undefined && value < field.min) {
return Promise.reject(new Error(`${field.label}不能小于 ${field.min}`))
}
if (field.max !== undefined && value > field.max) {
return Promise.reject(new Error(`${field.label}不能大于 ${field.max}`))
}
return Promise.resolve()
},
trigger: 'change',
},
]
}
async function fetchJobList() {
tableLoading.value = true
try {
const res: any = await jobApi.getJobList({ ...queryParams })
if (res.code === 200) {
jobList.value = res.data.list
total.value = res.data.total
paginationConfig.total = res.data.total
}
} catch (error) {
message.error('获取任务列表失败')
} finally {
tableLoading.value = false
}
}
function handleSearch() {
queryParams.page = 1
paginationConfig.current = 1
fetchJobList()
}
function handleReset() {
queryParams.jobName = ''
queryParams.status = undefined
handleSearch()
}
function handleTableChange(pag: any) {
queryParams.page = pag.current
queryParams.pageSize = pag.pageSize
paginationConfig.current = pag.current
paginationConfig.pageSize = pag.pageSize
fetchJobList()
}
async function handleStatusChange(record: JobItem, checked: boolean) {
const status: 0 | 1 = checked ? 1 : 0
statusLoadingId.value = record.id
try {
const res: any = await jobApi.changeJobStatus(record.id, status)
if (res.code === 200) {
record.status = status
message.success(status === 1 ? '已启用' : '已停用')
fetchJobList()
} else {
message.error(res.message || '状态更新失败')
}
} catch (error) {
// 失败时 record.status 未变更,受控开关自动回滚
} finally {
statusLoadingId.value = null
}
}
function handleEdit(record: JobItem) {
currentJob.value = record
paramFields.value = record.paramFields || []
formData.cronExpression = record.cronExpression || ''
formData.params = { ...(record.params || {}) }
formData.retryTimes = record.retryTimes ?? 0
formData.retryInterval = record.retryInterval ?? 60
formData.remark = record.remark || ''
editVisible.value = true
nextTick(() => formRef.value?.clearValidate())
}
function handleCancelEdit() {
editVisible.value = false
}
async function handleSave() {
if (!currentJob.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const res: any = await jobApi.updateJob(currentJob.value.id, {
cronExpression: formData.cronExpression,
params: { ...formData.params },
retryTimes: formData.retryTimes,
retryInterval: formData.retryInterval,
remark: formData.remark,
})
if (res.code === 200) {
message.success('保存成功')
editVisible.value = false
fetchJobList()
} else {
message.error(res.message || '保存失败')
}
} catch (error) {
// 保存失败保持弹窗打开,后端 message 由响应拦截器提示
} finally {
saving.value = false
}
}
function handleRun(record: JobItem) {
Modal.confirm({
title: '立即执行确认',
content: `确定立即执行任务「${record.jobName}」吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
const res: any = await jobApi.runJob(record.id)
if (res.code === 200) {
message.success(res.data?.message || '执行完成')
fetchJobList()
if (logVisible.value && currentJob.value?.id === record.id) {
fetchJobLogs()
}
}
},
})
}
function handleOpenLogs(record: JobItem) {
currentJob.value = record
logQuery.page = 1
logQuery.pageSize = 10
logQuery.status = undefined
logQuery.triggerType = undefined
logPaginationConfig.current = 1
logPaginationConfig.pageSize = 10
logVisible.value = true
fetchJobLogs()
}
async function fetchJobLogs() {
if (!currentJob.value) return
logLoading.value = true
try {
const res: any = await jobApi.getJobLogs(currentJob.value.id, {
page: logQuery.page,
pageSize: logQuery.pageSize,
status: logQuery.status,
triggerType: logQuery.triggerType,
})
if (res.code === 200) {
logList.value = res.data.list
logTotal.value = res.data.total
logPaginationConfig.total = res.data.total
}
} catch (error) {
message.error('获取执行日志失败')
} finally {
logLoading.value = false
}
}
function handleLogSearch() {
logQuery.page = 1
logPaginationConfig.current = 1
fetchJobLogs()
}
function handleLogTableChange(pag: any) {
logQuery.page = pag.current
logQuery.pageSize = pag.pageSize
logPaginationConfig.current = pag.current
logPaginationConfig.pageSize = pag.pageSize
fetchJobLogs()
}
function handleClearLogs() {
Modal.confirm({
title: '清理日志确认',
content: '确定清理 90 天前的执行日志吗?此操作不可恢复。',
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
const res: any = await jobApi.clearJobLogs(90)
if (res.code === 200) {
message.success(`已清理 ${res.data?.deleted ?? 0} 条日志`)
handleLogSearch()
}
},
})
}
onMounted(() => {
fetchJobList()
})
</script>
<style scoped lang="less">
.job-management {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
.search-card {
flex-shrink: 0;
margin-bottom: 16px;
:deep(.ant-card-body) {
padding-bottom: 4px;
}
}
.table-card {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 13px;
height: 100%;
}
}
.ellipsis-text {
max-width: 160px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.error-stack {
margin: 0;
padding: 8px 12px;
background: #fafafa;
border-radius: 4px;
font-size: 12px;
color: #cf1322;
white-space: pre-wrap;
word-break: break-all;
}
}
</style>