feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
1 parent
1803ad679c
commit
f4454ad014
57 files changed
+9388
-200
No files matched your search
@@ -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 }),
|
||||
};
|
||||
@@ -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 } }),
|
||||
};
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
// ===== 看板 =====
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
Reference in new issue
Block a user