This commit is contained in:
chabai 2025-08-14 11:39:12 +08:00
commit 9e4af94ff4
27 changed files with 6419 additions and 1886 deletions

View File

@ -4,8 +4,8 @@ VITE_API_PREFIX = '/dev-api'
# 接口地址
# VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/'
# VITE_API_BASE_URL = 'http://localhost:8888/'
VITE_API_BASE_URL = 'http://10.18.34.163:8888/'
VITE_API_BASE_URL = 'http://localhost:8888/'
# VITE_API_BASE_URL = 'http://10.18.34.163:8888/'
# VITE_API_BASE_URL = 'http://10.18.34.213:8888/'
# 接口地址 (WebSocket)

View File

@ -99,6 +99,37 @@ export const equipmentProcurementApi = {
return http.get<ApiRes<EquipmentResp>>(`/equipment/procurement/detail/${equipmentId}`)
},
/**
*
*/
receiveGoods: (equipmentId: string, data: ReceiptRequest) => {
console.log('📦 收货API被调用设备ID:', equipmentId)
console.log('📦 收货数据:', data)
return http.post<ApiRes<null>>(`/equipment/procurement/receipt/${equipmentId}`, data)
},
/**
*
*/
getReceiptDetail: (equipmentId: string) => {
return http.get<ApiRes<ReceiptDetail>>(`/equipment/procurement/receipt/${equipmentId}`)
},
/**
*
*/
makePayment: (equipmentId: string, data: PaymentRequest) => {
return http.post<ApiRes<null>>(`/equipment/procurement/payment/${equipmentId}`, data)
},
/**
*
*/
getPaymentDetail: (equipmentId: string) => {
return http.get<ApiRes<PaymentDetail>>(`/equipment/procurement/payment/${equipmentId}`)
},
/**
*
*/
@ -117,9 +148,6 @@ export const equipmentProcurementApi = {
*
*/
export: (params: EquipmentListReq) => {
return http.get<Blob>('/equipment/procurement/export', {
params,
responseType: 'blob'
})
return http.get('/equipment/procurement/export', params, { responseType: 'blob' })
}
}

View File

@ -101,28 +101,28 @@ export interface EquipmentResp {
/** 资产编号 */
assetCode?: string
/** 设备名称 */
equipmentName: string
equipmentName?: string
/** 设备类型 */
equipmentType: string
/** 设备类型描述 */
equipmentType?: string
/** 设备类型标签 */
equipmentTypeLabel?: string
/** 设备型号 */
equipmentModel: string
/** 设备SN */
equipmentSn: string
equipmentModel?: string
/** 设备序列号 */
equipmentSn?: string
/** 品牌 */
brand?: string
/** 配置规格/参数 */
specification?: string
/** 设备状态 */
equipmentStatus: string
/** 设备状态描述 */
equipmentStatus?: string
/** 设备状态标签 */
equipmentStatusLabel?: string
/** 使用状态 */
useStatus: string
useStatus?: string
/** 位置状态 */
locationStatus?: string
/** 位置状态描述 */
/** 位置状态标签 */
locationStatusLabel?: string
/** 设备当前物理位置 */
physicalLocation?: string
@ -130,7 +130,7 @@ export interface EquipmentResp {
responsiblePerson?: string
/** 健康状态 */
healthStatus?: string
/** 健康状态描述 */
/** 健康状态标签 */
healthStatusLabel?: string
/** 采购时间 */
purchaseTime?: string
@ -138,13 +138,13 @@ export interface EquipmentResp {
inStockTime?: string
/** 启用时间 */
activationTime?: string
/** 预报废时间 */
/** 预报废时间 */
expectedScrapTime?: string
/** 实际报废时间 */
actualScrapTime?: string
/** 状态变更时间 */
statusChangeTime?: string
/** 采购订单 */
/** 采购订单 */
purchaseOrder?: string
/** 供应商名称 */
supplierName?: string
@ -158,9 +158,9 @@ export interface EquipmentResp {
depreciationYears?: number
/** 残值 */
salvageValue?: number
/** 保修截止日期 */
/** 保修到期日期 */
warrantyExpireDate?: string
/** 上次维护日期 */
/** 最后维护日期 */
lastMaintenanceDate?: string
/** 下次维护日期 */
nextMaintenanceDate?: string
@ -176,7 +176,7 @@ export interface EquipmentResp {
projectName?: string
/** 使用人ID */
userId?: string
/** 使用人 */
/** 使用人姓名 */
name?: string
/** 创建时间 */
createTime?: string
@ -194,12 +194,14 @@ export interface EquipmentResp {
inventoryBasis?: string
/** 动态记录 */
dynamicRecord?: string
/** 采购状态 */
procurementStatus?: string
/** 审批状态 */
approvalStatus?: string
/** 收货状态 */
receiptStatus?: string
/** 支付状态 */
paymentStatus?: string
}
/**
@ -406,3 +408,221 @@ export interface EquipmentApprovalResp {
/** 更新时间 */
updateTime: string
}
/**
*
*/
export interface ReceiptRequest {
// 收货特有信息
receiptTime: string
receiptPerson: string
receiptQuantity: number
receiptRemark?: string
appearanceCheck: string
functionTest: string
packageIntegrity: string
accessoryIntegrity: string
checkResult: 'PASS' | 'FAIL' | 'CONDITIONAL'
checkRemark?: string
storageLocation: string
storageManager: string
// 设备基本信息(从采购数据继承)
equipmentName?: string
equipmentModel?: string
equipmentType?: string
equipmentSn?: string
brand?: string
specification?: string
assetCode?: string
// 采购信息(从采购数据继承)
purchaseOrder?: string
supplierName?: string
purchasePrice?: number
purchaseTime?: string
quantity?: number
unitPrice?: number
totalPrice?: number
// 入库信息
inStockTime?: string
physicalLocation?: string
locationStatus?: string
responsiblePerson?: string
inventoryBarcode?: string
// 状态信息
equipmentStatus?: string
useStatus?: string
healthStatus?: string
receiptStatus?: string
// 其他管理信息
depreciationMethod?: string
depreciationYears?: number
salvageValue?: number
currentNetValue?: number
// 系统字段
createTime?: string
updateTime?: string
}
/**
*
*/
export interface ReceiptDetail {
/** 设备ID */
equipmentId: string
/** 设备名称 */
equipmentName: string
/** 设备类型 */
equipmentType: string
/** 设备型号 */
equipmentModel: string
/** 品牌 */
brand: string
/** 供应商 */
supplierName: string
/** 采购订单 */
purchaseOrder: string
/** 收货状态 */
receiptStatus: string
/** 收货时间 */
receiptTime: string
/** 收货人 */
receiptPerson: string
/** 收货数量 */
receiptQuantity: number
/** 收货备注 */
receiptRemark?: string
/** 外观检查 */
appearanceCheck: string
/** 功能测试 */
functionTest: string
/** 包装完整性 */
packageIntegrity: string
/** 配件完整性 */
accessoryIntegrity: string
/** 检查结果 */
checkResult: string
/** 检查备注 */
checkRemark?: string
/** 入库状态 */
storageStatus: string
/** 入库时间 */
storageTime: string
/** 入库位置 */
storageLocation: string
/** 库管员 */
storageManager: string
}
/**
*
*/
export interface PaymentRequest {
/** 支付方式 */
paymentMethod: string
/** 支付金额 */
paymentAmount: number
/** 支付时间 */
paymentTime: string
/** 支付人 */
paymentPerson: string
/** 支付备注 */
paymentRemark?: string
/** 发票类型 */
invoiceType: string
/** 发票号码 */
invoiceNumber: string
/** 开票日期 */
invoiceDate: string
/** 发票金额 */
invoiceAmount: number
/** 税率 */
taxRate: number
/** 税额 */
taxAmount: number
/** 不含税金额 */
amountWithoutTax: number
/** 合同编号 */
contractNumber: string
/** 合同金额 */
contractAmount: number
/** 签订日期 */
contractDate: string
/** 付款条件 */
paymentTerms: string
/** 付款期限 */
paymentDeadline: string
}
/**
*
*/
export interface PaymentDetail {
/** 设备ID */
equipmentId: string
/** 设备名称 */
equipmentName: string
/** 设备类型 */
equipmentType: string
/** 设备型号 */
equipmentModel: string
/** 品牌 */
brand: string
/** 供应商 */
supplierName: string
/** 采购订单 */
purchaseOrder: string
/** 采购价格 */
purchasePrice: number
/** 采购数量 */
quantity: number
/** 总金额 */
totalPrice: number
/** 采购日期 */
purchaseTime: string
/** 支付状态 */
paymentStatus: string
/** 支付方式 */
paymentMethod: string
/** 支付金额 */
paymentAmount: number
/** 支付时间 */
paymentTime: string
/** 支付人 */
paymentPerson: string
/** 支付备注 */
paymentRemark?: string
/** 发票状态 */
invoiceStatus: string
/** 发票类型 */
invoiceType: string
/** 发票号码 */
invoiceNumber: string
/** 开票日期 */
invoiceDate: string
/** 发票金额 */
invoiceAmount: number
/** 税率 */
taxRate: number
/** 税额 */
taxAmount: number
/** 不含税金额 */
amountWithoutTax: number
/** 合同编号 */
contractNumber: string
/** 合同状态 */
contractStatus: string
/** 合同金额 */
contractAmount: number
/** 签订日期 */
contractDate: string
/** 付款条件 */
paymentTerms: string
/** 付款期限 */
paymentDeadline: string
}

View File

@ -15,6 +15,12 @@ export function getProject(id: string | number) {
return http.get<T.ProjectResp>(`${BASE_URL}/${id}`)
}
/** @desc 获取项目详情(标准详情接口) */
export function getProjectDetail(id: string | number) {
return http.get<T.ProjectResp>(`${BASE_URL}/detail/${id}`)
}
/** @desc 新增项目 */
export function addProject(data: any) {
return http.post(`${BASE_URL}`, data)

View File

@ -18,6 +18,8 @@ export interface ProjectResp {
projectCategory?: string // 项目类型/服务
projectManagerId?: string // 项目经理ID
projectManagerName?: string // 项目经理姓名
projectOrigin?: string // 项目来源
projectStaff?: string[] // 施工人员
startDate?: string // 开始日期
endDate?: string // 结束日期

View File

@ -19,7 +19,7 @@
:mask-closable="true"
:closable="true"
:destroy-on-close="false"
:z-index="999999"
:z-index="1000"
class="notification-modal"
>
<!-- 消息中心头部 -->
@ -673,7 +673,7 @@ defineExpose({
<style scoped lang="scss">
.notification-center {
position: relative;
z-index: 999999;
z-index: 1000;
.notification-trigger {
cursor: pointer;
@ -699,15 +699,15 @@ defineExpose({
//
.notification-modal {
:deep(.arco-modal) {
z-index: 999999 !important;
z-index: 1000 !important;
}
:deep(.arco-modal-mask) {
z-index: 999998 !important;
z-index: 999 !important;
}
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
z-index: 1000 !important;
}
}
@ -858,37 +858,37 @@ defineExpose({
padding: 16px;
}
//
//
:deep(.arco-modal) {
z-index: 999999 !important;
z-index: 1000 !important;
}
:deep(.arco-modal-mask) {
z-index: 999998 !important;
z-index: 999 !important;
}
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
z-index: 1000 !important;
}
// Arco Design v2
:deep(.arco-overlay) {
z-index: 999999 !important;
z-index: 1000 !important;
}
:deep(.arco-overlay-container) {
z-index: 999999 !important;
z-index: 1000 !important;
}
//
//
:deep(.arco-modal) {
z-index: 999999 !important;
z-index: 1000 !important;
position: relative !important;
}
//
//
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
z-index: 1000 !important;
position: relative !important;
}
</style>

View File

@ -1199,46 +1199,141 @@ export const systemRoutes: RouteRecordRaw[] = [
],
},
{
path: '/chat-platform',
name: 'ChatPlatform',
component: Layout,
redirect: '/chat-platform/options',
meta: { title: '聊天平台', icon: 'message', hidden: false, sort: 6 },
path: '/image-detection',
name: 'ImageDetection',
component: Layout,
redirect: '/Image-detection/tower-monitoring/clearance-monitoring',
meta: {
title: '图像检测',
icon: 'monitor',
hidden: false,
sort: 6.5,
},
children: [
// {
// path: '/chat-platform/options',
// name: 'ChatOptions',
// component: () => import('@/views/default/redirect/index.vue'), // 临时使用一个组件,实际开发中需要替换
// meta: {
// title: '二级选项1',
// icon: 'setting',
// hidden: false
// }
// }
],
{
path: '/image-detection/image-analysis',
name: 'ImageAnalysis',
component: () => import('@/components/ParentView/index.vue'),
meta: {
title: '检查图像分析',
icon: 'line-chart',
hidden: false,
},
redirect: '/image-detection/image-analysis/defect-detection',
children: [
{path: '/image-detection/image-analysis/defect-detection',
name: 'DefectDetection',
component: () => import('@/views/construction-operation-platform/implementation-workflow/data-processing/intelligent-inspection/defect-algorithm/index.vue'),
meta: {
title: '缺陷检测',
icon: 'line-chart',
hidden: false,
}
},
{path: '/image-detection/image-analysis/defect-edit',
name: 'DefectEdit',
component: () => import('@/components/ParentView/index.vue'),
meta: {
title: '缺陷编辑',
icon: 'line-chart',
hidden: false,
}
},
{path: '/image-detection/image-analysis/generate-reports',
name: 'GenerateReports',
component: () => import('@/views/project-operation-platform/data-processing/report-generation/index.vue'),
meta: {
title: '生成报告',
icon: 'line-chart',
hidden: false,
}
},
{path: '/image-detection/image-analysis/defect-base',
name: 'DefectBase',
component: () => import('@/views/project-operation-platform/data-processing/standard-info/index.vue'),
meta: {
title: '缺陷标准数据信息库',
icon: 'line-chart',
hidden: false,
}
},
]
},
{
path: '/tower-monitoring',
name: 'TowerMonitoring',
component: () => import('@/components/ParentView/index.vue'),
redirect: '/tower-monitoring/clearance-monitoring',
meta: {
title: '音视频检测',
icon: 'monitor',
hidden: false,
sort: 6.5,
},
children: [
{
path: '/tower-monitoring/clearance-monitoring',
name: 'ClearanceMonitoring',
component: () => import('@/views/tower-monitoring/deformation.vue'),
meta: {
title: '净空监测',
icon: 'fullscreen',
hidden: false,
},
},
{
path: '/tower-monitoring/deformation-monitoring',
name: 'DeformationMonitoring',
component: () => import('@/views/tower-monitoring/clearance.vue'),
meta: {
title: '形变监测',
icon: 'line-chart',
hidden: false,
},
},
{
path: '/tower-monitoring/whistle-monitoring',
name: 'WhistleMonitoring',
component: () => import('@/views/tower-monitoring/whistle.vue'),
meta: {
title: '哨声监测',
icon: 'sound',
hidden: false,
},
},
{
path: '/tower-monitoring/vibration-monitoring',
name: 'VibrationMonitoring',
component: () => import('@/views/tower-monitoring/vibration.vue'),
meta: {
title: '振动监测',
icon: 'shake',
hidden: false,
},
},
],
},
{
path: '/image-detection/reporting-center',
name: 'ReportingCenter',
component: () => import('@/views/tower-monitoring/vibration.vue'),
meta: {
title: '报告中心',
icon: 'shake',
hidden: false,
},
}
]
},
// {
// path: '/user/profile',
// name: 'UserProfile',
// path: '/chat-platform',
// name: 'ChatPlatform',
// component: Layout,
// redirect: '/user/profile',
// meta: {
// title: '个人中心',
// icon: 'user',
// hidden: false,
// sort: 100,
// },
// redirect: '/chat-platform/options',
// meta: { title: '聊天平台', icon: 'message', hidden: false, sort: 6 },
// children: [
// {
// path: '/user/profile',
// name: 'UsersProfile',
// component: () => import('@/views/user/profile/index.vue'),
// meta: {
// title: '个人中心',
// icon: 'user',
// hidden: false,
// },
// },
// ],
// },
{

View File

@ -7,70 +7,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ApprovalAssistant: typeof import('./../components/ApprovalAssistant/index.vue')['default']
ApprovalMessageItem: typeof import('./../components/NotificationCenter/ApprovalMessageItem.vue')['default']
Avatar: typeof import('./../components/Avatar/index.vue')['default']
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
CellCopy: typeof import('./../components/CellCopy/index.vue')['default']
Chart: typeof import('./../components/Chart/index.vue')['default']
CircularProgress: typeof import('./../components/CircularProgress/index.vue')['default']
ColumnSetting: typeof import('./../components/GiTable/src/components/ColumnSetting.vue')['default']
CronForm: typeof import('./../components/GenCron/CronForm/index.vue')['default']
CronModal: typeof import('./../components/GenCron/CronModal/index.vue')['default']
DateRangePicker: typeof import('./../components/DateRangePicker/index.vue')['default']
DayForm: typeof import('./../components/GenCron/CronForm/component/day-form.vue')['default']
FilePreview: typeof import('./../components/FilePreview/index.vue')['default']
GiCellAvatar: typeof import('./../components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./../components/GiCell/GiCellGender.vue')['default']
GiCellStatus: typeof import('./../components/GiCell/GiCellStatus.vue')['default']
GiCellTag: typeof import('./../components/GiCell/GiCellTag.vue')['default']
GiCellTags: typeof import('./../components/GiCell/GiCellTags.vue')['default']
GiCodeView: typeof import('./../components/GiCodeView/index.vue')['default']
GiDot: typeof import('./../components/GiDot/index.tsx')['default']
GiEditTable: typeof import('./../components/GiEditTable/GiEditTable.vue')['default']
GiFooter: typeof import('./../components/GiFooter/index.vue')['default']
GiForm: typeof import('./../components/GiForm/src/GiForm.vue')['default']
GiIconBox: typeof import('./../components/GiIconBox/index.vue')['default']
GiIconSelector: typeof import('./../components/GiIconSelector/index.vue')['default']
GiIframe: typeof import('./../components/GiIframe/index.vue')['default']
GiOption: typeof import('./../components/GiOption/index.vue')['default']
GiOptionItem: typeof import('./../components/GiOptionItem/index.vue')['default']
GiPageLayout: typeof import('./../components/GiPageLayout/index.vue')['default']
GiSpace: typeof import('./../components/GiSpace/index.vue')['default']
GiSplitButton: typeof import('./../components/GiSplitButton/index.vue')['default']
GiSplitPane: typeof import('./../components/GiSplitPane/index.vue')['default']
GiSplitPaneFlexibleBox: typeof import('./../components/GiSplitPane/components/GiSplitPaneFlexibleBox.vue')['default']
GiSvgIcon: typeof import('./../components/GiSvgIcon/index.vue')['default']
GiTable: typeof import('./../components/GiTable/src/GiTable.vue')['default']
GiTag: typeof import('./../components/GiTag/index.tsx')['default']
GiThemeBtn: typeof import('./../components/GiThemeBtn/index.vue')['default']
HourForm: typeof import('./../components/GenCron/CronForm/component/hour-form.vue')['default']
Icon403: typeof import('./../components/icons/Icon403.vue')['default']
Icon404: typeof import('./../components/icons/Icon404.vue')['default']
Icon500: typeof import('./../components/icons/Icon500.vue')['default']
IconBorders: typeof import('./../components/icons/IconBorders.vue')['default']
IconTableSize: typeof import('./../components/icons/IconTableSize.vue')['default']
IconTreeAdd: typeof import('./../components/icons/IconTreeAdd.vue')['default']
IconTreeReduce: typeof import('./../components/icons/IconTreeReduce.vue')['default']
ImageImport: typeof import('./../components/ImageImport/index.vue')['default']
ImageImportWizard: typeof import('./../components/ImageImportWizard/index.vue')['default']
IndustrialImageList: typeof import('./../components/IndustrialImageList/index.vue')['default']
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
NotificationCenter: typeof import('./../components/NotificationCenter/index.vue')['default']
ParentView: typeof import('./../components/ParentView/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SecondForm: typeof import('./../components/GenCron/CronForm/component/second-form.vue')['default']
SplitPanel: typeof import('./../components/SplitPanel/index.vue')['default']
TextCopy: typeof import('./../components/TextCopy/index.vue')['default']
TurbineGrid: typeof import('./../components/TurbineGrid/index.vue')['default']
UserSelect: typeof import('./../components/UserSelect/index.vue')['default']
Verify: typeof import('./../components/Verify/index.vue')['default']
VerifyPoints: typeof import('./../components/Verify/Verify/VerifyPoints.vue')['default']
VerifySlide: typeof import('./../components/Verify/Verify/VerifySlide.vue')['default']
WeekForm: typeof import('./../components/GenCron/CronForm/component/week-form.vue')['default']
YearForm: typeof import('./../components/GenCron/CronForm/component/year-form.vue')['default']
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,105 @@
<template>
<div class="file-header">
<div class="breadcrumbs">
<a-breadcrumb>
<a-breadcrumb-item
v-for="(item, index) in breadcrumbPath"
:key="index"
:class="{ 'clickable': index < breadcrumbPath.length - 1 }"
@click="handleBreadcrumbClick(index)"
>
{{ item }}
</a-breadcrumb-item>
</a-breadcrumb>
<a-button
type="text"
shape="circle"
@click="handleRefresh"
:loading="refreshing"
tooltip="刷新数据"
>
<template #icon>
<icon-refresh :spin="refreshing" />
</template>
</a-button>
</div>
<a-space>
<a-button type="outline" @click="handleUpload">
<template #icon><icon-upload /></template>
上传文件
</a-button>
<a-button type="primary" @click="handleCreateFolder">
<template #icon><icon-plus /></template>
新建文件夹
</a-button>
</a-space>
</div>
</template>
<script setup>
import { IconRefresh, IconUpload, IconPlus } from '@arco-design/web-vue/es/icon';
// props
const props = defineProps({
breadcrumbPath: {
type: Array,
default: () => []
},
refreshing: {
type: Boolean,
default: false
}
});
// emit
const emit = defineEmits(['breadcrumb-click', 'refresh', 'upload', 'create-folder']);
//
const handleBreadcrumbClick = (index) => {
emit('breadcrumb-click', index);
};
//
const handleRefresh = () => {
emit('refresh');
};
//
const handleUpload = () => {
emit('upload');
};
//
const handleCreateFolder = () => {
emit('create-folder');
};
</script>
<style scoped>
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
height: 64px;
background: var(--color-bg-1);
border-bottom: 1px solid var(--color-border);
}
.breadcrumbs {
display: flex;
align-items: center;
gap: 16px;
}
.clickable {
cursor: pointer;
color: var(--color-primary);
transition: color 0.2s ease;
}
.clickable:hover {
color: var(--color-primary-light-1);
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,669 @@
<template>
<div class="file-list-container">
<!-- 文件列表标题和搜索框在同一行 -->
<div v-if="currentFolderId" class="file-header-container">
<div class="file-title">
<span class="file-list-title">文件列表 ({{ files.length }})</span>
</div>
<div class="file-search-container">
<a-input-search
v-model="fileSearchKeyword"
placeholder="搜索文件名..."
class="file-search-input"
@search="handleFileSearch"
@input="handleFileSearchInput"
@clear="handleFileSearchClear"
allow-clear
/>
</div>
</div>
<a-divider size="small" v-if="currentFolderId" />
<template v-if="!currentFolderId">
<div class="initial-state">
<icon-folder-add class="initial-icon" />
<div class="initial-text">请从左侧选择一个文件夹</div>
</div>
</template>
<!-- 文件列表加载状态 -->
<a-skeleton
:loading="loading && currentFolderId"
:rows="8"
v-if="loading && currentFolderId"
animation="pulse"
>
<template #skeleton>
<a-row class="table-data-row" v-for="i in 8" :key="i">
<a-col :span="10" class="table-column name-column">
<div class="file-main">
<div class="w-8 h-8 rounded bg-gray-200 mr-3"></div>
<div class="file-name-wrap">
<div class="h-5 bg-gray-200 rounded w-1/2 mb-1"></div>
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
</div>
</div>
</a-col>
<a-col :span="4" class="table-column type-column">
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
</a-col>
<a-col :span="3" class="table-column size-column">
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</a-col>
<a-col :span="5" class="table-column time-column">
<div class="h-4 bg-gray-200 rounded w-2/3"></div>
</a-col>
<a-col :span="2" class="table-column action-column">
<div class="flex gap-2">
<div class="w-6 h-6 rounded bg-gray-200"></div>
<div class="w-6 h-6 rounded bg-gray-200"></div>
<div class="w-6 h-6 rounded bg-gray-200"></div>
<div class="w-6 h-6 rounded bg-gray-200"></div>
</div>
</a-col>
</a-row>
</template>
</a-skeleton>
<!-- 文件表格 -->
<div class="file-grid-container" v-if="currentFolderId && !loading">
<!-- 表头行 -->
<a-row class="table-header-row">
<a-col :span="10" class="table-column name-column">
<div class="sortable-header" @click="handleSortChange('fileName')">
<span>文件名</span>
<div class="sort-indicator">
<div class="sort-arrow up" :class="{ active: props.sortField === 'file_name' && props.sortOrder === 'asc' }"></div>
<div class="sort-arrow down" :class="{ active: props.sortField === 'file_name' && props.sortOrder === 'desc' }"></div>
</div>
</div>
</a-col>
<a-col :span="4" class="table-column type-column">
<div class="sortable-header" @click="handleSortChange('fileType')">
<span>类型</span>
<div class="sort-indicator">
<div class="sort-arrow up" :class="{ active: props.sortField === 'file_type' && props.sortOrder === 'asc' }"></div>
<div class="sort-arrow down" :class="{ active: props.sortField === 'file_type' && props.sortOrder === 'desc' }"></div>
</div>
</div>
</a-col>
<a-col :span="3" class="table-column size-column">
<div class="sortable-header" @click="handleSortChange('fileSize')">
<span>大小</span>
<div class="sort-indicator">
<div class="sort-arrow up" :class="{ active: props.sortField === 'file_size' && props.sortOrder === 'asc' }"></div>
<div class="sort-arrow down" :class="{ active: props.sortField === 'file_size' && props.sortOrder === 'desc' }"></div>
</div>
</div>
</a-col>
<a-col :span="5" class="table-column time-column">
<div class="sortable-header" @click="handleSortChange('uploadTime')">
<span>修改时间</span>
<div class="sort-indicator">
<div class="sort-arrow up" :class="{ active: props.sortField === 'upload_time' && props.sortOrder === 'asc' }"></div>
<div class="sort-arrow down" :class="{ active: props.sortField === 'upload_time' && props.sortOrder === 'desc' }"></div>
</div>
</div>
</a-col>
<a-col :span="2" class="table-column action-column">操作</a-col>
</a-row>
<!-- 数据行 -->
<a-row
v-for="file in files"
:key="file.fileId"
class="table-data-row"
>
<!-- 文件名列 -->
<a-col :span="10" class="table-column name-column">
<div class="file-main">
<icon-file :style="{ color: fileColor(getFileExtension(file.fileName || file.name)) }" class="file-icon-large" />
<div class="file-name-wrap">
<a-typography-title :heading="6" class="file-name">{{ file.fileName || file.name }}</a-typography-title>
<div class="file-name-small">{{ file.fileName || file.name }}</div>
</div>
</div>
</a-col>
<!-- 类型列 -->
<a-col :span="4" class="table-column type-column">
<div class="cell-content">{{ fileTypeText(getFileExtension(file.fileName || file.name)) }}</div>
</a-col>
<!-- 大小列 -->
<a-col :span="3" class="table-column size-column">
<div class="cell-content">{{ formatFileListSize(file.fileSize || file.size) }}</div>
</a-col>
<!-- 时间列 -->
<a-col :span="5" class="table-column time-column">
<div class="cell-content">{{ formatUploadTime(file.uploadTime || file.uploadTime) }}</div>
</a-col>
<!-- 操作列 -->
<a-col :span="2" class="table-column action-column">
<div class="file-actions">
<a-button
type="text"
shape="circle"
size="small"
tooltip="预览"
@click="handlePreview(file)"
>
<icon-eye />
</a-button>
<a-button
type="text"
shape="circle"
size="small"
tooltip="下载"
@click="handleDownload(file)"
>
<icon-download />
</a-button>
<a-button
type="text"
shape="circle"
size="small"
tooltip="重命名"
@click="handleEditFile(file)"
>
<icon-edit />
</a-button>
<a-button
type="text"
shape="circle"
size="small"
tooltip="删除"
@click="handleDelete(file)"
class="action-btn delete-btn"
>
<icon-delete />
</a-button>
</div>
</a-col>
</a-row>
</div>
<!-- 空状态 -->
<a-empty
v-if="!loading && currentFolderId && files.length === 0"
description="暂无文件"
class="empty-state"
>
<template #image><icon-file /></template>
<template #actions>
<a-button type="primary" @click="handleUpload">
<template #icon><icon-upload /></template>
上传文件
</a-button>
</template>
</a-empty>
</div>
</template>
<script setup>
//
import { ref, computed, watch } from 'vue';
import {
IconFolder,
IconFile,
IconMore,
IconDownload,
IconDelete,
IconEdit,
IconEye,
IconCopy,
IconFolderAdd,
IconUpload
} from '@arco-design/web-vue/es/icon';
// props
const props = defineProps({
files: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
currentFolderId: {
type: [String, Number],
default: null
},
sortField: {
type: String,
default: ''
},
sortOrder: {
type: String,
default: ''
}
});
// emit
const emit = defineEmits([
'file-click',
'file-download',
'file-delete',
'file-edit',
'file-preview',
'file-copy',
'file-more',
'file-search',
'file-search-input',
'file-search-clear',
'sort-change',
'upload'
]);
//
const fileSearchKeyword = ref('');
//
watch(() => props.sortField, (newVal, oldVal) => {
console.log('👀 FileList组件 - sortField变化:', oldVal, '->', newVal);
});
watch(() => props.sortOrder, (newVal, oldVal) => {
console.log('👀 FileList组件 - sortOrder变化:', oldVal, '->', newVal);
});
//
const handleFileSearch = (value) => {
emit('file-search', value);
};
//
const handleFileSearchInput = (value) => {
emit('file-search-input', value);
};
//
const handleFileSearchClear = () => {
emit('file-search-clear');
};
//
const handleSortChange = (field) => {
console.log('🎯 FileList组件 - 排序点击:', field);
console.log('🎯 FileList组件 - 当前sortField:', props.sortField);
console.log('🎯 FileList组件 - 当前sortOrder:', props.sortOrder);
emit('sort-change', field);
};
//
const handlePreview = (file) => {
emit('file-preview', file);
};
//
const handleDownload = (file) => {
emit('file-download', file);
};
//
const handleEditFile = (file) => {
emit('file-edit', file);
};
//
const handleDelete = (file) => {
emit('file-delete', file);
};
//
const handleUpload = () => {
emit('upload');
};
// -
const getFileExtension = (filename) => {
if (!filename) return '';
return filename.split('.').pop().toLowerCase();
};
// -
const fileColor = (extension) => {
const colorMap = {
pdf: '#ff4d4f',
doc: '#1890ff',
docx: '#1890ff',
xls: '#52c41a',
xlsx: '#52c41a',
ppt: '#fa8c16',
pptx: '#fa8c16',
zip: '#722ed1',
rar: '#722ed1',
txt: '#8c8c8c',
jpg: '#fadb14',
jpeg: '#fadb14',
png: '#fadb14',
gif: '#fadb14',
bmp: '#fadb14',
webp: '#fadb14'
};
return colorMap[extension] || '#8c8c8c';
};
// -
const fileTypeText = (extension) => {
const typeMap = {
pdf: 'PDF文档',
doc: 'Word文档',
docx: 'Word文档',
xls: 'Excel表格',
xlsx: 'Excel表格',
ppt: 'PPT演示',
pptx: 'PPT演示',
zip: '压缩文件',
rar: '压缩文件',
txt: '文本文件',
jpg: '图片文件',
jpeg: '图片文件',
png: '图片文件',
gif: '图片文件',
bmp: '图片文件',
webp: '图片文件'
};
return typeMap[extension] || '未知文件';
};
// -
const formatFileListSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// -
const formatUploadTime = (time) => {
if (!time) return '';
const date = new Date(time);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
</script>
<style scoped>
.file-list-container {
width: 100%;
}
.file-header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.file-title {
display: flex;
align-items: center;
}
.file-list-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
.file-search-container {
flex-shrink: 0;
}
.file-search-input {
width: 300px;
}
.initial-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
color: var(--color-text-3);
background-color: var(--color-fill-1);
border-radius: 8px;
text-align: center;
}
.initial-icon {
font-size: 48px;
margin-bottom: 16px;
color: var(--color-text-4);
}
.file-grid-container {
flex: 1;
width: 100%;
margin-top: 16px;
border-radius: 8px;
border: 1px solid var(--color-border);
overflow-y: auto;
background-color: var(--color-bg-1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 0;
min-height: 300px;
max-height: calc(100vh - 380px);
}
.table-header-row {
padding: 0 16px;
height: 48px;
line-height: 48px;
background-color: var(--color-fill-1);
border-bottom: 1px solid var(--color-border);
font-size: 13px;
color: var(--color-text-3);
font-weight: 500;
}
.table-data-row {
display: flex;
padding: 0 16px;
height: 64px;
align-items: center;
border-bottom: 1px solid var(--color-border);
transition: all 0.25s ease;
cursor: pointer;
background-color: var(--color-bg-1);
}
.table-data-row:last-child {
border-bottom: none;
}
.table-data-row:hover {
background-color: rgba(22, 93, 255, 0.1);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}
.table-column {
height: 100%;
display: flex;
align-items: center;
padding: 0 8px;
}
.name-column {
flex: 2;
}
.type-column {
flex: 1;
}
.size-column {
flex: 1;
}
.time-column {
flex: 1.5;
}
.action-column {
flex: 0.5;
justify-content: center;
}
.sortable-header {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
transition: color 0.2s ease;
}
.sortable-header:hover {
color: var(--color-primary);
}
.sort-indicator {
display: flex;
flex-direction: column;
margin-left: 4px;
height: 12px;
}
.sort-arrow {
width: 0;
height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
transition: border-color 0.2s ease;
}
.sort-arrow.up {
border-bottom: 3px solid var(--color-text-4);
margin-bottom: 1px;
}
.sort-arrow.down {
border-top: 3px solid var(--color-text-4);
}
.sort-arrow.active {
border-bottom-color: var(--color-primary);
border-top-color: var(--color-primary);
}
.file-main {
display: flex;
align-items: center;
width: 100%;
}
.file-icon-large {
font-size: 24px;
margin-right: 12px;
flex-shrink: 0;
}
.file-name-wrap {
flex: 1;
min-width: 0;
}
.file-name {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-name-small {
font-size: 12px;
color: var(--color-text-3);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-content {
font-size: 13px;
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.table-data-row:hover .file-actions {
opacity: 1;
}
.file-actions .action-btn {
width: 28px;
height: 28px;
color: var(--color-text-3);
border-radius: 4px;
transition: all 0.2s ease;
}
.file-actions .action-btn:hover {
color: var(--color-primary);
background: var(--color-fill-3);
transform: scale(1.1);
}
.file-actions .delete-btn:hover {
color: var(--color-danger);
background: var(--color-danger-light-1);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 0;
color: var(--color-text-3);
background-color: var(--color-fill-1);
border-radius: 8px;
text-align: center;
}
:deep(.empty-state .arco-btn) {
margin-top: 16px;
padding: 8px 16px;
background-color: var(--color-primary);
color: white;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
:deep(.empty-state .arco-btn:hover) {
background-color: var(--color-primary-dark-1);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
:deep(.empty-state .arco-btn:active) {
transform: translateY(0);
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<div v-if="visible" class="pagination-container">
<a-pagination
:total="total"
:current="current"
:page-size="pageSize"
:show-total="true"
:show-page-size="true"
:page-size-options="[10, 20, 50, 100]"
:show-jumper="true"
:hide-on-single-page="false"
size="default"
@change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</template>
<script setup>
// props
const props = defineProps({
total: {
type: Number,
default: 0
},
current: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
},
visible: {
type: Boolean,
default: true
}
});
// emit
const emit = defineEmits(['page-change', 'page-size-change']);
//
const handlePageChange = (page) => {
emit('page-change', page);
};
//
const handlePageSizeChange = (pageSize) => {
emit('page-size-change', pageSize);
};
</script>
<style scoped>
.pagination-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg-1);
padding: 16px 24px;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
align-items: center;
z-index: 10;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
margin-top: 0;
}
.pagination-container :deep(.arco-pagination) {
margin: 0;
}
.pagination-container :deep(.arco-pagination-item) {
border-radius: 6px;
margin: 0 4px;
transition: all 0.2s ease;
}
.pagination-container :deep(.arco-pagination-item:hover) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination-container :deep(.arco-pagination-item-active) {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.pagination-container :deep(.arco-pagination-prev),
.pagination-container :deep(.arco-pagination-next) {
border-radius: 6px;
transition: all 0.2s ease;
}
.pagination-container :deep(.arco-pagination-prev:hover),
.pagination-container :deep(.arco-pagination-next:hover) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pagination-container :deep(.arco-pagination-size-changer) {
margin-left: 16px;
}
.pagination-container :deep(.arco-pagination-jumper) {
margin-left: 16px;
}
.pagination-container :deep(.arco-pagination-total) {
color: var(--color-text-2);
font-size: 14px;
}
</style>

View File

@ -0,0 +1,557 @@
<template>
<a-modal
:visible="visible"
title="上传文件"
width="620px"
:mask-closable="false"
@ok="handleSubmit"
@cancel="handleCancel"
:confirm-loading="uploading"
:ok-disabled="!canUpload"
@update:visible="(val) => emit('update:visible', val)"
>
<a-form :model="uploadForm" ref="uploadFormRef" layout="vertical">
<!-- 选择文件 -->
<a-form-item
label="选择文件"
:validate-status="!hasFiles ? 'error' : ''"
:help="!hasFiles ? '请选择需要上传的文件' : ''"
>
<div class="upload-container">
<!-- 上传按钮 -->
<a-upload
ref="uploadRef"
:key="visible ? 'upload-open' : 'upload-closed'"
:auto-upload="false"
:show-file-list="false"
@change="handleFileChange"
:accept="allowedFileTypes"
multiple
>
<a-button type="primary" class="upload-btn">
<icon-upload />
点击选择文件
</a-button>
</a-upload>
<!-- 文件类型提示 -->
<div class="upload-hint">
支持 {{ allowedFileTypesText }} 等格式单个文件不超过 {{ maxFileSizeText }}
</div>
</div>
<!-- 文件列表 -->
<div class="upload-file-list" v-if="fileListTemp.length > 0">
<div
class="upload-file-item"
v-for="file in fileListTemp"
:key="file.uid"
:class="{ 'file-error': file.error }"
>
<div class="file-info">
<icon-file
:style="{ color: fileColor(getFileExtension(file.name)) }"
class="file-icon"
/>
<div class="file-details">
<div class="file-name">{{ file.name }}</div>
<div class="file-meta">
{{ formatFileSize(file.size) }}
<span v-if="file.error" class="error-text">{{ file.error }}</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="file-progress" v-if="file.status === 'uploading'">
<a-progress
:percent="file.percent || 0"
size="small"
:status="file.percent === 100 ? 'success' : 'processing'"
/>
</div>
<!-- 操作按钮 -->
<div class="file-actions">
<a-button
v-if="file.status !== 'uploading'"
type="text"
shape="circle"
size="small"
@click="removeFile(file)"
class="remove-btn"
>
<icon-delete />
</a-button>
<a-button
v-else
type="text"
shape="circle"
size="small"
@click="cancelUpload(file)"
class="cancel-btn"
>
<icon-stop />
</a-button>
</div>
</div>
</div>
</a-form-item>
<!-- 目标文件夹选择 -->
<a-form-item
label="上传至目录"
field="folderId"
:rules="[{ required: true, message: '请选择目标文件夹' }]"
>
<a-select
v-model="uploadForm.folderId"
placeholder="请选择目标文件夹"
allow-clear
>
<a-option value="0">根目录</a-option>
<a-option
v-for="folder in folderList"
:key="folder.id"
:value="folder.id"
>
{{ folder.name }}
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconUpload, IconFile, IconDelete, IconStop } from '@arco-design/web-vue/es/icon'
import { uploadFileApi } from '@/apis/bussiness'
import axios from 'axios'
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false
},
folderList: {
type: Array,
default: () => []
},
currentFolderId: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits([
'update:visible',
'upload-success'
])
//
const uploadForm = reactive({
folderId: ''
})
const fileListTemp = ref([])
const uploadFormRef = ref(null)
const uploadRef = ref(null)
const uploading = ref(false)
const cancelTokens = ref({})
//
const allowedFileTypes = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.txt,.jpg,.jpeg,.png,.gif,.bmp,.webp'
const allowedFileTypesText = 'PDF, Word, Excel, PPT, 压缩文件, 文本文件, 图片文件'
const maxFileSize = 1000 * 1024 * 1024 // 1000MB
const maxFileSizeText = '1000MB'
//
const hasFiles = computed(() => {
const validFiles = fileListTemp.value.filter(file => {
return !file.error && file.status !== 'removed' && file.status !== 'canceled'
})
return validFiles.length > 0
})
const canUpload = computed(() => {
return hasFiles.value && !uploading.value && uploadForm.folderId
})
// visible
watch(() => props.visible, (visible) => {
if (visible) {
//
uploadForm.folderId = props.currentFolderId || ''
fileListTemp.value = []
//
if (uploadRef.value) {
try {
uploadRef.value.reset()
} catch (error) {
console.log('重置上传组件时出错:', error)
}
}
}
})
//
const handleFileChange = (info) => {
if (!info || !Array.isArray(info) || !props.visible) {
return
}
const fileList = info
if (fileList.length === 0) {
return
}
// UID
const existingUids = fileListTemp.value.map(f => f.uid)
//
fileList.forEach((file) => {
//
if (existingUids.includes(file.uid)) {
return
}
//
const fileObj = {
uid: file.uid,
name: file.name,
size: file.size || file.file?.size || 0,
type: file.type || file.file?.type || '',
status: 'ready',
error: '',
originFileObj: file.file || file
}
//
const isValid = validateFile(fileObj)
if (isValid) {
fileListTemp.value.push(fileObj)
}
})
}
//
const validateFile = (file) => {
file.error = ''
//
const ext = getFileExtension(file.name).toLowerCase()
const allowedExts = allowedFileTypes
.split(',')
.map(type => type.toLowerCase().replace(/^\./, ''))
if (!allowedExts.includes(ext)) {
file.error = `不支持的文件类型,支持: ${allowedFileTypesText}`
return false
}
//
if (file.size > maxFileSize) {
file.error = `文件过大,最大支持 ${maxFileSizeText}`
return false
}
return true
}
//
const getFileExtension = (fileName) => {
const lastDotIndex = fileName.lastIndexOf('.')
return lastDotIndex > 0 ? fileName.slice(lastDotIndex + 1) : ''
}
//
const fileColor = (extension) => {
const colorMap = {
pdf: '#ff4d4f',
doc: '#1890ff',
docx: '#1890ff',
xls: '#52c41a',
xlsx: '#52c41a',
ppt: '#faad14',
pptx: '#faad14',
zip: '#722ed1',
txt: '#8c8c8c',
jpg: '#52c41a',
jpeg: '#52c41a',
png: '#1890ff',
gif: '#faad14',
bmp: '#722ed1',
webp: '#13c2c2'
}
return colorMap[extension.toLowerCase()] || 'var(--color-text-3)'
}
//
const formatFileSize = (fileSize) => {
if (fileSize === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = fileSize
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
//
const removeFile = (file) => {
fileListTemp.value = fileListTemp.value.filter(f => f.uid !== file.uid)
//
if (file.status === 'uploading' && cancelTokens.value[file.uid]) {
cancelTokens.value[file.uid].cancel('上传已取消')
delete cancelTokens.value[file.uid]
}
}
//
const cancelUpload = (file) => {
if (cancelTokens.value[file.uid]) {
cancelTokens.value[file.uid].cancel('上传已取消')
file.status = 'canceled'
}
}
//
const handleSubmit = async () => {
//
const validFiles = fileListTemp.value.filter(file =>
!file.error && file.status !== 'removed' && file.status !== 'canceled'
)
if (validFiles.length === 0) {
Message.warning('请选择有效的文件')
return
}
// ID
if (!uploadForm.folderId) {
Message.warning('请选择目标文件夹')
return
}
uploading.value = true
let hasError = false
let hasFileExists = false
for (const fileItem of validFiles) {
// File
const realFile = fileItem.originFileObj || fileItem
if (!realFile) {
hasError = true
continue
}
fileItem.status = 'uploading'
fileItem.percent = 0
//
const source = axios.CancelToken.source()
cancelTokens.value[fileItem.uid] = source
// API
const result = await uploadFileApi(
realFile,
Number(uploadForm.folderId),
(progressEvent) => {
if (progressEvent.lengthComputable) {
fileItem.percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
}
},
source.token
)
//
if (result.code === 200) {
fileItem.status = 'success'
fileItem.percent = 100
} else if (result.code === 400 && result.msg && result.msg.includes('已存在')) {
//
fileItem.status = 'error'
fileItem.error = '文件已存在'
hasFileExists = true
} else {
fileItem.status = 'error'
fileItem.error = result.msg || '上传失败'
hasError = true
}
}
//
if (hasFileExists && !hasError) {
Message.warning('文件已存在')
} else if (hasError) {
Message.error('上传失败')
} else {
Message.success('上传成功')
emit('upload-success')
}
handleCancel()
}
//
const handleCancel = () => {
//
Object.values(cancelTokens.value).forEach(source => {
source.cancel('上传已取消')
})
//
emit('update:visible', false)
uploadForm.folderId = props.currentFolderId || ''
fileListTemp.value = []
cancelTokens.value = {}
uploading.value = false
//
if (uploadRef.value) {
uploadRef.value.reset()
}
}
</script>
<style scoped>
/* 上传文件相关样式 */
.upload-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.upload-btn {
align-self: flex-start;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
}
.upload-hint {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
padding: 8px 12px;
background: var(--color-fill-2);
border-radius: 6px;
border-left: 3px solid var(--color-primary-light-3);
}
.upload-file-list {
margin-top: 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-1);
max-height: 300px;
overflow-y: auto;
}
.upload-file-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
transition: all 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--color-fill-1);
}
&.file-error {
background: rgba(255, 77, 79, 0.05);
border-left: 3px solid #ff4d4f;
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
gap: 12px;
min-width: 0;
}
.file-icon {
font-size: 20px;
flex-shrink: 0;
}
.file-details {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--color-text-3);
display: flex;
align-items: center;
gap: 8px;
}
.error-text {
color: #ff4d4f;
font-weight: 500;
}
.file-progress {
margin: 0 16px;
min-width: 120px;
}
.file-actions {
display: flex;
gap: 4px;
}
.remove-btn {
color: var(--color-text-3);
&:hover {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.1);
}
}
.cancel-btn {
color: var(--color-text-3);
&:hover {
color: #faad14;
background: rgba(250, 173, 20, 0.1);
}
}
</style>

View File

@ -73,18 +73,8 @@
</div>
</div>
</a-tab-pane>
<a-tab-pane key="props" tap="形变" title="形变原数据">
<div class="tab-content">
<raw-data>
</raw-data>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 文件预览模态框待重新设计 -->
<!-- <PreviewModal ref="previewModal" /> -->
</GiPageLayout>
</template>
@ -107,8 +97,6 @@ import {
} from '@/apis/industrial-image'
import DeformationTap from './components/DeformationTap.vue'
//
// const previewModal = ref()
//
const activeTab = ref('image')

View File

@ -7,9 +7,9 @@
<template #icon><icon-arrow-left /></template>
</a-button>
<h2 class="ml-2">{{ projectTitle }}</h2>
<a-tag class="ml-2" :color="getStatusColor(projectData.status)" v-if="projectData.status">{{
projectData.status
}}</a-tag>
<a-tag class="ml-2" :color="getStatusColor(projectData.statusLabel ?? projectData.status)" v-if="projectData.status !== undefined && projectData.status !== null">
{{ projectData.statusLabel ?? projectData.status }}
</a-tag>
</div>
<div class="flex items-center">
<a-button v-permission="['project:update']" type="primary" class="mr-2" @click="editProject">
@ -198,8 +198,8 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import { getProject, deleteProject } from '@/apis/project'
import { addTask, addTaskGroup, listTask, updateTaskProgress } from '@/apis/project/task'
import { getProjectDetail, deleteProject } from '@/apis/project'
import { addTask, addTaskGroup, updateTaskProgress } from '@/apis/project/task'
import dayjs from 'dayjs'
defineOptions({ name: 'ProjectDetail' })
@ -257,10 +257,15 @@ const projectTitle = computed(() => {
const projectInfos = computed(() => [
{ label: '项目编号', value: projectData.value?.projectCode },
{ label: '项目负责人', value: projectData.value?.projectManager },
{ label: '参与人', value: projectData.value?.projectStaff?.join(', ') },
{ label: '项目周期', value: projectData.value?.projectPeriod ? `${projectData.value.projectPeriod[0]}${projectData.value.projectPeriod[1]}` : '' },
{ label: '客户', value: projectData.value?.commissionUnit },
{ label: '项目负责人', value: projectData.value?.projectManagerName || projectData.value?.projectManager },
{ label: '项目来源', value: projectData.value?.projectOrigin },
{ label: '风场名称', value: projectData.value?.farmName },
{ label: '风场地址', value: projectData.value?.farmAddress },
{ label: '开始时间', value: projectData.value?.startDate },
{ label: '结束时间', value: projectData.value?.endDate },
{ label: '项目规模', value: projectData.value?.scale },
{ label: '状态', value: (statusMap as any)[Number(projectData.value?.status)]?.label || projectData.value?.statusLabel },
{ label: '客户', value: projectData.value?.client },
{ label: '备注', value: projectData.value?.projectIntro || '无' }
])
@ -284,6 +289,15 @@ const taskDetailInfos = computed(() => {
{ label: '状态', value: currentTask.value.status },
{ label: '描述', value: currentTask.value.description || '无' }
]
const statusMap: Record<number, { label: string; color: string }> = {
0: { label: '待施工', color: 'gray' },
1: { label: '施工中', color: 'blue' },
2: { label: '已完工', color: 'green' },
3: { label: '已审核', color: 'orange' },
4: { label: '已验收', color: 'arcoblue' },
}
})
const getStatusColor = (status: string) => {
@ -322,8 +336,14 @@ const formatDate = (date: string) => {
const fetchProjectData = async () => {
loading.value = true
try {
const res = await getProject(projectId.value)
projectData.value = res.data
const res = await getProjectDetail(projectId.value)
const detail = (res as any).data || res
// statusstatusLabel
if (typeof detail.status === 'number' && !detail.statusLabel) {
const mapper = (statusMap as any)[detail.status]
if (mapper) detail.statusLabel = mapper.label
}
projectData.value = detail
} catch (error) {
console.error(error)
Message.error('获取项目详情失败')
@ -332,34 +352,39 @@ const fetchProjectData = async () => {
}
}
const fetchTaskData = async () => {
try {
const res = await listTask({
projectId: projectId.value,
page: 1,
size: 100
})
//
const inferTaskStatus = (task: any): string => {
if (task.status) return task.status
const now = dayjs()
const start = task.planStartDate ? dayjs(task.planStartDate) : null
const end = task.planEndDate ? dayjs(task.planEndDate) : null
if (end && end.isBefore(now)) return '已完成'
if (start && start.isAfter(now)) return '计划中'
if (start && (!end || end.isAfter(now))) return '正在做'
return '其他'
}
//
taskColumns.value.forEach(column => {
column.tasks = []
})
const tasks = res.data?.list || []
const fetchTaskData = () => {
// 使
const detail = projectData.value || {}
const tasks = (detail.tasks || []) as any[]
//
tasks.forEach((task: any) => {
const column = taskColumns.value.find(col => col.status === task.status)
if (column) {
column.tasks.push(task)
} else {
taskColumns.value.find(col => col.status === '其他')?.tasks.push(task)
}
})
} catch (error) {
console.error(error)
Message.error('获取任务数据失败')
}
//
taskColumns.value.forEach(column => {
column.tasks = []
})
//
tasks.forEach((task: any) => {
const st = inferTaskStatus(task)
const column = taskColumns.value.find(col => col.status === st)
if (column) {
column.tasks.push(task)
} else {
taskColumns.value.find(col => col.status === '其他')?.tasks.push(task)
}
})
}
const goBack = () => {
@ -491,8 +516,7 @@ const submitProgressUpdate = async () => {
}
onMounted(() => {
fetchProjectData()
fetchTaskData()
fetchProjectData().then(() => fetchTaskData())
})
</script>

View File

@ -12,9 +12,11 @@
-->
<template>
<GiPageLayout>
<GiTable row-key="id" :data="dataList" :columns="tableColumns" :loading="loading"
<GiTable
row-key="id" :data="dataList" :columns="tableColumns" :loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1500 }" :pagination="pagination" :disabled-tools="['size']"
@page-change="onPageChange" @page-size-change="onPageSizeChange" @refresh="search">
@page-change="onPageChange" @page-size-change="onPageSizeChange" @refresh="search"
>
<template #top>
<GiForm v-model="searchForm" search :columns="queryFormColumns" size="medium" @search="search" @reset="reset">
</GiForm>
@ -71,10 +73,14 @@
</GiTable>
<!-- 新增/编辑项目弹窗 -->
<a-modal v-model:visible="addModalVisible" :title="modalTitle" @cancel="resetForm"
:ok-button-props="{ loading: submitLoading }" @ok="handleSubmit" width="800px" modal-class="project-form-modal">
<a-form ref="formRef" :model="form" :rules="formRules" layout="vertical"
:style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }">
<a-modal
v-model:visible="addModalVisible" :title="modalTitle" :ok-button-props="{ loading: submitLoading }"
width="800px" modal-class="project-form-modal" @cancel="resetForm" @ok="handleSubmit"
>
<a-form
ref="formRef" :model="form" :rules="formRules" layout="vertical"
:style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }"
>
<!-- 基本信息 -->
<a-divider orientation="left">基本信息</a-divider>
<a-row :gutter="16">
@ -88,10 +94,12 @@
<a-input v-model="form.farmAddress" placeholder="请输入地址" />
</a-form-item>
</a-col>
<a-col><a-button size="mini" @click="() => { Message.info(`待开发`) }">
<a-col>
<a-button size="mini" @click="() => { Message.info(`待开发`) }">
<template #icon><icon-location /></template>
地图选点
</a-button></a-col>
</a-button>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
@ -108,7 +116,7 @@
<a-col :span="12">
<a-form-item field="inspectionUnit" label="业主">
<a-input v-model="form.inspectionUnit" placeholder="请输入业主单位" @input="(val) => (form.farmName = val)" />
<!--风场名称同步业主 -->
<!-- 风场名称同步业主 -->
</a-form-item>
</a-col>
<a-col :span="12">
@ -145,10 +153,133 @@
</a-col>
</a-row>
<a-row :gutter="16">
<a-form-item field="projectContent" label="项目内容">
<a-textarea v-model="form.coverUrl" placeholder="请输入项目内容" :rows="4" />
</a-form-item>
<a-col :span="12">
<a-form-item field="projectOrigin" label="项目来源" :rules="[{ required: true, message: '请输入项目来源' }]">
<a-input v-model="form.projectOrigin" placeholder="请输入项目来源" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">任务设置</a-divider>
<div class="mb-2">
<a-button type="dashed" size="small" @click="addTask">
<template #icon><icon-plus /></template>
新增任务
</a-button>
</div>
<div v-if="form.tasks.length === 0" class="text-gray-500 mb-2">暂无任务请点击新增任务</div>
<a-space direction="vertical" fill>
<a-card v-for="(task, tIndex) in form.tasks" :key="tIndex" size="small">
<template #title>
<div class="flex items-center justify-between">
<span>任务 {{ tIndex + 1 }}</span>
<a-space>
<a-button size="mini" @click="addSubtask(tIndex)">新增子任务</a-button>
<a-button size="mini" status="danger" @click="removeTask(tIndex)">删除</a-button>
</a-space>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskName`" label="任务名称" required>
<a-input v-model="task.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.taskCode`" label="任务编号">
<a-input v-model="task.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.mainUserId`" label="负责人">
<a-select v-model="task.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.scales`" label="工量">
<a-input-number v-model="task.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="task.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="task.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="task.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- 子任务 -->
<div v-if="task.children && task.children.length">
<a-divider orientation="left">子任务</a-divider>
<a-card
v-for="(sub, sIndex) in task.children"
:key="sIndex"
size="small"
class="mb-2"
>
<template #title>
<div class="flex items-center justify-between">
<span>子任务 {{ tIndex + 1 }}-{{ sIndex + 1 }}</span>
<a-button size="mini" status="danger" @click="removeSubtask(tIndex, sIndex)">删除</a-button>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskName`" label="任务名称" required>
<a-input v-model="sub.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskCode`" label="任务编号">
<a-input v-model="sub.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.mainUserId`" label="负责人">
<a-select v-model="sub.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.scales`" label="工量">
<a-input-number v-model="sub.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="sub.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="sub.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="sub.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
</a-card>
</a-space>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="status" label="项目状态">
@ -198,7 +329,6 @@
</a-col>
</a-row>
<a-divider orientation="middle">地图</a-divider>
</a-form>
</a-modal>
@ -221,53 +351,53 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue'
import { addProject, deleteProject, listProject, updateProject, exportProject, importProject } from '@/apis/project'
import type { TableColumnData } from '@arco-design/web-vue'
import TurbineGrid from './TurbineGrid.vue'
import { addProject, deleteProject, exportProject, importProject, listProject, updateProject, getProjectDetail } from '@/apis/project'
import { isMobile } from '@/utils'
import has from '@/utils/has'
import http from '@/utils/http'
import type { ColumnItem } from '@/components/GiForm'
import type { TableColumnData } from '@arco-design/web-vue'
import type { ProjectResp, ProjectPageQuery } from '@/apis/project/type'
import * as T from '@/apis/project/type'
import TurbineGrid from './TurbineGrid.vue'
import type { ProjectPageQuery } from '@/apis/project/type'
import type * as T from '@/apis/project/type'
defineOptions({ name: 'ProjectManagement' })
// (API)
const PROJECT_STATUS = {
NOT_STARTED: 0, // /
IN_PROGRESS: 1, //
COMPLETED: 2, //
NOT_STARTED: 0, // /
IN_PROGRESS: 1, //
COMPLETED: 2, //
} as const
//
const PROJECT_STATUS_MAP = {
0: '待施工',
1: '施工中',
2: '已完成'
2: '已完成',
} as const
//
const PROJECT_STATUS_OPTIONS = [
{ label: '待施工', value: 0 },
{ label: '施工中', value: 1 },
{ label: '已完成', value: 2 }
{ label: '已完成', value: 2 },
]
//
const PROJECT_CATEGORY = {
EXTERNAL_WORK: '外部工作',
INTERNAL_PROJECT: '内部项目',
TECHNICAL_SERVICE: '技术服务'
TECHNICAL_SERVICE: '技术服务',
} as const
//
const PROJECT_CATEGORY_OPTIONS = [
{ label: PROJECT_CATEGORY.EXTERNAL_WORK, value: PROJECT_CATEGORY.EXTERNAL_WORK },
{ label: PROJECT_CATEGORY.INTERNAL_PROJECT, value: PROJECT_CATEGORY.INTERNAL_PROJECT },
{ label: PROJECT_CATEGORY.TECHNICAL_SERVICE, value: PROJECT_CATEGORY.TECHNICAL_SERVICE }
{ label: PROJECT_CATEGORY.TECHNICAL_SERVICE, value: PROJECT_CATEGORY.TECHNICAL_SERVICE },
]
const router = useRouter()
@ -280,12 +410,12 @@ const currentId = ref<string | null>(null)
const fileList = ref([])
const dataList = ref<T.ProjectResp[]>([])
const userLoading = ref(false)
const userOptions = ref<{ label: string; value: string }[]>([])
const userOptions = ref<{ label: string, value: string }[]>([])
let searchForm = reactive<Partial<ProjectPageQuery>>({
const searchForm = reactive<Partial<ProjectPageQuery>>({
projectName: '',
status: undefined,
fieldName: '', // 使fieldNameAPI使
fieldName: '', // 使fieldNameAPI使
})
const queryFormColumns: ColumnItem[] = reactive([
@ -320,28 +450,48 @@ const queryFormColumns: ColumnItem[] = reactive([
])
const form = reactive({
projectId: '', // id
projectName: '', //
projectManagerId: '', // id
client: '', //
clientContact: '', //
clientPhone: '', //
inspectionUnit: '', //
inspectionContact: '', //
inspectionPhone: '', //
farmName: '', //
farmAddress: '', //
scale: '', //
turbineModel: '', //
status: '', // 01234
startDate: '', //
endDate: '', //
coverUrl: '', //
projectId: '', // id
projectName: '', //
projectManagerId: '', // id
client: '', //
clientContact: '', //
clientPhone: '', //
inspectionUnit: '', //
inspectionContact: '', //
inspectionPhone: '', //
farmName: '', //
farmAddress: '', //
projectOrigin: '', //
scale: '', //
turbineModel: '', //
status: '', // 01234
startDate: '', //
endDate: '', //
// coverUrl: '', // 使
constructionTeamLeaderId: '', // id
constructorIds: '', // id
qualityOfficerId: '', // id
auditorId: '', // id
turbineList: [] as { id: number; turbineNo: string; lat?: number; lng?: number; status: 0 | 1 | 2 }[], //
constructorIds: '', // id
qualityOfficerId: '', // id
auditorId: '', // id
//
tasks: [] as Array<{
taskName: string
taskCode?: string
mainUserId?: string | number
planStartDate?: string
planEndDate?: string
scales?: number
taskGroupId?: number | string
children?: Array<{
taskName: string
taskCode?: string
mainUserId?: string | number
planStartDate?: string
planEndDate?: string
scales?: number
taskGroupId?: number | string
}>
}>,
turbineList: [] as { id: number, turbineNo: string, lat?: number, lng?: number, status: 0 | 1 | 2 }[], //
})
const pagination = reactive({
@ -350,7 +500,7 @@ const pagination = reactive({
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true
showPageSize: true,
})
const openMapModal = (item: any) => {
Message.info(`地图选点功能待开发,当前机组编号:${item.turbineNo}`)
@ -384,70 +534,70 @@ const tableColumns = ref<TableColumnData[]>([
slotName: 'fieldInfo',
minWidth: 180,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
align: 'center',
width: 100
width: 100,
},
{
title: '委托单位',
dataIndex: 'commissionUnit',
minWidth: 140,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '委托单位联系人/电话',
slotName: 'commissionInfo',
minWidth: 160,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '业主',
dataIndex: 'inspectionUnit',
minWidth: 140,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '业主联系人/电话',
slotName: 'inspectionInfo',
minWidth: 160,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '项目规模',
dataIndex: 'projectScale',
width: 100,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '机组型号',
dataIndex: 'orgNumber',
width: 100,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '项目经理/施工人员',
slotName: 'projectManager',
minWidth: 160,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '项目周期',
slotName: 'projectPeriod',
minWidth: 180,
ellipsis: true,
tooltip: true
tooltip: true,
},
{
title: '操作',
@ -492,7 +642,7 @@ const fetchData = async () => {
const params: ProjectPageQuery = {
...searchForm,
page: pagination.current,
size: pagination.pageSize
size: pagination.pageSize,
}
const res = await listProject(params)
@ -516,9 +666,9 @@ const fetchData = async () => {
projectManager: item.projectManagerName,
projectScale: item.scale,
//
projectPeriod: item.startDate && item.endDate ? [item.startDate, item.endDate] : []
};
return mappedItem;
projectPeriod: item.startDate && item.endDate ? [item.startDate, item.endDate] : [],
}
return mappedItem
})
// APItotal使
@ -553,7 +703,7 @@ const reset = () => {
//
Object.assign(searchForm, {
projectName: '',
fieldName: '', // 使fieldNameAPI使
fieldName: '', // 使fieldNameAPI使
status: undefined,
})
@ -576,27 +726,29 @@ const onPageSizeChange = (pageSize: number) => {
const resetForm = () => {
//
Object.assign(form, {
projectId: '', // id
projectName: '', //
projectManagerId: '', // id
client: '', //
clientContact: '', //
clientPhone: '', //
inspectionUnit: '', //
inspectionContact: '', //
inspectionPhone: '', //
farmName: '', //
farmAddress: '', //
scale: '', //
turbineModel: '', //
status: 0, // 01234
startDate: '', //
endDate: '', //
coverUrl: '', //
projectId: '', // id
projectName: '', //
projectManagerId: '', // id
client: '', //
clientContact: '', //
clientPhone: '', //
inspectionUnit: '', //
inspectionContact: '', //
inspectionPhone: '', //
farmName: '', //
farmAddress: '', //
projectOrigin: '', //
scale: '', //
turbineModel: '', //
status: 0, // 01234
startDate: '', //
endDate: '', //
// coverUrl: '', //
constructionTeamLeaderId: '', // id
constructorIds: '', // id
qualityOfficerId: '', // id
auditorId: '' // id
constructorIds: '', // id
qualityOfficerId: '', // id
auditorId: '', // id
tasks: [],
})
isEdit.value = false
@ -607,21 +759,49 @@ const openAddModal = () => {
resetForm()
addModalVisible.value = true
}
// /使
const addTask = () => {
;(form.tasks as any[]).push({ taskName: '', taskCode: '', mainUserId: undefined, planStartDate: '', planEndDate: '', scales: undefined, taskGroupId: undefined, children: [] })
}
const removeTask = (index: number) => {
;(form.tasks as any[]).splice(index, 1)
}
const addSubtask = (parentIndex: number) => {
const list = (form.tasks as any[])
if (!list[parentIndex].children) list[parentIndex].children = []
list[parentIndex].children!.push({ taskName: '', taskCode: '', mainUserId: undefined, planStartDate: '', planEndDate: '', scales: undefined, taskGroupId: undefined })
}
const removeSubtask = (parentIndex: number, index: number) => {
const list = (form.tasks as any[])
list[parentIndex].children!.splice(index, 1)
}
const openEditModal = (record: T.ProjectResp) => {
isEdit.value = true
currentId.value = record.id || record.projectId || null
//
Object.keys(form).forEach(key => {
// @ts-ignore
form[key] = ''
})
//
//
// { code, data, msg } res.data
// await 使 async async
// try {
// const res = await getProjectDetail(currentId.value as any)
// const detail = (res as any).data || res
// Object.assign(form, {
// ...form,
// ...detail,
// })
// } catch (e) { console.error('', e) }
// tasksturbineList
resetForm()
//
Object.keys(form).forEach(key => {
Object.keys(form).forEach((key) => {
if (key in record && record[key as keyof T.ProjectResp] !== undefined) {
// @ts-ignore -
// @ts-expect-error -
form[key] = record[key as keyof T.ProjectResp]
}
})
@ -645,6 +825,7 @@ const openEditModal = (record: T.ProjectResp) => {
//
const formRules = {
projectName: [{ required: true, message: '请输入项目名称' }],
projectOrigin: [{ required: true, message: '请输入项目来源' }],
}
//
@ -660,15 +841,70 @@ const handleSubmit = async () => {
await formRef.value.validate()
//
const normalizeDate = (d: any) => (d ? (typeof d === 'string' ? d : new Date(d).toISOString().split('T')[0]) : '')
//
const mapTasks = (tasks: any[]) =>
(tasks || []).map(t => ({
...t,
planStartDate: normalizeDate(t.planStartDate),
planEndDate: normalizeDate(t.planEndDate),
children: (t.children || []).map((c: any) => ({
...c,
planStartDate: normalizeDate(c.planStartDate),
planEndDate: normalizeDate(c.planEndDate),
}))
}))
const pickTaskFields = (t: any) => ({
mainUserId: t.mainUserId ?? '',
planEndDate: normalizeDate(t.planEndDate),
planStartDate: normalizeDate(t.planStartDate),
scales: t.scales ?? 0,
taskCode: t.taskCode ?? '',
taskGroupId: t.taskGroupId ?? '',
taskName: t.taskName ?? '',
})
const flattenTasks = (tasks: any[]) => {
const result: any[] = []
;(tasks || []).forEach((t) => {
result.push(pickTaskFields(t))
;(t.children || []).forEach((c: any) => result.push(pickTaskFields(c)))
})
return result
}
const submitData = {
...form,
// projectId
auditorId: (form as any).auditorId || '',
bonusProvision: (form as any).bonusProvision ?? 0,
client: form.client || '',
clientContact: form.clientContact || '',
clientPhone: form.clientPhone || '',
constructionTeamLeaderId: form.constructionTeamLeaderId || '',
constructorIds: Array.isArray(form.constructorIds) ? form.constructorIds.join(',') : (form.constructorIds || ''),
coverUrl: (form as any).coverUrl || '',
duration: (form as any).duration ?? 0,
endDate: normalizeDate(form.endDate),
equipmentAmortization: (form as any).equipmentAmortization ?? 0,
farmAddress: form.farmAddress || '',
farmName: form.farmName || '',
inspectionContact: form.inspectionContact || '',
inspectionPhone: form.inspectionPhone || '',
inspectionUnit: form.inspectionUnit || '',
laborCost: (form as any).laborCost ?? 0,
othersCost: (form as any).othersCost ?? 0,
projectBudget: (form as any).projectBudget ?? 0,
projectId: isEdit.value && currentId.value ? currentId.value : form.projectId,
// - YYYY-MM-DD
startDate: form.startDate ? (typeof form.startDate === 'string' ? form.startDate : new Date(form.startDate).toISOString().split('T')[0]) : '',
endDate: form.endDate ? (typeof form.endDate === 'string' ? form.endDate : new Date(form.endDate).toISOString().split('T')[0]) : '',
// ID -
constructorIds: Array.isArray(form.constructorIds) ? form.constructorIds.join(',') : form.constructorIds
projectManagerId: form.projectManagerId || '',
projectName: form.projectName,
projectOrigin: (form as any).projectOrigin || '',
qualityOfficerId: form.qualityOfficerId || '',
scale: form.scale || '',
startDate: normalizeDate(form.startDate),
status: (form as any).status ?? 0,
tasks: flattenTasks(form.tasks as any[]),
transAccomMeals: (form as any).transAccomMeals ?? 0,
turbineModel: form.turbineModel || '',
}
console.log('提交数据:', submitData)
@ -753,8 +989,8 @@ const viewDetail = (record: T.ProjectResp) => {
router.push({
name: 'ProjectDetail',
params: {
id: projectId.toString()
}
id: projectId.toString(),
},
})
}
@ -811,7 +1047,7 @@ const exportData = async () => {
const params = {
projectName: searchForm.projectName,
status: searchForm.status,
fieldName: searchForm.fieldName, // 使fieldNameAPI使
fieldName: searchForm.fieldName, // 使fieldNameAPI使
}
await exportProject(params)
@ -829,9 +1065,9 @@ const fetchUserList = async () => {
//
const res = await http.get('/user/list')
if (res.data && Array.isArray(res.data)) {
userOptions.value = res.data.map(item => ({
userOptions.value = res.data.map((item) => ({
label: item.userName || item.username || item.name || item.nickName || item.account || '未命名用户',
value: item.userId || item.id || ''
value: item.userId || item.id || '',
}))
} else {
userOptions.value = []

View File

@ -0,0 +1,505 @@
<template>
<a-modal
:visible="visible"
title="设备付款"
width="900px"
:confirm-loading="loading"
@cancel="handleCancel"
@ok="handleSubmit"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
layout="vertical"
>
<!-- 基本信息 -->
<a-card title="设备信息" class="detail-card" :bordered="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="设备名称">
{{ equipmentData?.equipmentName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
{{ equipmentData?.equipmentType || '-' }}
</a-descriptions-item>
<a-descriptions-item label="设备型号">
{{ equipmentData?.equipmentModel || '-' }}
</a-descriptions-item>
<a-col :span="12">
<a-form-item label="品牌">
{{ equipmentData?.brand || '-' }}
</a-form-item>
</a-col>
<a-descriptions-item label="供应商">
{{ equipmentData?.supplierName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="采购订单">
{{ equipmentData?.purchaseOrder || '-' }}
</a-descriptions-item>
<a-descriptions-item label="采购价格">
¥{{ equipmentData?.purchasePrice || '-' }}
</a-descriptions-item>
<a-descriptions-item label="采购数量">
{{ equipmentData?.quantity || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- 支付信息 -->
<a-card title="支付信息" class="detail-card" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="支付方式" field="paymentMethod" required>
<a-select
v-model="formData.paymentMethod"
placeholder="请选择支付方式"
style="width: 100%"
>
<a-option value="银行转账">银行转账</a-option>
<a-option value="现金">现金</a-option>
<a-option value="支票">支票</a-option>
<a-option value="信用卡">信用卡</a-option>
<a-option value="其他">其他</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="支付金额" field="paymentAmount" required>
<a-input-number
v-model="formData.paymentAmount"
placeholder="请输入支付金额"
:min="0.01"
:max="999999.99"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="支付时间" field="paymentTime" required>
<a-date-picker
v-model="formData.paymentTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择支付时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="支付人" field="paymentPerson" required>
<a-input
v-model="formData.paymentPerson"
placeholder="请输入支付人姓名"
show-word-limit
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="支付备注" field="paymentRemark">
<a-textarea
v-model="formData.paymentRemark"
placeholder="请输入支付备注"
:rows="3"
show-word-limit
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
<!-- 发票信息 -->
<a-card title="发票信息" class="detail-card" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="发票类型" field="invoiceType" required>
<a-select
v-model="formData.invoiceType"
placeholder="请选择发票类型"
style="width: 100%"
>
<a-option value="增值税专用发票">增值税专用发票</a-option>
<a-option value="增值税普通发票">增值税普通发票</a-option>
<a-option value="电子发票">电子发票</a-option>
<a-option value="其他">其他</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="发票号码" field="invoiceNumber" required>
<a-input
v-model="formData.invoiceNumber"
placeholder="请输入发票号码"
show-word-limit
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开票日期" field="invoiceDate" required>
<a-date-picker
v-model="formData.invoiceDate"
format="YYYY-MM-DD"
placeholder="请选择开票日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="发票金额" field="invoiceAmount" required>
<a-input-number
v-model="formData.invoiceAmount"
placeholder="请输入发票金额"
:min="0.01"
:max="999999.99"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="税率(%)" field="taxRate" required>
<a-input-number
v-model="formData.taxRate"
placeholder="请输入税率"
:min="0"
:max="100"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="税额" field="taxAmount" required>
<a-input-number
v-model="formData.taxAmount"
placeholder="请输入税额"
:min="0"
:max="999999.99"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="不含税金额" field="amountWithoutTax" required>
<a-input-number
v-model="formData.amountWithoutTax"
placeholder="请输入不含税金额"
:min="0.01"
:max="999999.99"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
<!-- 合同信息 -->
<a-card title="合同信息" class="detail-card" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="合同编号" field="contractNumber" required>
<a-input
v-model="formData.contractNumber"
placeholder="请输入合同编号"
show-word-limit
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="合同金额" field="contractAmount" required>
<a-input-number
v-model="formData.contractAmount"
placeholder="请输入合同金额"
:min="0.01"
:max="999999.99"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="签订日期" field="contractDate" required>
<a-date-picker
v-model="formData.contractDate"
format="YYYY-MM-DD"
placeholder="请选择签订日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="付款条件" field="paymentTerms" required>
<a-input
v-model="formData.paymentTerms"
placeholder="请输入付款条件"
show-word-limit
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="付款期限" field="paymentDeadline" required>
<a-date-picker
v-model="formData.paymentDeadline"
format="YYYY-MM-DD"
placeholder="请选择付款期限"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import type { EquipmentResp, PaymentRequest } from '@/apis/equipment/type'
import { equipmentProcurementApi } from '@/apis/equipment/procurement'
interface Props {
visible: boolean
equipmentData?: EquipmentResp | null
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
equipmentData: null,
})
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
//
const formData = reactive<PaymentRequest>({
paymentMethod: '',
paymentAmount: 0,
paymentTime: '',
paymentPerson: '',
paymentRemark: '',
invoiceType: '',
invoiceNumber: '',
invoiceDate: '',
invoiceAmount: 0,
taxRate: 13,
taxAmount: 0,
amountWithoutTax: 0,
contractNumber: '',
contractAmount: 0,
contractDate: '',
paymentTerms: '',
paymentDeadline: '',
})
//
const calculatedTaxAmount = computed(() => {
if (formData.invoiceAmount && formData.taxRate) {
return (formData.invoiceAmount * formData.taxRate) / 100
}
return 0
})
const calculatedAmountWithoutTax = computed(() => {
if (formData.invoiceAmount && formData.taxAmount) {
return formData.invoiceAmount - formData.taxAmount
}
return 0
})
//
watch([() => formData.invoiceAmount, () => formData.taxRate], () => {
formData.taxAmount = calculatedTaxAmount.value
formData.amountWithoutTax = calculatedAmountWithoutTax.value
})
//
const rules = {
paymentMethod: [
{ required: true, message: '请选择支付方式' }
],
paymentAmount: [
{ required: true, message: '请输入支付金额' },
{ type: 'number', min: 0.01, message: '支付金额必须大于0' }
],
paymentTime: [
{ required: true, message: '请选择支付时间' }
],
paymentPerson: [
{ required: true, message: '请输入支付人姓名' },
{ min: 2, max: 50, message: '支付人姓名长度应在2-50个字符之间' }
],
invoiceType: [
{ required: true, message: '请选择发票类型' }
],
invoiceNumber: [
{ required: true, message: '请输入发票号码' },
{ min: 2, max: 50, message: '发票号码长度应在2-50个字符之间' }
],
invoiceDate: [
{ required: true, message: '请选择开票日期' }
],
invoiceAmount: [
{ required: true, message: '请输入发票金额' },
{ type: 'number', min: 0.01, message: '发票金额必须大于0' }
],
taxRate: [
{ required: true, message: '请输入税率' },
{ type: 'number', min: 0, max: 100, message: '税率应在0-100之间' }
],
contractNumber: [
{ required: true, message: '请输入合同编号' },
{ min: 2, max: 50, message: '合同编号长度应在2-50个字符之间' }
],
contractAmount: [
{ required: true, message: '请输入合同金额' },
{ type: 'number', min: 0.01, message: '合同金额必须大于0' }
],
contractDate: [
{ required: true, message: '请选择签订日期' }
],
paymentTerms: [
{ required: true, message: '请输入付款条件' },
{ min: 2, max: 100, message: '付款条件长度应在2-100个字符之间' }
],
paymentDeadline: [
{ required: true, message: '请选择付款期限' }
],
}
//
watch(() => props.visible, (visible) => {
if (visible && props.equipmentData) {
//
Object.assign(formData, {
paymentMethod: '',
paymentAmount: props.equipmentData.purchasePrice || 0,
paymentTime: '',
paymentPerson: '',
paymentRemark: '',
invoiceType: '增值税专用发票',
invoiceNumber: '',
invoiceDate: '',
invoiceAmount: props.equipmentData.purchasePrice || 0,
taxRate: 13,
taxAmount: 0,
amountWithoutTax: 0,
contractNumber: props.equipmentData.purchaseOrder || '',
contractAmount: props.equipmentData.purchasePrice || 0,
contractDate: '',
paymentTerms: '货到付款',
paymentDeadline: '',
})
//
formData.taxAmount = calculatedTaxAmount.value
formData.amountWithoutTax = calculatedAmountWithoutTax.value
formRef.value?.clearValidate()
}
})
//
const handleSubmit = async () => {
try {
await formRef.value?.validate()
loading.value = true
if (!props.equipmentData?.equipmentId) {
throw new Error('设备ID不能为空')
}
//
const paymentTime = formData.paymentTime ? new Date(formData.paymentTime).toISOString() : new Date().toISOString()
const invoiceDate = formData.invoiceDate ? new Date(formData.invoiceDate).toISOString() : new Date().toISOString()
const contractDate = formData.contractDate ? new Date(formData.contractDate).toISOString() : new Date().toISOString()
const paymentDeadline = formData.paymentDeadline ? new Date(formData.paymentDeadline).toISOString() : new Date().toISOString()
const requestData: PaymentRequest = {
...formData,
paymentTime,
invoiceDate,
contractDate,
paymentDeadline,
}
await equipmentProcurementApi.makePayment(props.equipmentData.equipmentId, requestData)
Message.success('付款成功')
emit('success')
emit('update:visible', false)
} catch (error: any) {
console.error('付款失败:', error)
Message.error(error?.message || '付款失败,请检查表单信息')
} finally {
loading.value = false
}
}
//
const handleCancel = () => {
emit('update:visible', false)
}
</script>
<style scoped lang="scss">
.detail-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.arco-form-item {
margin-bottom: 16px;
}
.arco-input,
.arco-select,
.arco-input-number,
.arco-date-picker,
.arco-textarea {
border-radius: 6px;
}
.arco-textarea {
resize: vertical;
}
</style>

View File

@ -0,0 +1,509 @@
<template>
<a-modal
:visible="visible"
title="确认收货"
width="800px"
:confirm-loading="loading"
@cancel="handleCancel"
@ok="handleSubmit"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
layout="vertical"
>
<!-- 基本信息 -->
<a-card title="设备信息" class="detail-card" :bordered="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="设备名称">
{{ equipmentData?.equipmentName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
{{ equipmentData?.equipmentType || '-' }}
</a-descriptions-item>
<a-descriptions-item label="设备型号">
{{ equipmentData?.equipmentModel || '-' }}
</a-descriptions-item>
<a-descriptions-item label="品牌">
{{ equipmentData?.brand || '-' }}
</a-descriptions-item>
<a-descriptions-item label="供应商">
{{ equipmentData?.supplierName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="采购订单">
{{ equipmentData?.purchaseOrder || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- 收货信息 -->
<a-card title="收货信息" class="detail-card" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="收货时间" field="receiptTime" required>
<a-date-picker
v-model="formData.receiptTime"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择收货时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="收货人" field="receiptPerson" required>
<a-input
v-model="formData.receiptPerson"
placeholder="请输入收货人姓名"
show-word-limit
:max-length="50"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="收货数量" field="receiptQuantity" required>
<a-input-number
v-model="formData.receiptQuantity"
placeholder="请输入收货数量"
:min="1"
:max="9999"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="入库位置" field="storageLocation" required>
<a-input
v-model="formData.storageLocation"
placeholder="请输入入库位置"
show-word-limit
:max-length="100"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="库管员" field="storageManager" required>
<a-input
v-model="formData.storageManager"
placeholder="请输入库管员姓名"
show-word-limit
:max-length="50"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="收货备注" field="receiptRemark">
<a-input
v-model="formData.receiptRemark"
placeholder="请输入收货备注"
show-word-limit
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
<!-- 质量检查 -->
<a-card title="质量检查" class="detail-card" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="外观检查" field="appearanceCheck" required>
<a-select
v-model="formData.appearanceCheck"
placeholder="请选择外观检查结果"
style="width: 100%"
>
<a-option value="正常">正常</a-option>
<a-option value="轻微划痕">轻微划痕</a-option>
<a-option value="明显划痕">明显划痕</a-option>
<a-option value="变形">变形</a-option>
<a-option value="其他">其他</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="功能测试" field="functionTest" required>
<a-select
v-model="formData.functionTest"
placeholder="请选择功能测试结果"
style="width: 100%"
>
<a-option value="正常">正常</a-option>
<a-option value="部分正常">部分正常</a-option>
<a-option value="异常">异常</a-option>
<a-option value="未测试">未测试</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="包装完整性" field="packageIntegrity" required>
<a-select
v-model="formData.packageIntegrity"
placeholder="请选择包装完整性"
style="width: 100%"
>
<a-option value="完整">完整</a-option>
<a-option value="轻微破损">轻微破损</a-option>
<a-option value="明显破损">明显破损</a-option>
<a-option value="严重破损">严重破损</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="配件完整性" field="accessoryIntegrity" required>
<a-select
v-model="formData.accessoryIntegrity"
placeholder="请选择配件完整性"
style="width: 100%"
>
<a-option value="完整">完整</a-option>
<a-option value="部分缺失">部分缺失</a-option>
<a-option value="严重缺失">严重缺失</a-option>
<a-option value="无配件">无配件</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="检查结果" field="checkResult" required>
<a-select
v-model="formData.checkResult"
placeholder="请选择检查结果"
style="width: 100%"
>
<a-option value="PASS">通过</a-option>
<a-option value="FAIL">不通过</a-option>
<a-option value="CONDITIONAL">有条件通过</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="检查备注" field="checkRemark">
<a-input
v-model="formData.checkRemark"
placeholder="请输入检查备注"
show-word-limit
:max-length="200"
/>
</a-form-item>
</a-col>
</a-row>
</a-card>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import type { EquipmentResp, ReceiptRequest } from '@/apis/equipment/type'
import { equipmentProcurementApi } from '@/apis/equipment/procurement'
interface Props {
visible: boolean
equipmentData?: EquipmentResp | null
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
equipmentData: null,
})
const emit = defineEmits<{
'update:visible': [value: boolean]
'success': []
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
// - 使
const formData = reactive<ReceiptRequest>({
//
receiptTime: '',
receiptPerson: '',
receiptQuantity: 1,
receiptRemark: '',
appearanceCheck: '',
functionTest: '',
packageIntegrity: '',
accessoryIntegrity: '',
checkResult: 'PASS',
checkRemark: '',
storageLocation: '',
storageManager: '',
//
equipmentName: '',
equipmentModel: '',
equipmentType: '',
equipmentSn: '',
brand: '',
specification: '',
assetCode: '',
//
purchaseOrder: '',
supplierName: '',
purchasePrice: 0,
purchaseTime: '',
quantity: 1,
unitPrice: 0,
totalPrice: 0,
//
inStockTime: '',
physicalLocation: '',
locationStatus: '',
responsiblePerson: '',
inventoryBarcode: '',
//
equipmentStatus: '',
useStatus: '',
healthStatus: '',
receiptStatus: '',
//
depreciationMethod: '',
depreciationYears: 5,
salvageValue: 0,
currentNetValue: 0,
//
createTime: '',
updateTime: ''
})
//
const rules = {
receiptTime: [
{ required: true, message: '请选择收货时间' }
],
receiptPerson: [
{ required: true, message: '请输入收货人姓名' },
{ min: 2, max: 50, message: '收货人姓名长度应在2-50个字符之间' }
],
receiptQuantity: [
{ required: true, message: '请输入收货数量' },
{ type: 'number', min: 1, max: 9999, message: '收货数量应在1-9999之间' }
],
storageLocation: [
{ required: true, message: '请输入入库位置' },
{ min: 2, max: 100, message: '入库位置长度应在2-100个字符之间' }
],
storageManager: [
{ required: true, message: '请输入库管员姓名' },
{ min: 2, max: 50, message: '库管员姓名长度应在2-50个字符之间' }
],
appearanceCheck: [
{ required: true, message: '请选择外观检查结果' }
],
functionTest: [
{ required: true, message: '请选择功能测试结果' }
],
packageIntegrity: [
{ required: true, message: '请选择包装完整性' }
],
accessoryIntegrity: [
{ required: true, message: '请选择配件完整性' }
],
checkResult: [
{ required: true, message: '请选择检查结果' }
],
}
//
const initFormData = () => {
if (props.equipmentData) {
//
Object.keys(formData).forEach((key) => {
const formKey = key as keyof ReceiptRequest
const equipmentKey = key as keyof EquipmentResp
if (formKey in formData && equipmentKey in props.equipmentData!) {
const value = props.equipmentData![equipmentKey]
if (value !== undefined) {
(formData[formKey] as any) = value
}
}
})
//
formData.receiptQuantity = props.equipmentData.quantity || 1
formData.storageLocation = props.equipmentData.physicalLocation || ''
formData.storageManager = props.equipmentData.responsiblePerson || ''
//
formData.receiptTime = formatDateTime(new Date())
}
}
//
watch(() => props.visible, (visible) => {
if (visible) {
initFormData()
formRef.value?.clearValidate()
}
})
//
watch(() => props.equipmentData, () => {
if (props.visible && props.equipmentData) {
initFormData()
}
}, { deep: true })
//
const handleSubmit = async () => {
try {
await formRef.value?.validate()
loading.value = true
if (!props.equipmentData?.equipmentId) {
throw new Error('设备ID不能为空')
}
console.log('📦 开始提交收货数据...')
console.log('📦 设备数据:', props.equipmentData)
console.log('📦 表单数据:', formData)
//
const receiptData: ReceiptRequest = {
//
receiptTime: formData.receiptTime ? formatDateTime(formData.receiptTime) : formatDateTime(new Date()),
receiptPerson: formData.receiptPerson,
receiptQuantity: formData.receiptQuantity,
receiptRemark: formData.receiptRemark,
appearanceCheck: formData.appearanceCheck,
functionTest: formData.functionTest,
packageIntegrity: formData.packageIntegrity,
accessoryIntegrity: formData.accessoryIntegrity,
checkResult: formData.checkResult,
checkRemark: formData.checkRemark,
storageLocation: formData.storageLocation,
storageManager: formData.storageManager,
//
equipmentName: props.equipmentData.equipmentName,
equipmentModel: props.equipmentData.equipmentModel,
equipmentType: props.equipmentData.equipmentType,
equipmentSn: props.equipmentData.equipmentSn,
brand: props.equipmentData.brand,
specification: props.equipmentData.specification,
assetCode: props.equipmentData.assetCode,
//
purchaseOrder: props.equipmentData.purchaseOrder,
supplierName: props.equipmentData.supplierName,
purchasePrice: props.equipmentData.purchasePrice,
purchaseTime: props.equipmentData.purchaseTime,
quantity: props.equipmentData.quantity,
unitPrice: props.equipmentData.unitPrice,
totalPrice: props.equipmentData.totalPrice,
//
inStockTime: formData.receiptTime ? formatDateTime(formData.receiptTime) : formatDateTime(new Date()),
physicalLocation: formData.storageLocation,
locationStatus: 'in_stock',
responsiblePerson: formData.storageManager,
inventoryBarcode: props.equipmentData.inventoryBarcode || generateInventoryBarcode(),
//
equipmentStatus: 'normal',
useStatus: '0',
healthStatus: 'good',
receiptStatus: 'RECEIVED',
//
depreciationMethod: props.equipmentData.depreciationMethod || 'straight_line',
depreciationYears: props.equipmentData.depreciationYears || 5,
salvageValue: props.equipmentData.salvageValue || 0,
currentNetValue: props.equipmentData.purchasePrice || 0,
//
createTime: formatDateTime(new Date()),
updateTime: formatDateTime(new Date())
}
console.log('📦 构建的收货数据:', receiptData)
// API
await equipmentProcurementApi.receiveGoods(
props.equipmentData.equipmentId,
receiptData
)
Message.success('收货成功,设备已自动入库')
emit('success')
emit('update:visible', false)
} catch (error: any) {
console.error('收货失败:', error)
Message.error(error?.message || '收货失败,请检查表单信息')
} finally {
loading.value = false
}
}
//
const generateInventoryBarcode = () => {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substr(2, 5)
return `INV-${timestamp}-${random}`.toUpperCase()
}
//
const formatDateTime = (date: string | Date) => {
const d = new Date(date);
let month = '' + (d.getMonth() + 1);
let day = '' + d.getDate();
const year = d.getFullYear();
if (month.length < 2)
month = '0' + month;
if (day.length < 2)
day = '0' + day;
return [year, month, day].join('-') + ' ' + [d.getHours(), d.getMinutes(), d.getSeconds()].join(':');
}
//
const handleCancel = () => {
emit('update:visible', false)
}
</script>
<style scoped lang="scss">
.detail-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.arco-form-item {
margin-bottom: 16px;
}
.arco-input,
.arco-select,
.arco-input-number,
.arco-date-picker {
border-radius: 6px;
}
</style>

View File

@ -312,6 +312,20 @@
v-model:visible="paymentDetailModalVisible"
:payment-data="currentPaymentData"
/>
<!-- 收货弹窗 -->
<ReceiptModal
v-model:visible="receiptModalVisible"
:equipment-data="currentReceiptData"
@success="handleReceiptSuccess"
/>
<!-- 支付弹窗 -->
<PaymentModal
v-model:visible="paymentModalVisible"
:equipment-data="currentPaymentData"
@success="handlePaymentSuccess"
/>
</div>
</template>
@ -333,6 +347,8 @@ import ProcurementSearch from './components/ProcurementSearch.vue'
import ProcurementApplicationModal from './components/ProcurementApplicationModal.vue'
import ReceiptDetailModal from './components/ReceiptDetailModal.vue'
import PaymentDetailModal from './components/PaymentDetailModal.vue'
import ReceiptModal from './components/ReceiptModal.vue'
import PaymentModal from './components/PaymentModal.vue'
import { equipmentProcurementApi } from '@/apis/equipment/procurement'
import { equipmentApprovalApi } from '@/apis/equipment/approval'
import type { EquipmentListReq, EquipmentResp } from '@/apis/equipment/type'
@ -377,6 +393,10 @@ const currentReceiptData = ref<EquipmentResp | null>(null)
const paymentDetailModalVisible = ref(false)
const currentPaymentData = ref<EquipmentResp | null>(null)
//
const receiptModalVisible = ref(false)
const paymentModalVisible = ref(false)
//
const selectedRowKeys = ref<string[]>([])
const rowSelection = reactive({
@ -988,17 +1008,8 @@ const canReceiveGoods = (record: EquipmentResp) => {
//
const handleReceiveGoods = async (record: EquipmentResp) => {
try {
// API
// await equipmentProcurementApi.receiveGoods(record.equipmentId)
message.success('收货成功')
//
(record as any).receiptStatus = 'RECEIVED'
loadData(currentSearchParams.value)
} catch (error: any) {
console.error('收货失败:', error)
message.error(error?.message || '收货失败')
}
currentReceiptData.value = { ...record }
receiptModalVisible.value = true
}
//
@ -1007,6 +1018,12 @@ const handleViewReceipt = (record: EquipmentResp) => {
receiptDetailModalVisible.value = true
}
//
const handleReceiptSuccess = () => {
receiptModalVisible.value = false
loadData(currentSearchParams.value)
}
//
const canMakePayment = (record: EquipmentResp) => {
const paymentStatus = (record as any).paymentStatus
@ -1015,17 +1032,8 @@ const canMakePayment = (record: EquipmentResp) => {
//
const handleMakePayment = async (record: EquipmentResp) => {
try {
// API
// await equipmentProcurementApi.makePayment(record.equipmentId)
message.success('付款成功')
//
(record as any).paymentStatus = 'PAID'
loadData(currentSearchParams.value)
} catch (error: any) {
console.error('付款失败:', error)
message.error(error?.message || '付款失败')
}
currentPaymentData.value = { ...record }
paymentModalVisible.value = true
}
//
@ -1034,6 +1042,12 @@ const handleViewPayment = (record: EquipmentResp) => {
paymentDetailModalVisible.value = true
}
//
const handlePaymentSuccess = () => {
paymentModalVisible.value = false
loadData(currentSearchParams.value)
}
//
const getApprovalStatusColor = (status: string) => {
const colorMap: Record<string, string> = {

View File

@ -200,47 +200,51 @@ const handleReject = (taskId: string) => {
/* 待审批状态样式 */
.status-item.pending {
border-top-color: #f59e0b;
background-color: rgba(245, 158, 11, 0.1); /* 浅橙色背景 */
background-color: #f59e0b;
background-image: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.status-item.pending .count {
color: #f59e0b;
color: #fff;
}
/* 已通过状态样式 */
.status-item.approved {
border-top-color: #10b981;
background-color: rgba(16, 185, 129, 0.1); /* 浅绿色背景 */
background-color: #10b981;
background-image: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.status-item.approved .count {
color: #10b981;
color: #fff;
}
/* 已拒绝状态样式 */
.status-item.rejected {
border-top-color: #ef4444;
background-color: rgba(239, 68, 68, 0.1); /* 浅红色背景 */
background-color: #ef4444;
background-image: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.status-item.rejected .count {
color: #ef4444;
color: #fff;
}
/* 状态文字样式 */
.status-text {
font-size: 14px;
color: #333;
font-size: 16px;
font-weight: 500;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* 数字样式 */
.count {
font-size: 20px;
font-weight: bold;
font-size: 28px;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
letter-spacing: 0.5px;
}
/* 激活态样式(可选,点击后高亮) */
.status-item.active {
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.content {

View File

@ -1,38 +1,711 @@
<template>
<GiPageLayout>
<div class="task-progress-page">
<!-- 页面标题 dwadw-->
<div class="page-header">
<h2>任务跟踪</h2>
<p class="page-description">跟踪监控和评估任务的完成情况</p>
<div class="task-tracking-page">
<!-- 固定标题和表头容器 -->
<div class="sticky-headers">
<!-- 页面标题 -->
<div class="page-header">
<h2>任务跟踪</h2>
<p class="page-description">跟踪监控和评估任务的完成情况</p>
</div>
<!-- 公共表头仅显示一次 -->
<div class="shared-header">
<div class="header-row">
<div class="col" style="width: 100px">任务描述</div>
<div class="col" style="width: 180px">任务情况总结</div>
<div class="col" style="width: 80px">任务执行人</div>
<div class="col" style="width: 60px">进展</div>
<div class="col" style="width: 120px">开始日期</div>
<div class="col" style="width: 120px">预计完成日期</div>
<div class="col" style="width: 80px">是否延期</div>
<div class="col" style="width: 120px">实际完成日期</div>
<div class="col" style="width: 180px">最新进展记录</div>
<div class="col" style="width: 100px">重要紧急程度</div>
</div>
</div>
</div>
<!-- 分组容器按重要紧急程度分组 -->
<div
v-for="(group, groupKey) in groupedTasks"
:key="groupKey"
class="task-group"
>
<!-- 分组标题带专门的折叠/展开按钮 -->
<div class="group-header">
<span class="group-title-text" :class="groupKey">{{ groupKey }}</span>
<button
class="toggle-btn"
@click="toggleGroup(groupKey)"
:aria-expanded="!group.collapsed"
>
<i class="icon" :class="group.collapsed ? 'el-icon-plus' : 'el-icon-minus'" />
<span class="toggle-text">{{ group.collapsed ? '展开' : '收起' }}</span>
</button>
</div>
<!-- 任务列表折叠时隐藏展开时显示 -->
<div class="task-list" v-show="!group.collapsed">
<div
v-for="(task, index) in group.tasks"
:key="index"
class="task-row"
>
<!-- 任务描述 -->
<div class="col" style="width: 100px">{{ task.taskDesc }}</div>
<!-- 任务情况总结带弹窗 -->
<div class="col info-cell" style="width: 180px">
<span @click="openPopup($event, task.summaryDetail, '任务情况总结')"
@mouseenter="cancelClosePopup"
@mouseleave="closePopup">
{{ task.summary }}
<i class="el-icon-info" />
</span>
</div>
<!-- 任务执行人 -->
<div class="col" style="width: 80px">{{ task.executor }}</div>
<!-- 进展标签化 -->
<div class="col progress-tag" :class="task.progress" style="width: 60px">
{{ task.progress }}
</div>
<!-- 开始日期 -->
<div class="col" style="width: 120px">{{ task.startDate }}</div>
<!-- 预计完成日期 -->
<div class="col" style="width: 120px">{{ task.expectEndDate }}</div>
<!-- 是否延期标签化 -->
<div class="col delay-tag" :class="task.isDelay" style="width: 80px">
{{ task.isDelay }}
</div>
<!-- 实际完成日期 -->
<div class="col" style="width: 120px">{{ task.actualEndDate }}</div>
<!-- 最新进展记录带弹窗 -->
<div class="col info-cell" style="width: 180px">
<span @click="openPopup($event, task.progressDetail, '最新进展记录')"
@mouseenter="cancelClosePopup"
@mouseleave="closePopup">
{{ task.latestProgress }}
<i class="el-icon-info" />
</span>
</div>
<!-- 重要紧急程度标签化 -->
<div class="col priority-tag" :class="groupKey" style="width: 100px">
{{ groupKey }}
</div>
</div>
</div>
</div>
<!-- 全局弹窗所有详情共用 -->
<transition name="popup">
<div
class="popup"
v-if="popupVisible"
:style="{
top: popupTop + 'px',
left: popupLeft + 'px'
}"
@mouseenter="cancelClosePopup"
@mouseleave="closePopup"
@click.stop
>
<div class="popup-title">{{ popupTitle }}</div>
<div class="popup-content">{{ popupContent }}</div>
<div class="popup-arrow"></div>
</div>
</transition>
</div>
</GiPageLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
//
interface Task {
taskDesc: string;
summary: string; //
summaryDetail: string; //
executor: string;
progress: string; //
priority: string; //
startDate: string;
expectEndDate: string;
isDelay: string; //
actualEndDate: string;
latestProgress: string; //
progressDetail: string; //
}
//
interface TaskGroup {
collapsed: boolean; //
tasks: Task[]; //
}
//
const rawTasks = ref<Task[]>([
{
taskDesc: '完成年度财务报告',
summary: '1. 任务执行人于小宁正...',
summaryDetail: '任务执行人于小宁正按流程推进,已梳理数据框架,待最终核算。目前已完成资产负债表初步编制,利润表数据核对中,预计下周完成全部核算工作。',
executor: '周北北',
progress: '进行中',
priority: '重要紧急',
startDate: '2023/02/05',
expectEndDate: '2024/11/25',
isDelay: '已延期',
actualEndDate: '',
latestProgress: '已经收集了所有必要的财...',
progressDetail: '已收集资产负债表、利润表原始数据,待合并现金流量表。本周重点完成了各部门费用核算,正在处理年末调整事项。'
},
{
taskDesc: '更新公司官网内容',
summary: '1. 正在收集各部门最新...',
summaryDetail: '正在收集各部门最新资料,市场部和销售部已提交更新内容,技术部和人力资源部资料待收。预计下周一开始页面制作。',
executor: '李小华',
progress: '进行中',
priority: '重要紧急',
startDate: '2023/11/01',
expectEndDate: '2023/11/30',
isDelay: '正常',
actualEndDate: '',
latestProgress: '设计稿已确认,等待内容...',
progressDetail: '设计稿已确认,等待各部门内容素材。目前已完成首页和产品页的设计,正在准备关于我们页面的素材。'
},
{
taskDesc: '制定明年培训计划',
summary: '1. 已完成需求调研,正...',
summaryDetail: '已完成需求调研正在整理各部门培训需求。调研显示技术类和管理类培训需求最高分别占比42%和35%。',
executor: '张明明',
progress: '进行中',
priority: '重要不紧急',
startDate: '2023/10/15',
expectEndDate: '2023/12/15',
isDelay: '正常',
actualEndDate: '',
latestProgress: '正在分析培训需求数据...',
progressDetail: '正在分析培训需求数据计划11月中旬完成初稿11月底组织各部门负责人评审。'
},
{
taskDesc: '组织年度员工团建活动',
summary: '1. 任务已经完成,因特...',
summaryDetail: '活动已落地执行含团队协作游戏、主题分享环节反馈良好。参与率达到95%收集到23条有效反馈其中85%为正面评价。',
executor: '周北北',
progress: '已完成',
priority: '紧急不重要',
startDate: '2023/01/18',
expectEndDate: '2024/12/02',
isDelay: '正常',
actualEndDate: '2023/05/25',
latestProgress: '已经确定了活动日期和地...',
progressDetail: '选定XX营地日期2023/05/20含露营、烧烤、团队挑战。活动预算控制在计划内实际花费比预算节省8%。'
},
{
taskDesc: '办公室绿植更换',
summary: '1. 已联系3家供应商...',
summaryDetail: '已联系3家供应商正在比较报价和服务。现有绿植约60%需要更换,主要是走廊和公共区域的大型绿植。',
executor: '王静静',
progress: '待开始',
priority: '不紧急不重要',
startDate: '2023/11/20',
expectEndDate: '2023/11/30',
isDelay: '正常',
actualEndDate: '',
latestProgress: '正在筛选供应商,等待批...',
progressDetail: '正在筛选供应商等待审批。初步选定两家供应商报价相差约15%,正在核实服务内容差异。'
},
{
taskDesc: '更新员工通讯录',
summary: '1. 收集各部门最新联...',
summaryDetail: '正在收集各部门最新联系方式,已完成市场部和销售部的信息更新,技术部和人力资源部资料待收。',
executor: '李小明',
progress: '待开始',
priority: '不紧急不重要',
startDate: '2023/11/25',
expectEndDate: '2023/12/15',
isDelay: '正常',
actualEndDate: '',
latestProgress: '等待各部门提交最新联...',
progressDetail: '已发送通知邮件给各部门负责人要求提供最新员工联系方式目前收到60%的回复。'
},
{
taskDesc: '整理归档旧项目文档',
summary: '1. 开始整理2022年...',
summaryDetail: '开始整理2022年度已完成项目的文档按照项目类型和日期进行分类归档预计需要两周时间完成。',
executor: '张小红',
progress: '待开始',
priority: '不紧急不重要',
startDate: '2023/12/01',
expectEndDate: '2023/12/15',
isDelay: '正常',
actualEndDate: '',
latestProgress: '准备归档工具和分类标...',
progressDetail: '已准备好归档所需的文件夹和标签,正在制定分类标准,等待主管审批。'
}
])
//
const groupKeys = ref(['重要紧急', '紧急不重要', '重要不紧急', '不紧急不重要'])
//
const groupCollapseState = ref<Record<string, boolean>>({})
//
const groupedTasks = computed(() => {
const groups: Record<string, TaskGroup> = {}
//
groupKeys.value.forEach(key => {
// 使(false)
groupCollapseState.value[key] = groupCollapseState.value[key] ?? false
groups[key] = {
collapsed: groupCollapseState.value[key],
tasks: []
}
})
//
rawTasks.value.forEach(task => {
const groupKey = task.priority
if (groups[groupKey]) {
groups[groupKey].tasks.push(task)
}
})
return groups
})
// /
function toggleGroup(groupKey: string) {
groupCollapseState.value[groupKey] = !groupCollapseState.value[groupKey]
//
localStorage.setItem('taskGroupCollapse', JSON.stringify(groupCollapseState.value))
}
//
onMounted(() => {
const savedState = localStorage.getItem('taskGroupCollapse')
if (savedState) {
groupCollapseState.value = JSON.parse(savedState)
}
})
//
const popupVisible = ref(false)
const popupTitle = ref('')
const popupContent = ref('')
const popupTop = ref(0)
const popupLeft = ref(0)
let popupTimer: number | null = null
//
function openPopup(event: MouseEvent, content: string, title: string) {
//
if (popupTimer) {
clearTimeout(popupTimer)
popupTimer = null
}
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
popupVisible.value = true
popupTitle.value = title
popupContent.value = content
//
popupTop.value = window.scrollY + window.innerHeight / 2 - 100
popupLeft.value = window.innerWidth / 2 - 150
}
//
function closePopup() {
popupTimer = setTimeout(() => {
popupVisible.value = false
popupTimer = null
}, 200)
}
//
function cancelClosePopup() {
if (popupTimer) {
clearTimeout(popupTimer)
popupTimer = null
}
}
//
function handleDocumentClick(e: MouseEvent) {
const popup = document.querySelector('.popup')
const infoCells = document.querySelectorAll('.info-cell')
//
if (popup && !popup.contains(e.target as Node) &&
!Array.from(infoCells).some(cell => cell.contains(e.target as Node))) {
closePopup()
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<style scoped>
.task-progress-page {
height: 100%;
/* 页面基础样式 */
.task-tracking-page {
padding: 20px;
display: flex;
flex-direction: column;
background: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
margin-bottom: 20px;
}
.page-header h2 {
font-size: 20px;
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.page-description {
color: #666;
margin-top: 8px;
font-size: 14px;
}
/* 公共表头 */
.shared-header {
background: #f8f9fa;
border: 1px solid #eee;
margin-bottom: 10px;
border-radius: 4px;
}
.header-row {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.header-row .col {
display: flex;
align-items: center;
justify-content: flex-start;
border-right: 1px solid #eee;
padding: 0 8px;
font-weight: 500;
color: #333;
font-size: 14px;
}
.header-row .col:last-child {
border-right: none;
}
/* 分组样式 */
.task-group {
margin-bottom: 10px;
border: 1px solid #eee;
border-radius: 4px;
background: #fff;
}
/* 分组标题(带折叠/展开按钮) */
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 15px;
border-bottom: 1px solid #eee;
cursor: pointer;
background: transparent;
}
.group-title-text {
font-weight: 500;
font-size: 15px;
padding: 4px 8px;
border-radius: 8px;
color: #fff;
}
.group-title-text.重要紧急 {
background: #dc3545;
}
.group-title-text.紧急不重要 {
background: #fd7e14;
}
.group-title-text.重要不紧急 {
background: #ffc107;
color: #333;
}
.group-title-text.不紧急不重要 {
background: #28a745;
}
/* 折叠/展开按钮 */
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background-color: #e9ecef;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #495057;
transition: all 0.2s ease;
}
.toggle-btn:hover {
background-color: #dee2e6;
color: #212529;
}
.toggle-btn .icon {
font-size: 14px;
}
.toggle-text {
user-select: none;
}
/* 任务列表(展开时显示) */
.task-tracking-page {
padding: 20px;
background: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
height: calc(100vh - 100px);
overflow-y: auto;
position: relative;
}
.sticky-headers {
position: sticky;
top: -20px; /* 向上移动消除空隙 */
background: #fff;
z-index: 10;
padding: 20px 0 10px;
margin-top: -20px; /* 消除外部空隙 */
}
.page-header {
padding: 20px 0 10px;
margin-bottom: 0;
}
.shared-header {
background: #fff;
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 10px;
}
.task-list {
padding: 10px;
}
/* 自定义滚动条样式 */
.task-tracking-page::-webkit-scrollbar {
width: 8px;
}
.task-tracking-page::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.task-tracking-page::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.task-tracking-page::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 任务行样式 */
.task-row {
display: flex;
align-items: center;
border-bottom: 1px solid #eee;
padding: 10px 0;
transition: background-color 0.2s;
}
.task-row:hover {
background-color: #f8f9fa;
}
.task-row:last-child {
border-bottom: none;
}
.task-row .col {
display: flex;
align-items: center;
justify-content: flex-start;
border-right: 1px solid #eee;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #333;
height: 40px;
}
.task-row .col:last-child {
border-right: none;
}
/* 信息单元格(带弹窗) */
.info-cell {
cursor: pointer;
color: #1890ff;
position: relative;
display: flex;
align-items: center;
gap: 4px;
}
.info-cell:hover {
text-decoration: underline;
}
.info-cell .el-icon-info {
font-size: 14px;
color: #666;
}
/* 进展标签样式 */
.progress-tag {
padding: 4px 8px;
border-radius: 8px;
color: #fff;
text-align: center;
font-weight: 500;
}
.progress-tag.进行中 {
background: #ffc107;
}
.progress-tag.已完成 {
background: #4caf50;
}
.progress-tag.待开始 {
background: #ff9800;
}
/* 是否延期标签 */
.delay-tag {
text-align: center;
font-weight: 500;
}
.delay-tag.正常 {
color: #28a745;
}
.delay-tag.已延期 {
color: #f44336;
}
/* 重要紧急程度标签 */
.priority-tag {
padding: 4px 8px;
border-radius: 8px;
color: #fff;
text-align: center;
font-weight: 500;
}
.priority-tag.重要紧急 {
background: #dc3545;
}
.priority-tag.紧急不重要 {
background: #fd7e14;
}
.priority-tag.重要不紧急 {
background: #ffc107;
color: #333;
}
.priority-tag.不紧急不重要 {
background: #28a745;
}
/* 弹窗样式 */
.popup {
/* 定位到页面中间 */
top: 50%;
left: 50%;
position: fixed;
width: 300px;
max-width: 80vw;
background: #fff;
border: 1px solid #f70b0b;
border-radius: 4px;
padding: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
z-index: 999;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.popup-title {
font-weight: bold;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
color: #333;
font-size: 15px;
}
.popup-content {
color: #555;
font-size: 14px;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
}
/* 弹窗过渡动画 */
.popup-enter-active {
transition: all 0.15s ease-out;
}
.popup-enter-from {
opacity: 0;
transform: translateY(5px);
}
.popup-enter-to {
opacity: 1;
transform: translateY(0);
}
</style>

View File

@ -173,6 +173,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import TaskForm from './components/TaskForm.vue';
import AssigneeSelector from './components/AssigneeSelector.vue';
const taskFormRef = ref<InstanceType<typeof TaskForm> | null>(null);
@ -330,15 +331,15 @@ const resetSearch = () => {
const handleSubmit = () => {
if (!taskFormRef.value?.form.taskName) {
alert('请填写任务名称');
Message.error('请填写任务名称');
return;
}
if (!taskFormRef.value?.form.dueDate) {
alert('请设置截止日期');
Message.error('请设置截止日期');
return;
}
if (!assigneeRef.value?.assignees.leader) {
alert('请选择任务负责人');
Message.error('请选择任务负责人');
return;
}
const taskData = {
@ -361,7 +362,7 @@ const handleSubmit = () => {
priority: taskData.priority || 'medium'
});
console.log('发布任务数据:', taskData);
alert('任务发布成功!');
Message.success('任务发布成功!');
showPublishModal.value = false;
};
</script>

View File

@ -56,7 +56,7 @@ const filteredUsers = ref<typeof users.value>([]);
const filterUsersByDepartment = () => {
if (!selectedDepartment.value) {
filteredUsers.value = [];
assignees.value.leader = '';
assignees.value.leader = -1;
return;
}
@ -66,7 +66,7 @@ const filterUsersByDepartment = () => {
//
if (assignees.value.leader && !filteredUsers.value.some(u => u.id === assignees.value.leader)) {
assignees.value.leader = '';
assignees.value.leader = 0;
}
};
@ -80,7 +80,7 @@ const departments = ref([
//
const assignees = ref({
leader: ''
leader: 0
});
//

View File

@ -0,0 +1,498 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- 顶部按钮 -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="openUploadModal">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
</div>
</div>
<!-- 筛选 -->
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目" required>
<a-select v-model="filterForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="filterForm.turbineId" placeholder="请选择机组" :options="turbineOptions"
allow-clear :disabled="!filterForm.projectId" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
</a-form-item>
</a-form>
</div>
<!-- 列表 -->
<a-table :columns="columns" :data="tableData" :pagination="pagination" :loading="loading"
:scroll="{ y: 'calc(100vh - 380px)' }">
<template #type="{ record }">
<a-tag>{{ record.type === 'clearance' ? '净空' : '形变' }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="record.preTreatment ? 'green' : 'red'">
{{ record.preTreatment ? '已处理' : '未处理' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button size="mini" @click="handlePreview(record)">预览</a-button>
<a-button size="mini" @click="handleDownload(record)">下载</a-button>
<a-popconfirm content="确认删除?" @ok="handleDelete(record)">
<a-button size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
<!-- 上传弹窗 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" :ok-loading="uploading"
@ok="handleUpload" @cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="onProjectChangeUpload" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="uploadForm.turbineId" placeholder="请选择机组" :options="turbineOptionsUpload"
allow-clear :disabled="!uploadForm.projectId" />
</a-form-item>
<a-form-item label="类型" required>
<a-select v-model="uploadForm.type" placeholder="请选择类型" :options="typeOptions" />
</a-form-item>
<a-form-item label="视频文件" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="uploadMode === 'batch'"
:limit="uploadMode === 'batch' ? 10 : 1" accept="video/*" :auto-upload="false"
list-type="picture-card" />
</a-form-item>
<a-form-item>
<a-radio-group v-model="uploadMode" type="button">
<a-radio value="single">单文件</a-radio>
<a-radio value="batch">批量</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</GiPageLayout>
<!-- 视频预览弹窗 -->
<a-modal v-model:visible="previewVisible" title="视频预览" width="800px" :footer="false"
@cancel="previewVisible = false">
<a-tabs v-model:active-key="activePreviewTab" @change="activePreviewTab = $event as any">
<!-- 原始视频 -->
<a-tab-pane key="video" title="原始视频">
<video v-if="previewUrl" :src="previewUrl" controls
style="width: 100%; max-height: 60vh; border-radius: 4px"></video>
</a-tab-pane>
<!-- 处理结果 -->
<a-tab-pane key="result" title="处理结果">
<a-spin :loading="loadingResult">
<a-space direction="vertical" size="medium" style="width: 100%">
<!-- 图片 -->
<img v-if="resultImgUrl" :src="resultImgUrl" style="max-width: 100%; border-radius: 4px"
alt="last frame" />
<!-- JSON 预览 -->
<a-card title="results.json" size="small">
<pre>{{ JSON.stringify(resultJson, null, 2) }}</pre>
</a-card>
</a-space>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { IconUpload } from '@arco-design/web-vue/es/icon'
import {
getProjectList,
getTurbineList
} from '@/apis/industrial-image'
import {
getVideoPage,
uploadBatchVideo,
uploadSingleVideo,
deleteVideo,
downloadVideo
} from '@/apis/video-monitor'
/* ---------------- 下拉 & 表单 ---------------- */
const projectOptions = ref<{ label: string; value: string }[]>([])
const turbineOptions = ref<{ label: string; value: string }[]>([]) //
const turbineOptionsUpload = ref<{ label: string; value: string }[]>([]) //
const typeOptions = [
{ label: '净空', value: 'clearance' },
{ label: '形变', value: 'deformation' }
]
const filterForm = reactive({
projectId: '',
turbineId: ''
})
const uploadForm = reactive({
projectId: '',
turbineId: '',
type: '',
fileList: [] as any[]
})
const uploadMode = ref<'single' | 'batch'>('single')
/* ---------------- 列表 ---------------- */
const columns: TableColumnData[] = [
{ title: '文件名', dataIndex: 'videoName', ellipsis: true, width: 220 },
// { title: '', dataIndex: 'projectName' },
// { title: '', dataIndex: 'turbineName' },
{ title: '类型', slotName: 'type' },
{ title: '上传时间', dataIndex: 'uploadTime' },
{ title: '状态', slotName: 'status' },
{ title: '操作', slotName: 'action', width: 120, fixed: 'right' }
]
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
/* ---------------- 控制弹窗 ---------------- */
const showUploadModal = ref(false)
const uploading = ref(false)
/* ---------------- 初始化 ---------------- */
onMounted(async () => {
const { data } = await getProjectList({ page: 1, pageSize: 1000 })
projectOptions.value = data.map((p: any) => ({ label: p.projectName, value: p.projectId }))
handleQuery()
})
const activePreviewTab = ref<'video' | 'result'>('video') //
const resultImgUrl = ref('')
const resultJson = ref<Record<string, any>>({})
const loadingResult = ref(false)
const resutlVideoUrl = ref('')
async function loadResultFiles(row: any) {
if (!row.preTreatment) return
loadingResult.value = true
try {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
//
resultImgUrl.value = `${base}${row.preImagePath}/last_frame.jpg`
resutlVideoUrl.value = `${base}${row.preImagePath}/annotated_video.mp4`
// JSON
const jsonUrl = `${base}${row.preImagePath}/results.json`
const res = await fetch(jsonUrl)
resultJson.value = await res.json()
} catch (e) {
console.error(e)
resultJson.value = {}
} finally {
loadingResult.value = false
}
console.log('result', resultImgUrl.value)
}
/* 项目 -> 机组(筛选) */
watch(
() => filterForm.projectId,
async (val) => {
filterForm.turbineId = ''
turbineOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineOptions.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
)
const previewVisible = ref(false)
const previewUrl = ref('')
function handlePreview(row: any) {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
previewUrl.value = new URL(row.videoPath.replace(/^\/+/, ''), base).href
previewVisible.value = true
activePreviewTab.value = 'video' //
if (row.preTreatment) {
loadResultFiles(row) //
}
}
/* 项目 -> 机组(上传弹窗) */
async function onProjectChangeUpload(projectId: string) {
uploadForm.turbineId = ''
turbineOptionsUpload.value = []
if (!projectId) return
const { data } = await getTurbineList({ projectId })
turbineOptionsUpload.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
/* ---------------- 查询 ---------------- */
function handleQuery() {
pagination.current = 1
loadTable()
}
async function loadTable() {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
projectId: filterForm.projectId,
turbineId: filterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
console.log(data)
tableData.value = data
pagination.total = data.length
} finally {
loading.value = false
}
}
/* ---------------- 上传 ---------------- */
function openUploadModal() {
uploadForm.projectId = ''
uploadForm.turbineId = ''
uploadForm.type = ''
uploadForm.fileList = []
showUploadModal.value = true
}
async function handleUpload() {
if (!uploadForm.projectId || !uploadForm.type || !uploadForm.fileList.length) {
Message.warning('请完整填写')
return
}
uploading.value = true
try {
const files = uploadForm.fileList.map((f: any) => f.file)
if (uploadMode.value === 'single') {
await uploadSingleVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files[0]
)
} else {
await uploadBatchVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files
)
}
Message.success('上传成功')
showUploadModal.value = false
loadTable()
} finally {
uploading.value = false
}
}
/* ---------------- 下载 / 删除 ---------------- */
async function handleDownload(row: any) {
const url = await downloadVideo(row.videoId)
window.open(url, '_blank')
}
async function handleDelete(row: any) {
await deleteVideo(row.videoId)
Message.success('删除成功')
loadTable()
}
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
</style>

View File

@ -0,0 +1,665 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- 顶部页签 -->
<a-tabs v-model:active-key="topTab" type="rounded" destroy-on-hide>
<!-- 原始数据 -->
<a-tab-pane key="data" title="原始数据">
<!-- 顶部按钮 -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="openUploadModal">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
</div>
</div>
<!-- 筛选 -->
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目" required>
<a-select v-model="filterForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="filterForm.turbineId" placeholder="请选择机组" :options="turbineOptions"
allow-clear :disabled="!filterForm.projectId" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
</a-form-item>
</a-form>
</div>
<!-- 列表 -->
<a-table :columns="columns" :data="tableData" :pagination="pagination" :loading="loading"
:scroll="{ y: 'calc(100vh - 380px)' }">
<template #type="{ record }">
<a-tag>{{ record.type === 'clearance' ? '净空' : '形变' }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="record.preTreatment ? 'green' : 'red'">
{{ record.preTreatment ? '已处理' : '未处理' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button size="mini" @click="handlePreview(record)">预览</a-button>
<a-button size="mini" @click="handleDownload(record)">下载</a-button>
<a-popconfirm content="确认删除?" @ok="handleDelete(record)">
<a-button size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-tab-pane>
<!-- 净空报告 -->
<a-tab-pane key="report" title="净空报告">
<!-- 筛选 -->
<a-form :model="reportFilterForm" layout="inline" style="margin-bottom: 16px">
<a-form-item label="项目">
<a-select v-model="reportFilterForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="reportFilterForm.turbineId" placeholder="请选择机组"
:options="turbineReportOptions" allow-clear :disabled="!reportFilterForm.projectId" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadReportGrid">查询</a-button>
</a-form-item>
</a-form>
<!-- 网格 -->
<a-row :gutter="[24, 24]">
<a-col v-for="item in reportGrid" :key="item.videoId" :xs="24" :sm="12" :md="8" :lg="6">
<!-- 卡片内部 -->
<a-card hoverable>
<div @click="openReportDetail(item)" style="cursor: pointer">
<video :src="item.videoUrl"
style="width: 100%; height: 120px; object-fit: cover; border-radius: 4px" />
<div class="video-name">{{ item.videoName }}</div>
</div>
<a-row :gutter="8" style="margin-top: 8px">
<a-col :span="8">
<div class="stat-label">最小</div>
<div class="stat-value">{{ item.min }} m</div>
</a-col>
<a-col :span="8">
<div class="stat-label">最大</div>
<div class="stat-value">{{ item.max }} m</div>
</a-col>
<a-col :span="8">
<div class="stat-label">平均</div>
<div class="stat-value">{{ item.avg }} m</div>
</a-col>
</a-row>
<!-- 查看详情按钮 -->
<a-button size="mini" style="margin-top: 8px; width: 100%"
@click="openReportDetail(item)">
查看详情
</a-button>
</a-card>
<!-- 新增报告详情模态框 -->
<a-modal v-model:visible="reportDetailVisible" title="净空报告详情" width="900px" :footer="false"
@cancel="reportDetailVisible = false">
<a-tabs size="small">
<a-tab-pane key="trend" title="趋势数据">
<a-table :columns="trendColumns" :data="currentReportDetail?.trend" size="small"
:scroll="{ y: 400 }" row-key="frame" />
</a-tab-pane>
<a-tab-pane key="json" title="原始 JSON">
<pre style="max-height: 400px; overflow: auto">{{
JSON.stringify(currentReportDetail?.rawJson, null, 2)
}}</pre>
</a-tab-pane>
</a-tabs>
</a-modal>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
<!-- 上传弹窗已去掉处理结果 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" :ok-loading="uploading"
@ok="handleUpload" @cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="onProjectChangeUpload" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="uploadForm.turbineId" placeholder="请选择机组" :options="turbineOptionsUpload"
allow-clear :disabled="!uploadForm.projectId" />
</a-form-item>
<a-form-item label="类型" required>
<a-select v-model="uploadForm.type" placeholder="请选择类型" :options="typeOptions" />
</a-form-item>
<a-form-item label="视频文件" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="uploadMode === 'batch'"
:limit="uploadMode === 'batch' ? 10 : 1" accept="video/*" :auto-upload="false"
list-type="picture-card" />
</a-form-item>
<a-form-item>
<a-radio-group v-model="uploadMode" type="button">
<a-radio value="single">单文件</a-radio>
<a-radio value="batch">批量</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
<!-- 视频预览弹窗仅保留原始视频 -->
<a-modal v-model:visible="previewVisible" title="视频预览" width="800px" :footer="false"
@cancel="previewVisible = false">
<a-tabs v-model:active-key="activePreviewTab">
<a-tab-pane key="video" title="原始视频">
<video v-if="previewUrl" :src="previewUrl" controls
style="width: 100%; max-height: 60vh; border-radius: 4px"></video>
</a-tab-pane>
</a-tabs>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { IconUpload } from '@arco-design/web-vue/es/icon'
import {
getProjectList,
getTurbineList
} from '@/apis/industrial-image'
import {
getVideoPage,
uploadBatchVideo,
uploadSingleVideo,
deleteVideo,
downloadVideo
} from '@/apis/video-monitor'
/* ---------------- 下拉 & 表单 ---------------- */
const projectOptions = ref<{ label: string; value: string }[]>([])
const turbineOptions = ref<{ label: string; value: string }[]>([])
const turbineOptionsUpload = ref<{ label: string; value: string }[]>([])
const typeOptions = [
{ label: '净空', value: 'clearance' },
{ label: '形变', value: 'deformation' }
]
const filterForm = reactive({
projectId: '',
turbineId: ''
})
const uploadForm = reactive({
projectId: '',
turbineId: '',
type: '',
fileList: [] as any[]
})
const uploadMode = ref<'single' | 'batch'>('single')
const reportDetailVisible = ref(false)
const currentReportDetail = ref<ReportItem | null>(null)
function openReportDetail(item: ReportItem) {
currentReportDetail.value = item
reportDetailVisible.value = true
}
/* ---------------- 列表 ---------------- */
const columns: TableColumnData[] = [
{ title: '文件名', dataIndex: 'videoName', ellipsis: true, width: 220 },
{ title: '类型', slotName: 'type' },
{ title: '上传时间', dataIndex: 'uploadTime' },
{ title: '状态', slotName: 'status' },
{ title: '操作', slotName: 'action', width: 120, fixed: 'right' }
]
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
/* ---------------- 顶部 Tab ---------------- */
const topTab = ref<'data' | 'report'>('data')
/* ---------------- 报告筛选 ---------------- */
const reportFilterForm = reactive({
projectId: '',
turbineId: ''
})
const turbineReportOptions = ref<{ label: string; value: string }[]>([])
watch(
() => reportFilterForm.projectId,
async (val) => {
reportFilterForm.turbineId = ''
turbineReportOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineReportOptions.value = data.map((t: any) => ({
label: t.turbineName,
value: t.turbineId
}))
}
)
/* ---------------- 报告网格 ---------------- */
interface ReportItem {
videoId: string
videoName: string
videoUrl: string
min: string
max: string
avg: string
trend: any[]
rawJson: any
_activeKey?: string[]
}
const reportGrid = ref<ReportItem[]>([])
async function loadReportGrid() {
loading.value = true
try {
const params = {
pageNo: 1,
pageSize: 1000,
projectId: reportFilterForm.projectId,
turbineId: reportFilterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
const rows = data.filter(
(r: any) => r.type === 'clearance' && r.preTreatment
)
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
reportGrid.value = await Promise.all(
rows.map(async (r: any) => {
const jsonUrl = `${base}${r.preImagePath}/results.json`
const json = await (await fetch(jsonUrl)).json()
const all = Object.values(json.clearance_history || {})
.flat()
.filter((v: any) => typeof v === 'number')
const min = Math.min(...all)
const max = Math.max(...all)
const avg = all.reduce((a: number, b: number) => a + b, 0) / all.length
const { tip0, tip1, tip2 } = json.clearance_history || {}
const len = Math.max(tip0?.length ?? 0, tip1?.length ?? 0, tip2?.length ?? 0)
const trend = Array.from({ length: len }, (_, i) => ({
frame: i + 1,
tip0: tip0?.[i]?.toFixed(3) ?? '-',
tip1: tip1?.[i]?.toFixed(3) ?? '-',
tip2: tip2?.[i]?.toFixed(3) ?? '-'
}))
return {
videoId: r.videoId,
videoName: r.videoName,
videoUrl: new URL(r.videoPath.replace(/^\/+/, ''), base).href,
min: min.toFixed(2),
max: max.toFixed(2),
avg: avg.toFixed(2),
trend,
rawJson: json,
_activeKey: []
}
})
)
} finally {
loading.value = false
}
}
function toggleDetail(item: ReportItem) {
item._activeKey = item._activeKey?.length ? [] : ['detail']
}
/* ---------------- 原列表逻辑 ---------------- */
onMounted(async () => {
const { data } = await getProjectList({ page: 1, pageSize: 1000 })
projectOptions.value = data.map((p: any) => ({
label: p.projectName,
value: p.projectId
}))
handleQuery()
})
watch(
() => filterForm.projectId,
async (val) => {
filterForm.turbineId = ''
turbineOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineOptions.value = data.map((t: any) => ({
label: t.turbineName,
value: t.turbineId
}))
}
)
/* ---------------- 列表查询 ---------------- */
function handleQuery() {
pagination.current = 1
loadTable()
}
async function loadTable() {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
projectId: filterForm.projectId,
turbineId: filterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
tableData.value = data
pagination.total = data.length
} finally {
loading.value = false
}
}
/* ---------------- 上传 ---------------- */
const showUploadModal = ref(false)
const uploading = ref(false)
function openUploadModal() {
uploadForm.projectId = ''
uploadForm.turbineId = ''
uploadForm.type = ''
uploadForm.fileList = []
showUploadModal.value = true
}
async function onProjectChangeUpload(projectId: string) {
uploadForm.turbineId = ''
turbineOptionsUpload.value = []
if (!projectId) return
const { data } = await getTurbineList({ projectId })
turbineOptionsUpload.value = data.map((t: any) => ({
label: t.turbineName,
value: t.turbineId
}))
}
async function handleUpload() {
if (!uploadForm.projectId || !uploadForm.type || !uploadForm.fileList.length) {
Message.warning('请完整填写')
return
}
uploading.value = true
try {
const files = uploadForm.fileList.map((f: any) => f.file)
if (uploadMode.value === 'single') {
await uploadSingleVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files[0]
)
} else {
await uploadBatchVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files
)
}
Message.success('上传成功')
showUploadModal.value = false
loadTable()
//
if (topTab.value === 'report') await loadReportGrid()
} finally {
uploading.value = false
}
}
/* ---------------- 下载 / 删除 ---------------- */
async function handleDownload(row: any) {
const url = await downloadVideo(row.videoId)
window.open(url, '_blank')
}
async function handleDelete(row: any) {
await deleteVideo(row.videoId)
Message.success('删除成功')
loadTable()
if (topTab.value === 'report') await loadReportGrid()
}
/* ---------------- 预览 ---------------- */
const previewVisible = ref(false)
const previewUrl = ref('')
const activePreviewTab = ref<'video' | 'result'>('video')
function handlePreview(row: any) {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
previewUrl.value = new URL(row.videoPath.replace(/^\/+/, ''), base).href
previewVisible.value = true
activePreviewTab.value = 'video'
}
/* ---------------- 趋势表格列 ---------------- */
const trendColumns = [
{ title: '帧', dataIndex: 'frame', width: 70 },
{ title: 'tip0 (m)', dataIndex: 'tip0' },
{ title: 'tip1 (m)', dataIndex: 'tip1' },
{ title: 'tip2 (m)', dataIndex: 'tip2' }
]
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
.video-name {
margin-top: 6px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
font-size: 12px;
color: #86909c;
}
.stat-value {
font-size: 16px;
font-weight: 600;
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- 顶部按钮 -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="openUploadModal">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
</div>
</div>
<!-- 筛选 -->
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目" required>
<a-select v-model="filterForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="filterForm.turbineId" placeholder="请选择机组" :options="turbineOptions"
allow-clear :disabled="!filterForm.projectId" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
</a-form-item>
</a-form>
</div>
<!-- 列表 -->
<a-table :columns="columns" :data="tableData" :pagination="pagination" :loading="loading"
:scroll="{ y: 'calc(100vh - 380px)' }">
<template #type="{ record }">
<a-tag>{{ record.type === 'clearance' ? '净空' : '形变' }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="record.preTreatment ? 'green' : 'red'">
{{ record.preTreatment ? '已处理' : '未处理' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button size="mini" @click="handlePreview(record)">预览</a-button>
<a-button size="mini" @click="handleDownload(record)">下载</a-button>
<a-popconfirm content="确认删除?" @ok="handleDelete(record)">
<a-button size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
<!-- 上传弹窗 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" :ok-loading="uploading"
@ok="handleUpload" @cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="onProjectChangeUpload" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="uploadForm.turbineId" placeholder="请选择机组" :options="turbineOptionsUpload"
allow-clear :disabled="!uploadForm.projectId" />
</a-form-item>
<a-form-item label="类型" required>
<a-select v-model="uploadForm.type" placeholder="请选择类型" :options="typeOptions" />
</a-form-item>
<a-form-item label="视频文件" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="uploadMode === 'batch'"
:limit="uploadMode === 'batch' ? 10 : 1" accept="video/*" :auto-upload="false"
list-type="picture-card" />
</a-form-item>
<a-form-item>
<a-radio-group v-model="uploadMode" type="button">
<a-radio value="single">单文件</a-radio>
<a-radio value="batch">批量</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</GiPageLayout>
<!-- 视频预览弹窗 -->
<a-modal v-model:visible="previewVisible" title="视频预览" width="800px" :footer="false"
@cancel="previewVisible = false">
<a-tabs v-model:active-key="activePreviewTab" @change="activePreviewTab = $event as any">
<!-- 原始视频 -->
<a-tab-pane key="video" title="原始视频">
<video v-if="previewUrl" :src="previewUrl" controls
style="width: 100%; max-height: 60vh; border-radius: 4px"></video>
</a-tab-pane>
<!-- 处理结果 -->
<a-tab-pane key="result" title="处理结果">
<a-spin :loading="loadingResult">
<a-space direction="vertical" size="medium" style="width: 100%">
<!-- 图片 -->
<img v-if="resultImgUrl" :src="resultImgUrl" style="max-width: 100%; border-radius: 4px"
alt="last frame" />
<!-- JSON 预览 -->
<a-card title="results.json" size="small">
<pre>{{ JSON.stringify(resultJson, null, 2) }}</pre>
</a-card>
</a-space>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { IconUpload } from '@arco-design/web-vue/es/icon'
import {
getProjectList,
getTurbineList
} from '@/apis/industrial-image'
import {
getVideoPage,
uploadBatchVideo,
uploadSingleVideo,
deleteVideo,
downloadVideo
} from '@/apis/video-monitor'
/* ---------------- 下拉 & 表单 ---------------- */
const projectOptions = ref<{ label: string; value: string }[]>([])
const turbineOptions = ref<{ label: string; value: string }[]>([]) //
const turbineOptionsUpload = ref<{ label: string; value: string }[]>([]) //
const typeOptions = [
{ label: '净空', value: 'clearance' },
{ label: '形变', value: 'deformation' }
]
const filterForm = reactive({
projectId: '',
turbineId: ''
})
const uploadForm = reactive({
projectId: '',
turbineId: '',
type: '',
fileList: [] as any[]
})
const uploadMode = ref<'single' | 'batch'>('single')
/* ---------------- 列表 ---------------- */
const columns: TableColumnData[] = [
{ title: '文件名', dataIndex: 'videoName', ellipsis: true, width: 220 },
// { title: '', dataIndex: 'projectName' },
// { title: '', dataIndex: 'turbineName' },
{ title: '类型', slotName: 'type' },
{ title: '上传时间', dataIndex: 'uploadTime' },
{ title: '状态', slotName: 'status' },
{ title: '操作', slotName: 'action', width: 120, fixed: 'right' }
]
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
/* ---------------- 控制弹窗 ---------------- */
const showUploadModal = ref(false)
const uploading = ref(false)
/* ---------------- 初始化 ---------------- */
onMounted(async () => {
const { data } = await getProjectList({ page: 1, pageSize: 1000 })
projectOptions.value = data.map((p: any) => ({ label: p.projectName, value: p.projectId }))
handleQuery()
})
const activePreviewTab = ref<'video' | 'result'>('video') //
const resultImgUrl = ref('')
const resultJson = ref<Record<string, any>>({})
const loadingResult = ref(false)
async function loadResultFiles(row: any) {
if (!row.preTreatment) return
loadingResult.value = true
try {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
//
resultImgUrl.value = `${base}${row.preImagePath}/last_frame.jpg`
// JSON
const jsonUrl = `${base}${row.preImagePath}/results.json`
const res = await fetch(jsonUrl)
resultJson.value = await res.json()
} catch (e) {
console.error(e)
resultJson.value = {}
} finally {
loadingResult.value = false
}
console.log('result', resultImgUrl.value)
}
/* 项目 -> 机组(筛选) */
watch(
() => filterForm.projectId,
async (val) => {
filterForm.turbineId = ''
turbineOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineOptions.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
)
const previewVisible = ref(false)
const previewUrl = ref('')
function handlePreview(row: any) {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
previewUrl.value = new URL(row.videoPath.replace(/^\/+/, ''), base).href
previewVisible.value = true
activePreviewTab.value = 'video' //
if (row.preTreatment) {
loadResultFiles(row) //
}
}
/* 项目 -> 机组(上传弹窗) */
async function onProjectChangeUpload(projectId: string) {
uploadForm.turbineId = ''
turbineOptionsUpload.value = []
if (!projectId) return
const { data } = await getTurbineList({ projectId })
turbineOptionsUpload.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
/* ---------------- 查询 ---------------- */
function handleQuery() {
pagination.current = 1
loadTable()
}
async function loadTable() {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
projectId: filterForm.projectId,
turbineId: filterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
console.log(data)
tableData.value = data
pagination.total = data.length
} finally {
loading.value = false
}
}
/* ---------------- 上传 ---------------- */
function openUploadModal() {
uploadForm.projectId = ''
uploadForm.turbineId = ''
uploadForm.type = ''
uploadForm.fileList = []
showUploadModal.value = true
}
async function handleUpload() {
if (!uploadForm.projectId || !uploadForm.type || !uploadForm.fileList.length) {
Message.warning('请完整填写')
return
}
uploading.value = true
try {
const files = uploadForm.fileList.map((f: any) => f.file)
if (uploadMode.value === 'single') {
await uploadSingleVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files[0]
)
} else {
await uploadBatchVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files
)
}
Message.success('上传成功')
showUploadModal.value = false
loadTable()
} finally {
uploading.value = false
}
}
/* ---------------- 下载 / 删除 ---------------- */
async function handleDownload(row: any) {
const url = await downloadVideo(row.videoId)
window.open(url, '_blank')
}
async function handleDelete(row: any) {
await deleteVideo(row.videoId)
Message.success('删除成功')
loadTable()
}
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- 顶部按钮 -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="openUploadModal">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
</div>
</div>
<!-- 筛选 -->
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目" required>
<a-select v-model="filterForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="filterForm.turbineId" placeholder="请选择机组" :options="turbineOptions"
allow-clear :disabled="!filterForm.projectId" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
</a-form-item>
</a-form>
</div>
<!-- 列表 -->
<a-table :columns="columns" :data="tableData" :pagination="pagination" :loading="loading"
:scroll="{ y: 'calc(100vh - 380px)' }">
<template #type="{ record }">
<a-tag>{{ record.type === 'clearance' ? '净空' : '形变' }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="record.preTreatment ? 'green' : 'red'">
{{ record.preTreatment ? '已处理' : '未处理' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button size="mini" @click="handlePreview(record)">预览</a-button>
<a-button size="mini" @click="handleDownload(record)">下载</a-button>
<a-popconfirm content="确认删除?" @ok="handleDelete(record)">
<a-button size="mini" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
<!-- 上传弹窗 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" :ok-loading="uploading"
@ok="handleUpload" @cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目" :options="projectOptions"
allow-clear @change="onProjectChangeUpload" />
</a-form-item>
<a-form-item label="机组">
<a-select v-model="uploadForm.turbineId" placeholder="请选择机组" :options="turbineOptionsUpload"
allow-clear :disabled="!uploadForm.projectId" />
</a-form-item>
<a-form-item label="类型" required>
<a-select v-model="uploadForm.type" placeholder="请选择类型" :options="typeOptions" />
</a-form-item>
<a-form-item label="视频文件" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="uploadMode === 'batch'"
:limit="uploadMode === 'batch' ? 10 : 1" accept="video/*" :auto-upload="false"
list-type="picture-card" />
</a-form-item>
<a-form-item>
<a-radio-group v-model="uploadMode" type="button">
<a-radio value="single">单文件</a-radio>
<a-radio value="batch">批量</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</GiPageLayout>
<!-- 视频预览弹窗 -->
<a-modal v-model:visible="previewVisible" title="视频预览" width="800px" :footer="false"
@cancel="previewVisible = false">
<a-tabs v-model:active-key="activePreviewTab" @change="activePreviewTab = $event as any">
<!-- 原始视频 -->
<a-tab-pane key="video" title="原始视频">
<video v-if="previewUrl" :src="previewUrl" controls
style="width: 100%; max-height: 60vh; border-radius: 4px"></video>
</a-tab-pane>
<!-- 处理结果 -->
<a-tab-pane key="result" title="处理结果">
<a-spin :loading="loadingResult">
<a-space direction="vertical" size="medium" style="width: 100%">
<!-- 图片 -->
<img v-if="resultImgUrl" :src="resultImgUrl" style="max-width: 100%; border-radius: 4px"
alt="last frame" />
<!-- JSON 预览 -->
<a-card title="results.json" size="small">
<pre>{{ JSON.stringify(resultJson, null, 2) }}</pre>
</a-card>
</a-space>
</a-spin>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { IconUpload } from '@arco-design/web-vue/es/icon'
import {
getProjectList,
getTurbineList
} from '@/apis/industrial-image'
import {
getVideoPage,
uploadBatchVideo,
uploadSingleVideo,
deleteVideo,
downloadVideo
} from '@/apis/video-monitor'
/* ---------------- 下拉 & 表单 ---------------- */
const projectOptions = ref<{ label: string; value: string }[]>([])
const turbineOptions = ref<{ label: string; value: string }[]>([]) //
const turbineOptionsUpload = ref<{ label: string; value: string }[]>([]) //
const typeOptions = [
{ label: '净空', value: 'clearance' },
{ label: '形变', value: 'deformation' }
]
const filterForm = reactive({
projectId: '',
turbineId: ''
})
const uploadForm = reactive({
projectId: '',
turbineId: '',
type: '',
fileList: [] as any[]
})
const uploadMode = ref<'single' | 'batch'>('single')
/* ---------------- 列表 ---------------- */
const columns: TableColumnData[] = [
{ title: '文件名', dataIndex: 'videoName', ellipsis: true, width: 220 },
// { title: '', dataIndex: 'projectName' },
// { title: '', dataIndex: 'turbineName' },
{ title: '类型', slotName: 'type' },
{ title: '上传时间', dataIndex: 'uploadTime' },
{ title: '状态', slotName: 'status' },
{ title: '操作', slotName: 'action', width: 120, fixed: 'right' }
]
const tableData = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
/* ---------------- 控制弹窗 ---------------- */
const showUploadModal = ref(false)
const uploading = ref(false)
/* ---------------- 初始化 ---------------- */
onMounted(async () => {
const { data } = await getProjectList({ page: 1, pageSize: 1000 })
projectOptions.value = data.map((p: any) => ({ label: p.projectName, value: p.projectId }))
handleQuery()
})
const activePreviewTab = ref<'video' | 'result'>('video') //
const resultImgUrl = ref('')
const resultJson = ref<Record<string, any>>({})
const loadingResult = ref(false)
async function loadResultFiles(row: any) {
if (!row.preTreatment) return
loadingResult.value = true
try {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
//
resultImgUrl.value = `${base}${row.preImagePath}/last_frame.jpg`
// JSON
const jsonUrl = `${base}${row.preImagePath}/results.json`
const res = await fetch(jsonUrl)
resultJson.value = await res.json()
} catch (e) {
console.error(e)
resultJson.value = {}
} finally {
loadingResult.value = false
}
console.log('result', resultImgUrl.value)
}
/* 项目 -> 机组(筛选) */
watch(
() => filterForm.projectId,
async (val) => {
filterForm.turbineId = ''
turbineOptions.value = []
if (!val) return
const { data } = await getTurbineList({ projectId: val })
turbineOptions.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
)
const previewVisible = ref(false)
const previewUrl = ref('')
function handlePreview(row: any) {
const base = import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, '')
previewUrl.value = new URL(row.videoPath.replace(/^\/+/, ''), base).href
previewVisible.value = true
activePreviewTab.value = 'video' //
if (row.preTreatment) {
loadResultFiles(row) //
}
}
/* 项目 -> 机组(上传弹窗) */
async function onProjectChangeUpload(projectId: string) {
uploadForm.turbineId = ''
turbineOptionsUpload.value = []
if (!projectId) return
const { data } = await getTurbineList({ projectId })
turbineOptionsUpload.value = data.map((t: any) => ({ label: t.turbineName, value: t.turbineId }))
}
/* ---------------- 查询 ---------------- */
function handleQuery() {
pagination.current = 1
loadTable()
}
async function loadTable() {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.pageSize,
projectId: filterForm.projectId,
turbineId: filterForm.turbineId || undefined
}
const { data } = await getVideoPage(params)
console.log(data)
tableData.value = data
pagination.total = data.length
} finally {
loading.value = false
}
}
/* ---------------- 上传 ---------------- */
function openUploadModal() {
uploadForm.projectId = ''
uploadForm.turbineId = ''
uploadForm.type = ''
uploadForm.fileList = []
showUploadModal.value = true
}
async function handleUpload() {
if (!uploadForm.projectId || !uploadForm.type || !uploadForm.fileList.length) {
Message.warning('请完整填写')
return
}
uploading.value = true
try {
const files = uploadForm.fileList.map((f: any) => f.file)
if (uploadMode.value === 'single') {
await uploadSingleVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files[0]
)
} else {
await uploadBatchVideo(
uploadForm.projectId,
uploadForm.turbineId || '',
uploadForm.type,
files
)
}
Message.success('上传成功')
showUploadModal.value = false
loadTable()
} finally {
uploading.value = false
}
}
/* ---------------- 下载 / 删除 ---------------- */
async function handleDownload(row: any) {
const url = await downloadVideo(row.videoId)
window.open(url, '_blank')
}
async function handleDelete(row: any) {
await deleteVideo(row.videoId)
Message.success('删除成功')
loadTable()
}
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
</style>