Compare commits

17 Commits

Author SHA1 Message Date
zc
630a98af65 优化 2026-03-06 18:25:24 +08:00
zc
31178da436 Merge remote-tracking branch 'refs/remotes/origin/master' into dev
# Conflicts:
#	src/views/weightManage/index.vue
2026-03-06 18:15:58 +08:00
zc
323a641fa3 Merge branch 'refs/heads/master' into dev
# Conflicts:
#	src/views/system/user/index.vue
2026-03-06 18:06:34 +08:00
zc
c858ace541 优化 2026-03-06 18:04:40 +08:00
f35d64bd91 称重抓取 2026-03-06 18:03:28 +08:00
zc
006130b4b7 优化 2026-03-06 16:29:31 +08:00
zc
b4ebebe2a8 优化 2026-03-06 15:54:36 +08:00
zc
60933ee1a6 优化 2026-03-06 14:17:19 +08:00
zc
d014699b95 优化 2026-03-05 18:14:25 +08:00
15ce1cfa13 Merge branch 'refs/heads/dev' into master_lz
# Conflicts:
#	src/views/system/user/index.vue
2026-03-05 16:35:28 +08:00
7058245cdf 物料新增批量导入照片按钮 2026-03-05 14:48:32 +08:00
7f5ab90f22 优化 2026-03-04 18:04:37 +08:00
zc
0c40b20df7 优化追溯 2026-03-04 18:04:32 +08:00
zc
1e64e776ce 优化追溯 2026-03-04 16:46:52 +08:00
zc
31b7d3237a Merge branch 'refs/heads/master' into dev 2026-03-04 10:34:25 +08:00
zc
85dd8102a3 优化称重页面 2026-03-03 17:59:37 +08:00
124de2926e 优化 2026-03-03 15:05:20 +08:00
21 changed files with 2146 additions and 294 deletions

24
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.4", "dayjs": "^1.11.4",
"echarts": "^5.4.2", "echarts": "^5.4.2",
"flv.js": "^1.6.2",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -765,7 +766,6 @@
}, },
"node_modules/@clack/prompts/node_modules/is-unicode-supported": { "node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0", "version": "1.3.0",
"extraneous": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6560,6 +6560,12 @@
"es6-symbol": "^3.1.1" "es6-symbol": "^3.1.1"
} }
}, },
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmmirror.com/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"license": "MIT"
},
"node_modules/es6-symbol": { "node_modules/es6-symbol": {
"version": "3.1.4", "version": "3.1.4",
"resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz", "resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz",
@@ -8254,6 +8260,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/flv.js": {
"version": "1.6.2",
"resolved": "https://registry.npmmirror.com/flv.js/-/flv.js-1.6.2.tgz",
"integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==",
"license": "Apache-2.0",
"dependencies": {
"es6-promise": "^4.2.8",
"webworkify-webpack": "^2.1.5"
}
},
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.9", "version": "1.15.9",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -16043,6 +16059,12 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/webworkify-webpack": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz",
"integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",

View File

@@ -34,6 +34,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.4", "dayjs": "^1.11.4",
"echarts": "^5.4.2", "echarts": "^5.4.2",
"flv.js": "^1.6.2",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"lint-staged": "^15.2.10", "lint-staged": "^15.2.10",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

View File

@@ -25,6 +25,11 @@ export function socialLogin(req: any) {
return http.post<T.LoginResp>(`${BASE_URL}/login`, req) return http.post<T.LoginResp>(`${BASE_URL}/login`, req)
} }
/** @desc 三方账号登录 */
export function cardLogin(req: any) {
return http.post<T.LoginResp>(`${BASE_URL}/login`, req)
}
/** @desc 三方账号登录授权 */ /** @desc 三方账号登录授权 */
export function socialAuth(source: string) { export function socialAuth(source: string) {
return http.get<T.SocialAuthAuthorizeResp>(`${BASE_URL}/${source}`) return http.get<T.SocialAuthAuthorizeResp>(`${BASE_URL}/${source}`)

View File

@@ -42,13 +42,14 @@ export interface RouteItem {
} }
/** 认证类型 */ /** 认证类型 */
export type AuthType = 'ACCOUNT' | 'PHONE' | 'EMAIL' | 'SOCIAL' export type AuthType = 'ACCOUNT' | 'PHONE' | 'EMAIL' | 'SOCIAL' | 'CARD'
export const AuthTypeConstants = { export const AuthTypeConstants = {
ACCOUNT: 'ACCOUNT', ACCOUNT: 'ACCOUNT',
PHONE: 'PHONE', PHONE: 'PHONE',
EMAIL: 'EMAIL', EMAIL: 'EMAIL',
SOCIAL: 'SOCIAL', SOCIAL: 'SOCIAL',
CARD: 'CARD',
} as const } as const
/** 基础认证请求接口 */ /** 基础认证请求接口 */
@@ -77,6 +78,16 @@ export interface EmailLoginReq extends AuthReq {
captcha: string captcha: string
} }
/** 刷卡登录请求参数 */
export interface CardLoginReq extends AuthReq {
cardNumber: string
}
/** 邮箱登录请求参数 */
export interface CardLoginReq extends AuthReq {
card: string
}
/** 登录响应类型 */ /** 登录响应类型 */
export interface LoginResp { export interface LoginResp {
token: string token: string

View File

@@ -1,12 +1,12 @@
import http from '@/utils/http' import http from '@/utils/http'
const BASE_URL = '/admin/meterialInfo' const BASE_URL = '/admin/materialInfo'
export interface MeterialInfoResp { export interface MaterialInfoResp {
materialName: string materialName: string
encoding: string encoding: string
unitWeight: string unitWeight: string
maxWeight: string materialSpec: string
photoUrl: string photoUrl: string
createUser: string createUser: string
createTime: string createTime: string
@@ -19,16 +19,33 @@ export interface MaterialInfoQuery {
encoding: string | undefined encoding: string | undefined
sort: Array<string> sort: Array<string>
} }
/* 物料信息导入结果类型 */
export interface MaterialImportResp {
importKey: string
totalRows: number
validRows: number
duplicateNameRows: number
duplicateCodeRows: number
}
export interface MaterialInfoPageQuery extends MaterialInfoQuery, PageQuery {} export interface MaterialInfoPageQuery extends MaterialInfoQuery, PageQuery {}
/** @desc 查询物料信息列表 */ /** @desc 查询物料信息列表 */
export function listMaterialInfo(query: MaterialInfoPageQuery) { export function listMaterialInfo(query: MaterialInfoPageQuery) {
return http.get<PageRes<MeterialInfoResp[]>>(`${BASE_URL}`, query) return http.get<PageRes<MaterialInfoResp[]>>(`${BASE_URL}`, query)
}
interface MaterialInfoDetailResp {
}
/** @desc 下载物料信息导入模板 */
export function downloadMaterialInfoImportTemplate() {
return http.download(`${BASE_URL}/import/template`)
} }
/** @desc 查询物料信息详情 */ /** @desc 查询物料信息详情 */
export function getMaterialInfo(id: string) { export function getMaterialInfo(id: string) {
return http.get<MeterialInfoDetailResp>(`${BASE_URL}/${id}`) return http.get<MaterialInfoDetailResp>(`${BASE_URL}/${id}`)
} }
/** @desc 新增物料信息 */ /** @desc 新增物料信息 */
@@ -47,6 +64,26 @@ export function deleteMaterialInfo(id: string) {
} }
/** @desc 导出物料信息 */ /** @desc 导出物料信息 */
export function exportMaterialInfo(query: MeterialInfoQuery) { export function exportMaterialInfo(query: MaterialInfoQuery) {
return http.download(`${BASE_URL}/export`, query) return http.download(`${BASE_URL}/export`, query)
} }
/** @desc 解析物料信息导入数据 */
export function parseImportMaterial(data: FormData) {
return http.post(`${BASE_URL}/import/parse`, data)
}
/** @desc 导入物料信息 */
export function importMaterial(data: any) {
return http.post(`${BASE_URL}/import`, data)
}
/** @desc 物料照片批量导入数据 */
export function uploadMaterialPhotos(data: FormData) {
return http.post(`${BASE_URL}/import/uploadMaterialPhotos`, data)
}
/** @desc 物料照片抓取数据 */
export function catchPhoto(data: FormData) {
return http.post(`${BASE_URL}/import/catch`, data)
}

View File

@@ -4,21 +4,21 @@ const BASE_URL = '/weighManage/material'
export interface WeighManageResp { export interface WeighManageResp {
id: string id: string
materialCode: string encoding: string
materialName: string materialName: string
materialSpec: string materialSpec: string
weight: number unitWeight: number
imageUrl: string photoUrl: string
matchResult: string matchResult: string
} }
export interface WeighManageQuery { export interface WeighManageQuery {
materialCode: string encoding: string
} }
/** @desc 查询物料信息 */ /** @desc 查询物料信息 */
export function getMaterialDetail(query: WeighManageQuery) { export function getMaterialDetail(code: string) {
return http.get<WeighManageResp>(`${BASE_URL}/detail`, query) return http.get<WeighManageResp>(`/admin/materialInfo/code/${code}`)
} }
/** @desc 新增人员管理 */ /** @desc 新增人员管理 */

View File

@@ -0,0 +1,67 @@
import http from '@/utils/http'
const BASE_URL = '/weighManage/workOrder'
export interface WorkOrderResp {
id: string
title: string
orderNo: string
materialName: string
encoding: string
unitWeight: string
materialSpec: string
photoUrl: string
totalWeight: string
totalCount: string
createUserString: string
updateUserString: string
matchResult: string
}
export interface WorkOrderInfoResp {
id: string
workOrderId: string
materialId: string
weightTime: string
quantity: string
weight: string
imgUrl: string
calculatedWeight: string
}
export interface WorkOrderQuery {
orderNo: string | undefined
materialName: string | undefined
encoding: string | undefined
userName: string | undefined
carNo: string | undefined
startDate: string | undefined
endDate: string | undefined
sort: Array<string>
}
export interface WorkOrderPageQuery extends WorkOrderQuery, PageQuery {}
/** @desc 查询工作订单列表 */
export function listWorkOrder(query: WorkOrderPageQuery) {
return http.get<PageRes<WorkOrderResp[]>>(`${BASE_URL}`, query)
}
/** @desc 查询工作订单详情 */
export function getWorkOrder(id: string) {
return http.get<Array<WorkOrderInfoResp>>(`${BASE_URL}/${id}`)
}
/** @desc 新增工作订单 */
export function addWorkOrder(data: any) {
return http.post(`${BASE_URL}`, data)
}
/** @desc 删除工作订单 */
export function deleteWorkOrder(ids: string | Array<string>) {
return http.del(`${BASE_URL}/${ids}`)
}
/** @desc 导出工作订单 */
export function exportWorkOrder(query: WorkOrderQuery) {
return http.download(`${BASE_URL}/export`, query)
}

View File

@@ -4,10 +4,12 @@ import { resetRouter } from '@/router'
import { import {
type AccountLoginReq, type AccountLoginReq,
AuthTypeConstants, AuthTypeConstants,
type CardLoginReq,
type EmailLoginReq, type EmailLoginReq,
type PhoneLoginReq, type PhoneLoginReq,
type UserInfo, type UserInfo,
accountLogin as accountLoginApi, accountLogin as accountLoginApi,
cardLogin as cardLoginApi,
emailLogin as emailLoginApi, emailLogin as emailLoginApi,
getUserInfo as getUserInfoApi, getUserInfo as getUserInfoApi,
logout as logoutApi, logout as logoutApi,
@@ -70,6 +72,13 @@ const storeSetup = () => {
token.value = res.data.token token.value = res.data.token
} }
// 刷卡登录
const cardLogin = async (req: CardLoginReq) => {
const res = await cardLoginApi({ ...req, clientId: import.meta.env.VITE_CLIENT_ID, authType: AuthTypeConstants.CARD })
setToken(res.data.token)
token.value = res.data.token
}
// 三方账号登录 // 三方账号登录
const socialLogin = async (source: string, req: any) => { const socialLogin = async (source: string, req: any) => {
const res = await socialLoginApi({ ...req, source, clientId: import.meta.env.VITE_CLIENT_ID, authType: AuthTypeConstants.SOCIAL }) const res = await socialLoginApi({ ...req, source, clientId: import.meta.env.VITE_CLIENT_ID, authType: AuthTypeConstants.SOCIAL })
@@ -120,6 +129,7 @@ const storeSetup = () => {
accountLogin, accountLogin,
emailLogin, emailLogin,
phoneLogin, phoneLogin,
cardLogin,
socialLogin, socialLogin,
logout, logout,
logoutCallBack, logoutCallBack,

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
ACheckbox: typeof import('@arco-design/web-vue')['Checkbox'] ACheckbox: typeof import('@arco-design/web-vue')['Checkbox']
ACol: typeof import('@arco-design/web-vue')['Col'] ACol: typeof import('@arco-design/web-vue')['Col']
AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider'] AConfigProvider: typeof import('@arco-design/web-vue')['ConfigProvider']
ADatePicker: typeof import('@arco-design/web-vue')['DatePicker']
ADescriptions: typeof import('@arco-design/web-vue')['Descriptions'] ADescriptions: typeof import('@arco-design/web-vue')['Descriptions']
ADescriptionsItem: typeof import('@arco-design/web-vue')['DescriptionsItem'] ADescriptionsItem: typeof import('@arco-design/web-vue')['DescriptionsItem']
ADivider: typeof import('@arco-design/web-vue')['Divider'] ADivider: typeof import('@arco-design/web-vue')['Divider']
@@ -28,7 +29,9 @@ declare module 'vue' {
AIcon: typeof import('@arco-design/web-vue')['Icon'] AIcon: typeof import('@arco-design/web-vue')['Icon']
AImage: typeof import('@arco-design/web-vue')['Image'] AImage: typeof import('@arco-design/web-vue')['Image']
AInput: typeof import('@arco-design/web-vue')['Input'] AInput: typeof import('@arco-design/web-vue')['Input']
AInputNumber: typeof import('@arco-design/web-vue')['InputNumber']
AInputPassword: typeof import('@arco-design/web-vue')['InputPassword'] AInputPassword: typeof import('@arco-design/web-vue')['InputPassword']
AInputSearch: typeof import('@arco-design/web-vue')['InputSearch']
ALayout: typeof import('@arco-design/web-vue')['Layout'] ALayout: typeof import('@arco-design/web-vue')['Layout']
ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader'] ALayoutHeader: typeof import('@arco-design/web-vue')['LayoutHeader']
ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider'] ALayoutSider: typeof import('@arco-design/web-vue')['LayoutSider']
@@ -36,17 +39,23 @@ declare module 'vue' {
AMenu: typeof import('@arco-design/web-vue')['Menu'] AMenu: typeof import('@arco-design/web-vue')['Menu']
AMenuItem: typeof import('@arco-design/web-vue')['MenuItem'] AMenuItem: typeof import('@arco-design/web-vue')['MenuItem']
AModal: typeof import('@arco-design/web-vue')['Modal'] AModal: typeof import('@arco-design/web-vue')['Modal']
APagination: typeof import('@arco-design/web-vue')['Pagination']
APopover: typeof import('@arco-design/web-vue')['Popover'] APopover: typeof import('@arco-design/web-vue')['Popover']
ARadio: typeof import('@arco-design/web-vue')['Radio']
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
ARow: typeof import('@arco-design/web-vue')['Row'] ARow: typeof import('@arco-design/web-vue')['Row']
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar'] AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
ASelect: typeof import('@arco-design/web-vue')['Select'] ASelect: typeof import('@arco-design/web-vue')['Select']
ASpace: typeof import('@arco-design/web-vue')['Space'] ASpace: typeof import('@arco-design/web-vue')['Space']
ASpin: typeof import('@arco-design/web-vue')['Spin']
ASubMenu: typeof import('@arco-design/web-vue')['SubMenu'] ASubMenu: typeof import('@arco-design/web-vue')['SubMenu']
ASwitch: typeof import('@arco-design/web-vue')['Switch'] ASwitch: typeof import('@arco-design/web-vue')['Switch']
ATable: typeof import('@arco-design/web-vue')['Table'] ATable: typeof import('@arco-design/web-vue')['Table']
ATabPane: typeof import('@arco-design/web-vue')['TabPane'] ATabPane: typeof import('@arco-design/web-vue')['TabPane']
ATabs: typeof import('@arco-design/web-vue')['Tabs'] ATabs: typeof import('@arco-design/web-vue')['Tabs']
ATag: typeof import('@arco-design/web-vue')['Tag']
ATooltip: typeof import('@arco-design/web-vue')['Tooltip'] ATooltip: typeof import('@arco-design/web-vue')['Tooltip']
ATreeSelect: typeof import('@arco-design/web-vue')['TreeSelect']
Avatar: typeof import('./../components/Avatar/index.vue')['default'] Avatar: typeof import('./../components/Avatar/index.vue')['default']
AWatermark: typeof import('@arco-design/web-vue')['Watermark'] AWatermark: typeof import('@arco-design/web-vue')['Watermark']
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default'] Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']

View File

@@ -5,9 +5,10 @@
<div class="guide-section full-background"> <div class="guide-section full-background">
<div class="guide-content full-height"> <div class="guide-content full-height">
<!-- 标题 --> <!-- 标题 -->
<!-- 注意标题现在不再强制占满全宽而是适应内容 -->
<h1 class="page-title">物料领取流程</h1> <h1 class="page-title">物料领取流程</h1>
<!-- 图片和按钮卡片 - 确保在任何比例下完全显示 --> <!-- 图片和按钮卡片 -->
<div class="guide-card centered"> <div class="guide-card centered">
<!-- 图片展示区 --> <!-- 图片展示区 -->
<div class="image-wrapper"> <div class="image-wrapper">
@@ -36,7 +37,7 @@ defineOptions({ name: 'Analysis' })
const router = useRouter() const router = useRouter()
const handleStart = () => { const handleStart = () => {
router.push('') router.push('/weightManage')
} }
</script> </script>
@@ -66,10 +67,10 @@ const handleStart = () => {
height: 100%; height: 100%;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: flex-start; // 整体靠左,由子元素 margin 控制位置
align-items: center; align-items: center;
background: #f5f7fa; background: #f5f7fa;
padding: 10px; // 减小内边距 padding: 10px;
box-sizing: border-box; box-sizing: border-box;
.guide-content.full-height { .guide-content.full-height {
@@ -79,10 +80,13 @@ const handleStart = () => {
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; // 【关键】让子元素(标题和卡片)从左侧开始排列,而不是拉伸
.page-title { .page-title {
text-align: center; // 【修改】让标题宽度适应内容,以便通过 margin 控制其整体位置
font-size: 42px; // 稍微减小字体 width: fit-content;
text-align: center; // 文字本身在标题块内居中
font-size: 42px;
font-weight: 800; font-weight: 800;
color: #1e293b; color: #1e293b;
margin-bottom: 15px; margin-bottom: 15px;
@@ -92,13 +96,17 @@ const handleStart = () => {
flex-shrink: 0; flex-shrink: 0;
padding: 0 10px; padding: 0 10px;
// 【关键修改】应用与卡片相同的左侧偏移,确保标题在卡片正上方
margin-left: 30%; // 必须与 .guide-card 的 margin-left 一致
margin-right: auto;
&::after { &::after {
content: ''; content: '';
display: block; display: block;
width: 80px; // 减小下划线宽度 width: 80px;
height: 4px; height: 4px;
background: linear-gradient(90deg, #165dff, #6aa1ff); background: linear-gradient(90deg, #165dff, #6aa1ff);
margin: 10px auto 0; margin: 10px auto 0; // 下划线在标题块内居中
border-radius: 2px; border-radius: 2px;
} }
} }
@@ -108,22 +116,23 @@ const handleStart = () => {
.guide-card { .guide-card {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 40px;
gap: 40px; // 减小间距
background: white; background: white;
border-radius: 30px; // 减小圆角 border-radius: 30px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 20px 30px; // 减小内边距 padding: 20px 30px;
flex-wrap: wrap; flex-wrap: wrap;
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
min-height: 400px; // 减小最小高度 min-height: 400px;
width: auto; width: auto;
max-width: 90vw; // 使用视口宽度的90%,确保有边距 max-width: 90vw;
margin: 0 auto;
// 【核心修改】设置左边距实现左偏移
margin-left: 10%; // 与标题保持一致
margin-right: auto;
&.centered { &.centered {
margin-left: auto; // 移除原本的居中逻辑
margin-right: auto;
} }
.image-wrapper { .image-wrapper {
@@ -131,16 +140,16 @@ const handleStart = () => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-width: 300px; // 减小最小宽度 min-width: 300px;
max-width: 600px; // 减小最大宽度 max-width: 600px;
height: 100%; height: 100%;
.guide-image.enlarged { .guide-image.enlarged {
max-width: 100%; max-width: 100%;
width: auto; width: auto;
height: auto; height: auto;
max-height: 50vh; // 减小最大高度 max-height: 50vh;
min-height: 250px; // 减小最小高度 min-height: 250px;
object-fit: contain; object-fit: contain;
border-radius: 16px; border-radius: 16px;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.08)); filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.08));
@@ -159,13 +168,13 @@ const handleStart = () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
min-width: 180px; // 减小最小宽度 min-width: 180px;
.button-hint { .button-hint {
font-size: 14px; // 减小字体 font-size: 14px;
color: #475569; color: #475569;
background: #f1f5f9; background: #f1f5f9;
padding: 6px 18px; // 减小内边距 padding: 6px 18px;
border-radius: 24px; border-radius: 24px;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
@@ -180,9 +189,9 @@ const handleStart = () => {
} }
.start-button.enlarged { .start-button.enlarged {
min-width: 160px; // 减小按钮宽度 min-width: 160px;
height: 56px; // 减小按钮高度 height: 56px;
font-size: 22px; // 减小字体 font-size: 22px;
border-radius: 40px; border-radius: 40px;
box-shadow: 0 10px 20px rgba(22, 93, 255, 0.2); box-shadow: 0 10px 20px rgba(22, 93, 255, 0.2);
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -209,12 +218,11 @@ const handleStart = () => {
// 针对不同屏幕尺寸的精细调整 // 针对不同屏幕尺寸的精细调整
@media (min-width: 1920px) { @media (min-width: 1920px) {
.guide-card { .guide-card {
max-width: 1600px; // 超大屏幕限制最大宽度 max-width: 1600px;
padding: 30px 50px; padding: 30px 50px;
.image-wrapper {
max-width: 800px;
} }
.guide-content.full-height .page-title {
// 大屏幕保持同步
} }
} }
@@ -222,15 +230,7 @@ const handleStart = () => {
.guide-card { .guide-card {
gap: 30px; gap: 30px;
padding: 20px 25px; padding: 20px 25px;
// 如果需要,可以在这里微调 margin-left记得同时微调 title
.image-wrapper {
min-width: 280px;
max-width: 500px;
.guide-image.enlarged {
max-height: 45vh;
}
}
} }
} }
@@ -242,43 +242,26 @@ const handleStart = () => {
.guide-card { .guide-card {
gap: 25px; gap: 25px;
padding: 15px 20px; padding: 15px 20px;
max-width: 95vw; // 在小屏幕上使用更宽的百分比 max-width: 95vw;
// 平板端如果需要调整偏移,请同时调整下方 title 的 margin
.image-wrapper {
min-width: 250px;
max-width: 450px;
.guide-image.enlarged {
max-height: 40vh;
min-height: 200px;
}
}
.button-wrapper {
min-width: 160px;
.button-hint {
font-size: 13px;
padding: 5px 15px;
}
.start-button.enlarged {
min-width: 150px;
height: 50px;
font-size: 20px;
}
}
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.guide-section.full-background { .guide-section.full-background {
padding: 5px; padding: 5px;
justify-content: center; // 移动端恢复整体居中
.guide-content.full-height { .guide-content.full-height {
align-items: center; // 移动端子元素也居中
.page-title { .page-title {
font-size: 28px; font-size: 28px;
margin-bottom: 10px; margin-bottom: 10px;
// 【重要】移动端重置 margin恢复自动居中
margin-left: auto;
margin-right: auto;
width: fit-content; // 保持适应内容
&::after { &::after {
width: 60px; width: 60px;
@@ -294,7 +277,11 @@ const handleStart = () => {
padding: 20px 15px; padding: 20px 15px;
gap: 20px; gap: 20px;
min-height: auto; min-height: auto;
max-width: 98vw; // 移动端几乎占满宽度 max-width: 98vw;
// 【重要】移动端强制居中
margin-left: auto;
margin-right: auto;
.image-wrapper { .image-wrapper {
min-width: auto; min-width: auto;
@@ -334,14 +321,12 @@ const handleStart = () => {
.guide-section.full-background { .guide-section.full-background {
.guide-card { .guide-card {
@media (min-width: 1025px) { @media (min-width: 1025px) {
margin-left: 5%; // 有侧边栏时左移 // 如果有侧边栏,确保标题和卡片偏移量依然一致
margin-right: auto;
} }
} }
} }
} }
// 确保父容器占满且无滚动问题
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -359,22 +344,22 @@ html, body {
overflow-y: auto; overflow-y: auto;
} }
// 针对100%缩放比例的特殊处理 // 针对特定分辨率的微调(可选)
@media screen and (min-width: 1280px) and (max-width: 1366px) { @media screen and (min-width: 1280px) and (max-width: 1366px) {
.guide-card { .guide-card {
max-width: 85vw; // 在常见笔记本屏幕上使用85%宽度 max-width: 85vw;
} }
} }
@media screen and (min-width: 1367px) and (max-width: 1440px) { @media screen and (min-width: 1367px) and (max-width: 1440px) {
.guide-card { .guide-card {
max-width: 80vw; // 在1440p屏幕上使用80%宽度 max-width: 80vw;
} }
} }
@media screen and (min-width: 1441px) and (max-width: 1680px) { @media screen and (min-width: 1441px) and (max-width: 1680px) {
.guide-card { .guide-card {
max-width: 75vw; // 在更大屏幕上使用75%宽度 max-width: 75vw;
} }
} }
</style> </style>

View File

@@ -0,0 +1,118 @@
<template>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col-style="{ display: 'none' }"
:wrapper-col-style="{ flex: 1 }"
size="large"
@submit="handleLogin"
>
<a-form-item field="cardNumber" hide-label>
<a-input v-model="form.cardNumber" placeholder="请刷卡" allow-clear @keyup.enter="handleLogin" />
</a-form-item>
<a-form-item>
<a-space direction="vertical" fill class="w-full">
<a-button type="primary" :loading="loading" html-type="submit" size="large" long>立即登录</a-button>
</a-space>
</a-form-item>
</a-form>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { type FormInstance, Message } from '@arco-design/web-vue'
import { useTabsStore, useUserStore } from '@/stores'
const formRef = ref<FormInstance>()
const form = reactive({
cardNumber: '',
})
const rules: FormInstance['rules'] = {
cardNumber: [
{ required: true, message: '请刷卡或输入卡号' },
],
}
const userStore = useUserStore()
const tabsStore = useTabsStore()
const router = useRouter()
const loading = ref(false)
// 登录
const handleLogin = async () => {
const isInvalid = await formRef.value?.validate()
if (isInvalid) return
try {
loading.value = true
// 调用后端接口校验卡号
await userStore.cardLogin(form)
tabsStore.reset()
const { redirect, ...othersQuery } = router.currentRoute.value.query
await router.push({
path: (redirect as string) || '/',
query: {
...othersQuery,
},
})
Message.success('欢迎使用')
} catch (error) {
// 登录失败处理
} finally {
loading.value = false
}
}
// 监听键盘输入,实现自动识别卡号
onMounted(() => {
// 监听全局键盘事件,用于识别刷卡输入
document.addEventListener('keydown', (e) => {
// 刷卡器通常会快速输入卡号并以Enter键结束
if (e.key === 'Enter') {
// 遇到Enter键尝试登录
if (form.cardNumber.trim()) {
handleLogin()
}
} else if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
// 只捕获单个字符的按键,忽略控制键和功能键
form.cardNumber += e.key
} else if (e.key === 'Backspace') {
// 处理退格键
form.cardNumber = form.cardNumber.slice(0, -1)
}
})
})
</script>
<style scoped lang="scss">
.arco-input-wrapper,
:deep(.arco-select-view-single) {
height: 40px;
border-radius: 4px;
font-size: 13px;
}
.arco-input-wrapper.arco-input-error {
background-color: rgb(var(--danger-1));
border-color: rgb(var(--danger-3));
}
.arco-input-wrapper.arco-input-error:hover {
background-color: rgb(var(--danger-1));
border-color: rgb(var(--danger-6));
}
.arco-input-wrapper :deep(.arco-input) {
font-size: 13px;
color: var(--color-text-1);
}
.arco-input-wrapper:hover {
border-color: rgb(var(--arcoblue-6));
}
.btn {
height: 40px;
}
</style>

View File

@@ -15,28 +15,14 @@
<a-col :xs="24" :sm="12" :md="11"> <a-col :xs="24" :sm="12" :md="11">
<div class="login-right"> <div class="login-right">
<h3 v-if="isEmailLogin" class="login-right__title">邮箱登录</h3> <h3 v-if="isEmailLogin" class="login-right__title">邮箱登录</h3>
<EmailLogin v-if="isEmailLogin" />
<a-tabs v-else v-model:activeKey="activeTab" class="login-right__form"> <a-tabs v-else v-model:activeKey="activeTab" class="login-right__form">
<a-tab-pane key="1" title="账号登录"> <a-tab-pane key="1" title="账号登录">
<component :is="AccountLogin" v-if="activeTab === '1'" /> <component :is="AccountLogin" v-if="activeTab === '1'" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="2" title="手机号登录"> <a-tab-pane key="2" title="刷卡">
<component :is="PhoneLogin" v-if="activeTab === '2'" /> <component :is="CardLogin" v-if="activeTab === '2'" />
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
<!-- <div class="login-right__oauth">
<a-divider orientation="center">其他登录方式</a-divider>
<div class="list">
<div v-if="isEmailLogin" class="mode item" @click="toggleLoginMode"><icon-user /> 账号/手机号登录</div>
<div v-else class="mode item" @click="toggleLoginMode"><icon-email /> 邮箱登录</div>
<a class="item" title="使用 Gitee 账号登录" @click="onOauth('gitee')">
<GiSvgIcon name="gitee" :size="24" />
</a>
<a class="item" title="使用 GitHub 账号登录" @click="onOauth('github')">
<GiSvgIcon name="github" :size="24" />
</a>
</div>
</div>-->
</div> </div>
</a-col> </a-col>
</a-row> </a-row>
@@ -51,51 +37,13 @@
<Background /> <Background />
</div> </div>
<div v-else class="login h5">
<div class="login-logo">
<img v-if="logo" :src="logo" alt="logo" />
<img v-else src="/logo.svg" alt="logo" />
<span>{{ title }}</span>
</div>
<a-row align="stretch" class="login-box">
<a-col :xs="24" :sm="12" :md="11">
<div class="login-right">
<h3 v-if="isEmailLogin" class="login-right__title">邮箱登录</h3>
<EmailLogin v-if="isEmailLogin" />
<a-tabs v-else v-model:activeKey="activeTab" class="login-right__form">
<a-tab-pane key="1" title="账号登录">
<component :is="AccountLogin" v-if="activeTab === '1'" />
</a-tab-pane>
<a-tab-pane key="2" title="手机号登录">
<component :is="PhoneLogin" v-if="activeTab === '2'" />
</a-tab-pane>
</a-tabs>
</div>
</a-col>
</a-row>
<div class="login-right__oauth">
<a-divider orientation="center">其他登录方式</a-divider>
<div class="list">
<div v-if="isEmailLogin" class="mode item" @click="toggleLoginMode"><icon-user /> 账号/手机号登录</div>
<div v-else class="mode item" @click="toggleLoginMode"><icon-email /> 邮箱登录</div>
<a class="item" title="使用 Gitee 账号登录" @click="onOauth('gitee')">
<GiSvgIcon name="gitee" :size="24" />
</a>
<a class="item" title="使用 GitHub 账号登录" @click="onOauth('github')">
<GiSvgIcon name="github" :size="24" />
</a>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import Background from './components/background/index.vue' import Background from './components/background/index.vue'
import AccountLogin from './components/account/index.vue' import AccountLogin from './components/account/index.vue'
import PhoneLogin from './components/phone/index.vue' import CardLogin from './components/card/index.vue'
import EmailLogin from './components/email/index.vue'
import { socialAuth } from '@/apis/auth'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { useDevice } from '@/hooks' import { useDevice } from '@/hooks'
@@ -109,16 +57,6 @@ const logo = computed(() => appStore.getLogo())
const isEmailLogin = ref(false) const isEmailLogin = ref(false)
const activeTab = ref('1') const activeTab = ref('1')
// 切换登录模式
const toggleLoginMode = () => {
isEmailLogin.value = !isEmailLogin.value
}
// 第三方登录授权
const onOauth = async (source: string) => {
const { data } = await socialAuth(source)
window.location.href = data.authorizeUrl
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -58,9 +58,9 @@ const columns: ColumnItem[] = reactive([
span: 24, span: 24,
}, },
{ {
label: '物料单次可称量最大重量(kg)', label: '物料规格',
field: 'maxWeight', field: 'materialSpec',
type: 'input-number', type: 'input',
span: 24, span: 24,
}, },
{ {

View File

@@ -0,0 +1,201 @@
<template>
<a-drawer
v-model:visible="visible"
title="导入物料信息"
:mask-closable="false"
:esc-to-close="false"
:width="width >= 600 ? 600 : '100%'"
ok-text="确认导入"
cancel-text="取消导入"
@before-ok="save"
@close="reset"
>
<a-form ref="formRef" :model="form" size="large" auto-label-width>
<a-alert v-if="!form.disabled" style="margin-bottom: 15px">
请按照模板要求填写数据填写完毕后请先上传并进行解析
<template #action>
<a-link @click="downloadTemplate">
<template #icon><GiSvgIcon name="file-excel" :size="16" /></template>
<template #default>下载模板</template>
</a-link>
</template>
</a-alert>
<fieldset>
<legend>1.解析数据</legend>
<div class="file-box">
<a-upload
draggable
:custom-request="handleUpload"
:limit="1"
:show-retry-butto="false"
:show-cancel-button="false" tip="仅支持xls、xlsx格式"
:file-list="uploadFile"
accept=".xls, .xlsx, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/>
</div>
<div v-if="dataResult.importKey">
<div class="file-box">
<a-space size="large">
<a-statistic title="总计行数" :value="dataResult.totalRows" />
<a-statistic title="正常行数" :value="dataResult.validRows" />
</a-space>
</div>
<div class="file-box">
<a-space size="large">
<a-statistic title="已存在物料名" :value="dataResult.duplicateNameRows" />
<a-statistic title="已存在编码" :value="dataResult.duplicateCodeRows" />
</a-space>
</div>
</div>
</fieldset>
<fieldset>
<legend>2.导入策略</legend>
<a-form-item label="物料名已存在" field="duplicateName">
<a-radio-group v-model="form.duplicateName" type="button">
<a-radio :value="1">跳过该行</a-radio>
<a-radio :value="2">修改数据</a-radio>
<a-radio :value="3">停止导入</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="编码已存在" field="duplicateCode">
<a-radio-group v-model="form.duplicateCode" type="button">
<a-radio :value="1">跳过该行</a-radio>
<a-radio :value="2">修改数据</a-radio>
<a-radio :value="3">停止导入</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="默认状态" field="defaultStatus">
<a-switch
v-model="form.defaultStatus"
:checked-value="1"
:unchecked-value="2"
checked-text="启用"
unchecked-text="禁用"
type="round"
/>
</a-form-item>
</fieldset>
</a-form>
</a-drawer>
</template>
<script setup lang="ts">
import { type FormInstance, Message, type RequestOption } from '@arco-design/web-vue'
import { useWindowSize } from '@vueuse/core'
import {
type MaterialImportResp,
downloadMaterialInfoImportTemplate,
importMaterial,
parseImportMaterial,
} from '@/apis/material/materialInfo'
import { useDownload, useResetReactive } from '@/hooks'
const emit = defineEmits<{
(e: 'save-success'): void
}>()
const { width } = useWindowSize()
const visible = ref(false)
const formRef = ref<FormInstance>()
const uploadFile = ref([])
const [form, resetForm] = useResetReactive({
importKey: '',
errorPolicy: 1,
duplicateName: 1,
duplicateCode: 1,
defaultStatus: 1,
})
const dataResult = ref<MaterialImportResp>({
importKey: '',
totalRows: 0,
validRows: 0,
duplicateNameRows: 0,
duplicateCodeRows: 0,
})
// 重置
const reset = () => {
formRef.value?.resetFields()
dataResult.value.importKey = ''
uploadFile.value = []
resetForm()
}
// 下载模板
const downloadTemplate = () => {
useDownload(() => downloadMaterialInfoImportTemplate())
}
// 上传解析导入数据
const handleUpload = (options: RequestOption) => {
const controller = new AbortController();
(async function requestWrap() {
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
onProgress(20)
const formData = new FormData()
formData.append(name as string, fileItem.file as Blob)
try {
const res = await parseImportMaterial(formData)
dataResult.value = res.data
Message.success('上传解析成功')
onSuccess(res)
} catch (error) {
onError(error)
}
})()
return {
abort() {
controller.abort()
},
}
}
// 执行导入
const save = async () => {
try {
if (!dataResult.value.importKey) {
Message.warning('请先上传文件,解析导入数据')
return false
}
form.importKey = dataResult.value.importKey
const res = await importMaterial(form)
Message.success(`导入成功! 新增${res.data.insertRows}, 修改${res.data.updateRows},总计处理${res.data.totalRows}`)
emit('save-success')
return true
} catch (error) {
return false
}
}
// 打开
const onOpen = () => {
reset()
visible.value = true
}
defineExpose({ onOpen })
</script>
<style scoped lang="scss">
fieldset {
padding: 15px 15px 0 15px;
margin-bottom: 15px;
border: 1px solid var(--color-neutral-3);
border-radius: 3px;
}
fieldset legend {
color: rgb(var(--gray-10));
padding: 2px 5px 2px 5px;
border: 1px solid var(--color-neutral-3);
border-radius: 3px;
}
.file-box {
margin-bottom: 20px;
margin-left: 10px;
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<a-drawer
v-model:visible="visible"
title="批量导入物料照片"
:mask-closable="false"
:esc-to-close="false"
:width="width >= 600 ? 600 : '100%'"
ok-text="确认导入"
cancel-text="取消导入"
@before-ok="save"
@close="reset"
>
<a-form ref="formRef" :model="form" size="large" auto-label-width>
<fieldset>
<legend>zip文件上传</legend>
<div class="file-box">
<a-upload
draggable
:custom-request="handleUpload"
:limit="1"
:show-retry-button="false"
:show-cancel-button="false"
tip="仅支持zip格式"
:file-list="uploadFile"
accept=".zip"
/>
</div>
</fieldset>
</a-form>
</a-drawer>
</template>
<script setup lang="ts">
import { type FormInstance, Message, type RequestOption } from '@arco-design/web-vue'
import { useWindowSize } from '@vueuse/core'
import { uploadMaterialPhotos } from '@/apis/material/materialInfo'
import { useResetReactive } from '@/hooks'
const emit = defineEmits<{
(e: 'save-success'): void
}>()
const { width } = useWindowSize()
const visible = ref(false)
const formRef = ref<FormInstance>()
const uploadFile = ref([])
// 新增:保存上传的文件对象
const uploadFileObj = ref<File | null>(null)
// 移除不必要的表单字段,只保留空对象即可
const [form, resetForm] = useResetReactive({})
// 重置
const reset = () => {
formRef.value?.resetFields()
uploadFile.value = []
uploadFileObj.value = null // 重置文件对象
resetForm()
}
// 修改上传逻辑:仅保存文件对象,不做解析
const handleUpload = (options: RequestOption) => {
const { onProgress, onError, onSuccess, fileItem } = options
onProgress(100) // 直接标记上传完成(仅前端显示)
// 保存文件对象供后续提交使用
uploadFileObj.value = fileItem.file as File
uploadFile.value = [fileItem] // 更新文件列表显示
Message.success('文件已选择,点击确认导入即可上传')
onSuccess({}) // 通知上传组件完成
return {
abort() {
// 空实现取消时会走reset逻辑
},
}
}
// 执行导入:直接上传文件到后端
const save = async () => {
try {
// 校验是否选择了文件
if (!uploadFileObj.value) {
Message.warning('请先选择要上传的ZIP文件')
return false
}
// 构建FormData
const formData = new FormData()
formData.append('zipFile', uploadFileObj.value) // 后端接收参数名保持为zipFile
// 调用后端照片批量导入接口
await uploadMaterialPhotos(formData)
Message.success('物料照片批量导入成功!')
emit('save-success') // 通知父组件刷新列表
visible.value = false // 关闭抽屉
return true
} catch (error) {
Message.error('物料照片导入失败,请重试!')
return false
}
}
// 打开抽屉
const onOpen = () => {
reset()
visible.value = true
}
defineExpose({ onOpen })
</script>
<style scoped lang="scss">
fieldset {
padding: 15px 15px 0 15px;
margin-bottom: 15px;
border: 1px solid var(--color-neutral-3);
border-radius: 3px;
}
fieldset legend {
color: rgb(var(--gray-10));
padding: 2px 5px 2px 5px;
border: 1px solid var(--color-neutral-3);
border-radius: 3px;
}
.file-box {
margin-bottom: 20px;
margin-left: 10px;
}
</style>

View File

@@ -12,6 +12,7 @@
:disabled-column-keys="['name']" :disabled-column-keys="['name']"
@refresh="search" @refresh="search"
> >
<!-- toolbar 部分保持不变 -->
<template #toolbar-left> <template #toolbar-left>
<a-input-search v-model="queryForm.materialName" placeholder="请输入物料名称" allow-clear @search="search" /> <a-input-search v-model="queryForm.materialName" placeholder="请输入物料名称" allow-clear @search="search" />
<a-input-search v-model="queryForm.encoding" placeholder="请输入物料编码" allow-clear @search="search" /> <a-input-search v-model="queryForm.encoding" placeholder="请输入物料编码" allow-clear @search="search" />
@@ -25,24 +26,44 @@
<template #icon><icon-plus /></template> <template #icon><icon-plus /></template>
<template #default>新增</template> <template #default>新增</template>
</a-button> </a-button>
<a-button v-permission="['admin:materialInfo:import']" @click="onImport">
<template #icon><icon-upload /></template>
<template #default>导入</template>
</a-button>
<a-button v-permission="['admin:materialInfo:export']" @click="onExport"> <a-button v-permission="['admin:materialInfo:export']" @click="onExport">
<template #icon><icon-download /></template> <template #icon><icon-download /></template>
<template #default>导出</template> <template #default>导出</template>
</a-button> </a-button>
<a-button v-permission="['admin:materialInfo:import']" @click="onPhotosImport">
<template #icon><icon-upload /></template>
<template #default>照片批量导入</template>
</a-button>
</template> </template>
<!-- 物料照片插槽核心新增部分 -->
<!-- 修改点照片列插槽 -->
<template #photoUrl="{ record }"> <template #photoUrl="{ record }">
<div class="photo-container"> <div class="photo-container">
<!-- 1. 正常显示图片有地址 未标记为错误 -->
<img <img
v-if="record.photoUrl" v-if="record.photoUrl && !record.photoLoadError"
:src="record.photoUrl" :src="record.photoUrl"
alt="物料照片" alt="物料照片"
class="material-photo" class="material-photo"
@error="handleImgError($event)" @load="handleImgLoad(record)"
@error="handleImgError(record, $event)"
/> />
<span v-else class="no-photo">暂无照片</span>
<!-- 2. 无地址 -->
<span v-else-if="!record.photoUrl" class="no-photo">暂无照片</span>
<!-- 3. 地址存在但加载失败 -->
<span v-else class="photo-error">
<icon-exclamation-circle-fill class="error-icon" />
照片异常
</span>
</div> </div>
</template> </template>
<template #action="{ record }"> <template #action="{ record }">
<a-space> <a-space>
<a-link v-permission="['admin:materialInfo:update']" title="修改" @click="onUpdate(record)">修改</a-link> <a-link v-permission="['admin:materialInfo:update']" title="修改" @click="onUpdate(record)">修改</a-link>
@@ -60,11 +81,16 @@
</GiTable> </GiTable>
<MaterialInfoAddModal ref="MaterialInfoAddModalRef" @save-success="search" /> <MaterialInfoAddModal ref="MaterialInfoAddModalRef" @save-success="search" />
<MaterialInfoImportDrawer ref="MaterialInfoImportDrawerRef" @save-success="search" />
<PhotosImport ref="PhotosImportRef" @save-success="search"/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// ... 保持原有的 import 不变
import MaterialInfoAddModal from './MaterialInfoAddModal.vue' import MaterialInfoAddModal from './MaterialInfoAddModal.vue'
import MaterialInfoImportDrawer from './MaterialInfoImportDrawer.vue'
import PhotosImport from '@/views/material/PhotosImport.vue';
import { type MaterialInfoQuery, type MaterialInfoResp, deleteMaterialInfo, exportMaterialInfo, listMaterialInfo } from '@/apis/material/materialInfo' import { type MaterialInfoQuery, type MaterialInfoResp, deleteMaterialInfo, exportMaterialInfo, listMaterialInfo } from '@/apis/material/materialInfo'
import type { TableInstanceColumns } from '@/components/GiTable/type' import type { TableInstanceColumns } from '@/components/GiTable/type'
import { useDownload, useTable } from '@/hooks' import { useDownload, useTable } from '@/hooks'
@@ -73,6 +99,12 @@ import has from '@/utils/has'
defineOptions({ name: 'MaterialInfo' }) defineOptions({ name: 'MaterialInfo' })
// 扩展类型定义,允许动态添加 photoLoadError 属性
// 如果 TS 报错,可以在 MaterialInfoResp 接口定义中添加 photoLoadError?: boolean;
interface MaterialInfoRespWithStatus extends MaterialInfoResp {
photoLoadError?: boolean;
}
const queryForm = reactive<MaterialInfoQuery>({ const queryForm = reactive<MaterialInfoQuery>({
materialName: undefined, materialName: undefined,
encoding: undefined, encoding: undefined,
@@ -86,12 +118,12 @@ const {
search, search,
handleDelete, handleDelete,
} = useTable((page) => listMaterialInfo({ ...queryForm, ...page }), { immediate: true }) } = useTable((page) => listMaterialInfo({ ...queryForm, ...page }), { immediate: true })
const columns = ref<TableInstanceColumns[]>([ const columns = ref<TableInstanceColumns[]>([
{ title: '物料名称', dataIndex: 'materialName', slotName: 'materialName' }, { title: '物料名称', dataIndex: 'materialName', slotName: 'materialName' },
{ title: '物料编码', dataIndex: 'encoding', slotName: 'encoding' }, { title: '物料编码', dataIndex: 'encoding', slotName: 'encoding' },
{ title: '物料单位重量(g)', dataIndex: 'unitWeight', slotName: 'unitWeight' }, { title: '物料单位重量(g)', dataIndex: 'unitWeight', slotName: 'unitWeight' },
{ title: '物料单次可称量最大重量(kg)', dataIndex: 'maxWeight', slotName: 'maxWeight' }, { title: '物料规格', dataIndex: 'materialSpec', slotName: 'materialSpec' },
// 可给photoUrl列添加宽度优化显示
{ title: '物料照片', dataIndex: 'photoUrl', slotName: 'photoUrl', width: 120, align: 'center' }, { title: '物料照片', dataIndex: 'photoUrl', slotName: 'photoUrl', width: 120, align: 'center' },
{ title: '创建人', dataIndex: 'createUserString', slotName: 'createUser' }, { title: '创建人', dataIndex: 'createUserString', slotName: 'createUser' },
{ title: '创建时间', dataIndex: 'createTime', slotName: 'createTime' }, { title: '创建时间', dataIndex: 'createTime', slotName: 'createTime' },
@@ -106,14 +138,12 @@ const columns = ref<TableInstanceColumns[]>([
}, },
]) ])
// 重置
const reset = () => { const reset = () => {
queryForm.materialName = undefined queryForm.materialName = undefined
queryForm.encoding = undefined queryForm.encoding = undefined
search() search()
} }
// 删除
const onDelete = (record: MaterialInfoResp) => { const onDelete = (record: MaterialInfoResp) => {
return handleDelete(() => deleteMaterialInfo(record.id), { return handleDelete(() => deleteMaterialInfo(record.id), {
content: `是否确定删除该条数据?`, content: `是否确定删除该条数据?`,
@@ -121,54 +151,79 @@ const onDelete = (record: MaterialInfoResp) => {
}) })
} }
// 导出
const onExport = () => { const onExport = () => {
useDownload(() => exportMaterialInfo(queryForm)) useDownload(() => exportMaterialInfo(queryForm))
} }
// 图片加载失败处理函数(新增) // 【修改点】图片加载处理逻辑
const handleImgError = (e: Event) => { const handleImgLoad = (record: MaterialInfoRespWithStatus) => {
// 替换为你的默认图片地址(建议放在/static目录下 record.photoLoadError = false
const target = e.target as HTMLImageElement }
target.src = '/static/images/default-material.png'
const handleImgError = (record: MaterialInfoRespWithStatus, e: Event) => {
// 标记为加载错误,触发模板渲染 "照片异常"
record.photoLoadError = true
console.warn(`物料照片加载失败: ${record.photoUrl}`)
} }
const MaterialInfoAddModalRef = ref<InstanceType<typeof MaterialInfoAddModal>>() const MaterialInfoAddModalRef = ref<InstanceType<typeof MaterialInfoAddModal>>()
// 新增
const onAdd = () => { const onAdd = () => {
MaterialInfoAddModalRef.value?.onAdd() MaterialInfoAddModalRef.value?.onAdd()
} }
// 修改
const onUpdate = (record: MaterialInfoResp) => { const onUpdate = (record: MaterialInfoResp) => {
MaterialInfoAddModalRef.value?.onUpdate(record.id) MaterialInfoAddModalRef.value?.onUpdate(record.id)
} }
// 详情(补充定义,避免报错) const MaterialInfoImportDrawerRef = ref<InstanceType<typeof MaterialInfoImportDrawer>>()
const onDetail = (record: MaterialInfoResp) => { const onImport = () => {
// 可补充详情逻辑,如打开详情弹窗 MaterialInfoImportDrawerRef.value?.onOpen()
console.log('物料详情', record) }
const PhotosImportRef = ref<InstanceType<typeof PhotosImport>>()
const onPhotosImport = () => {
PhotosImportRef.value?.onOpen()
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
// 物料照片样式(新增)
.photo-container { .photo-container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 80px; height: 80px;
width: 100%;
overflow: hidden;
} }
.material-photo { .material-photo {
width: 80px; width: 100%;
height: 80px; height: 100%;
object-fit: cover; // 保持图片比例,避免拉伸 object-fit: cover;
border-radius: 4px; border-radius: 4px;
transition: opacity 0.3s;
} }
.no-photo { .no-photo {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
text-align: center;
}
// 新增样式
.photo-error {
color: #ff4d4f; // 阿里红/危险色
font-size: 12px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.4;
.error-icon {
font-size: 18px;
margin-bottom: 2px;
}
} }
</style> </style>

View File

@@ -0,0 +1,377 @@
<template>
<div class="gi_page">
<div class="container">
<h2 class="page-title">标签打印</h2>
<!-- 标签参数设置 -->
<div class="form-section">
<a-form :model="formData" layout="vertical">
<div class="form-grid">
<div class="form-grid-item">
<a-form-item label="物料名称">
<a-input v-model="formData.materialName" placeholder="请输入物料名称" :disabled="isFromWorkOrder" />
</a-form-item>
</div>
<div class="form-grid-item">
<a-form-item label="物料编码">
<a-input v-model="formData.encoding" placeholder="请输入物料编码" :disabled="isFromWorkOrder" />
</a-form-item>
</div>
<div class="form-grid-item">
<a-form-item label="工单编号">
<a-input v-model="formData.orderNo" placeholder="请输入工单编号" :disabled="isFromWorkOrder" />
</a-form-item>
</div>
<div class="form-grid-item">
<a-form-item label="生产批次" required>
<a-input v-model="formData.productionBatch" placeholder="请输入生产批次" />
</a-form-item>
</div>
</div>
<div class="form-actions">
<a-button type="primary" @click="generateLabel" :disabled="!formData.productionBatch">生成标签</a-button>
</div>
</a-form>
</div>
<!-- 标签预览 -->
<div v-if="labelData.partName" class="label-preview-section">
<h3>标签预览</h3>
<div class="label-container" ref="labelContainer">
<div class="label" v-for="index in 1" :key="index">
<table class="label-table">
<tr>
<td class="label-cell">
<div class="label-field">零件名称</div>
<div class="label-value">{{ labelData.partName }}</div>
</td>
<td class="label-cell">
<div class="label-field">生产日期</div>
<div class="label-value">{{ labelData.productionDate }}</div>
</td>
<td class="label-cell qr-cell" rowspan="4">
<div class="qr-code">
<img :src="`https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${encodeURIComponent(labelData.qrCodeData)}`" alt="QR Code" />
</div>
</td>
</tr>
<tr>
<td class="label-cell">
<div class="label-field">零件号</div>
<div class="label-value">{{ labelData.partNumber }}</div>
</td>
<td class="label-cell">
<div class="label-field">数量</div>
<div class="label-value">{{ labelData.quantity }}</div>
</td>
</tr>
<tr>
<td class="label-cell">
<div class="label-field">标重(kg)</div>
<div class="label-value">{{ labelData.standardWeight }}</div>
</td>
<td class="label-cell">
<div class="label-field">包装签字</div>
<div class="label-value">{{ labelData.packingSignature || '' }}</div>
</td>
</tr>
<tr>
<td class="label-cell">
<div class="label-field">实重(kg)</div>
<div class="label-value">{{ labelData.actualWeight || '' }}</div>
</td>
<td class="label-cell">
<div class="label-field">检验签字</div>
<div class="label-value">{{ labelData.inspectionSignature || '' }}</div>
</td>
</tr>
<!-- <tr>-->
<!-- <td class="label-cell" colspan="2"></td>-->
<!-- <td class="label-cell qr-cell">-->
<!-- <div class="serial-number">序号: {{ labelData.serialNumber }}</div>-->
<!-- </td>-->
<!-- </tr>-->
</table>
</div>
</div>
<div class="label-actions">
<a-button type="primary" @click="printLabel">打印标签</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 表单数据
const formData = reactive({
encoding: '',
materialName: '',
orderNo: '',
productionBatch: ''
})
// 是否是从工单页面跳转过来的
const isFromWorkOrder = ref(false)
// 标签数据
const labelData = reactive({
partName: '',
partNumber: '',
standardWeight: '',
actualWeight: '',
productionDate: '',
quantity: '',
packingSignature: '',
inspectionSignature: '',
serialNumber: '',
qrCodeData: '',
})
// 标签容器引用
const labelContainer = ref<HTMLElement | null>(null)
// 模拟获取标签数据的接口
const fetchLabelData = async (params: any) => {
// 模拟后端接口延迟
return new Promise((resolve) => {
setTimeout(() => {
// 格式化生产日期为 yyyyMMddHHmm 格式
const now = new Date()
const formattedDate = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0') +
String(now.getHours()).padStart(2, '0') +
String(now.getMinutes()).padStart(2, '0')
// 模拟返回数据
const mockData = {
partName: params.materialName || 'PP0449002护套',
partNumber: params.encoding || 'PP0449002',
standardWeight: '0.39500',
actualWeight: '',
productionDate: formattedDate,
quantity: '200',
packingSignature: '',
inspectionSignature: '',
serialNumber: '4',
qrCodeData: `PART:${params.encoding || 'PP0449002'},NAME:${params.materialName || 'PP0449002护套'},DATE:${formattedDate},QTY:200`,
}
resolve(mockData)
}, 500)
})
}
// 生成标签
const generateLabel = async () => {
if (!formData.productionBatch) {
Message.error('请输入生产批次')
return
}
try {
// 获取标签数据
const result = await fetchLabelData(formData)
Object.assign(labelData, result)
Message.success('标签生成成功')
} catch (error) {
console.error('生成标签失败:', error)
Message.error('生成标签失败')
}
}
// 组件挂载时,检查是否有参数传递
onMounted(() => {
// 从路由参数中获取数据
const materialName = route.query.materialName as string
const encoding = route.query.encoding as string
const orderNo = route.query.orderNo as string
// 如果有参数传递,设置表单数据并标记为不可修改
if (materialName && encoding && orderNo) {
formData.materialName = materialName
formData.encoding = encoding
formData.orderNo = orderNo
isFromWorkOrder.value = true
// 自动生成标签
generateLabel()
}
})
// 打印标签
const printLabel = async () => {
await nextTick()
if (labelContainer.value) {
// 克隆标签容器内容用于打印
const printContent = labelContainer.value.cloneNode(true) as HTMLElement
// 创建打印窗口
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>标签打印</title>
<style>
body { margin: 0; padding: 10mm; font-family: Arial, sans-serif; }
.label-container { display: flex; flex-wrap: wrap; gap: 10mm; justify-content: center; }
.label { width: 90mm; height: 36mm; border: 1px solid #000; padding: 0; box-sizing: border-box; page-break-inside: avoid; }
.label-table { width: 100%; height: 100%; border-collapse: collapse; }
.label-cell { border: 1px solid #000; padding: 1mm; vertical-align: top; }
.qr-cell { width: 24mm; text-align: center; vertical-align: middle; }
.label-field { font-size: 7pt; font-weight: bold; margin-bottom: 0.5mm; }
.label-value { font-size: 7pt; }
.qr-code img { width: 20mm; height: 20mm; margin: 1mm 0; }
.serial-number { font-size: 7pt; margin-top: 1mm; }
</style>
</head>
<body>
${printContent.innerHTML}
</body>
</html>
`)
printWindow.document.close()
printWindow.focus()
printWindow.print()
printWindow.close()
}
}
}
defineOptions({ name: 'BarcodePrint' })
</script>
<style scoped lang="scss">
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page-title {
margin-bottom: 30px;
text-align: center;
color: #333;
}
/* 表单区域 */
.form-section {
background-color: var(--color-bg-2);
padding: 20px;
border-radius: 4px;
margin-bottom: 30px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.form-grid-item {
width: 100%;
}
.form-actions {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 标签预览 */
.label-preview-section {
margin-top: 30px;
padding: 20px;
background-color: var(--color-bg-2);
border-radius: 4px;
}
.label-preview-section h3 {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: bold;
text-align: center;
}
.label-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
margin-bottom: 20px;
}
.label {
width: 450px;
height: 180px;
border: 1px solid #000;
padding: 0;
box-sizing: border-box;
font-family: Arial, sans-serif;
}
.label-table {
width: 100%;
height: 100%;
border-collapse: collapse;
}
.label-cell {
border: 1px solid #000;
padding: 5px;
vertical-align: top;
}
.qr-cell {
width: 120px;
text-align: center;
vertical-align: middle;
}
.label-field {
font-size: 12px;
font-weight: bold;
margin-bottom: 2px;
}
.label-value {
font-size: 12px;
}
.qr-code img {
width: 100px;
height: 100px;
margin: 5px 0;
}
.serial-number {
font-size: 12px;
margin-top: 5px;
}
.label-actions {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 确保输入框宽度一致 */
:deep(.arco-input-wrapper) {
width: 100%;
height: 35px;
}
:deep(.arco-input) {
font-size: 16px;
}
</style>

View File

@@ -13,7 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Message, type TreeNodeData } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { addUser, getUser, updateUser } from '@/apis/system/user' import { addUser, getUser, updateUser } from '@/apis/system/user'
import { type ColumnItem, GiForm } from '@/components/GiForm' import { type ColumnItem, GiForm } from '@/components/GiForm'

View File

@@ -102,6 +102,7 @@ import { DisEnableStatusList } from '@/constant/common'
import { useDownload, useResetReactive, useTable } from '@/hooks' import { useDownload, useResetReactive, useTable } from '@/hooks'
import { isMobile } from '@/utils' import { isMobile } from '@/utils'
import has from '@/utils/has' import has from '@/utils/has'
import type {ColumnItem} from "@/components/GiForm";
defineOptions({ name: 'SystemUser' }) defineOptions({ name: 'SystemUser' })
@@ -122,7 +123,7 @@ const queryFormColumns: ColumnItem[] = reactive([
hideLabel: true, hideLabel: true,
}, },
props: { props: {
placeholder: '搜索用户名/昵称/描述', placeholder: '搜索用户名',
showWordLimit: false, showWordLimit: false,
}, },
}, },
@@ -226,11 +227,6 @@ const onUpdate = (record: UserResp) => {
UserAddDrawerRef.value?.onUpdate(record.id) UserAddDrawerRef.value?.onUpdate(record.id)
} }
const UserDetailDrawerRef = ref<InstanceType<typeof UserDetailDrawer>>()
const onDetail = (record: UserResp) => {
UserDetailDrawerRef.value?.onOpen(record.id)
}
const UserResetPwdModalRef = ref<InstanceType<typeof UserResetPwdModal>>() const UserResetPwdModalRef = ref<InstanceType<typeof UserResetPwdModal>>()
const onResetPwd = (record: UserResp) => { const onResetPwd = (record: UserResp) => {
UserResetPwdModalRef.value?.onOpen(record.id) UserResetPwdModalRef.value?.onOpen(record.id)

View File

@@ -25,8 +25,8 @@
<div class="main-content"> <div class="main-content">
<!-- 左侧表单 --> <!-- 左侧表单 -->
<div class="left-section"> <div class="left-section">
<a-image v-if="!formData.imageUrl" :src="formData.imageUrl"/> <a-image v-if="!formData.photoUrl" :src="formData.photoUrl" />
<img v-else :src="formData.imageUrl" class="sample-image square-image" alt="样图"> <img v-else :src="formData.photoUrl" class="sample-image square-image" alt="样图">
<a-form :model="formData" layout="vertical"> <a-form :model="formData" layout="vertical">
<div class="form-row"> <div class="form-row">
@@ -44,12 +44,12 @@
<div class="form-row"> <div class="form-row">
<div class="form-item"> <div class="form-item">
<a-form-item label="物料编码"> <a-form-item label="物料编码">
<a-input v-model="formData.materialCode" placeholder="物料编码" disabled /> <a-input v-model="formData.encoding" placeholder="物料编码" disabled />
</a-form-item> </a-form-item>
</div> </div>
<div class="form-item"> <div class="form-item">
<a-form-item label="重量"> <a-form-item label="重量(g)">
<a-input v-model="formData.weight" placeholder="Kg" disabled /> <a-input v-model="formData.unitWeight" placeholder="Kg" disabled />
</a-form-item> </a-form-item>
</div> </div>
</div> </div>
@@ -58,8 +58,9 @@
<a-form-item label="物料编码"> <a-form-item label="物料编码">
<a-input <a-input
v-model="formData.inputMaterialCode" v-model="formData.inputMaterialCode"
placeholder="请使用扫码枪扫描物料编码" placeholder="请点击此处确保光标闪烁,并使用扫码枪扫描物料编码"
@change="handleMaterialCodeChange" @keydown="handleKeyDown"
@input="handleMaterialCodeChange"
/> />
</a-form-item> </a-form-item>
</div> </div>
@@ -83,11 +84,10 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">物料编码:</span> <span class="label">物料编码:</span>
<span class="value">{{ formData.materialCode || '-' }}</span> <span class="value">{{ formData.encoding || '-' }}</span>
</div> </div>
</div> </div>
<div class="info-card"> <div class="info-card">
<h4>比对结果</h4> <h4>比对结果</h4>
<div class="info-item"> <div class="info-item">
@@ -109,7 +109,7 @@
</div> </div>
</div> </div>
<!-- 称重登记页面 --> <!-- 称重登记页面 - 这里放FLV播放器 -->
<div v-else-if="activeStep === 2" class="step-content"> <div v-else-if="activeStep === 2" class="step-content">
<div class="weighing-content"> <div class="weighing-content">
<!-- 左侧输入区域 --> <!-- 左侧输入区域 -->
@@ -123,22 +123,58 @@
<div class="form-row"> <div class="form-row">
<div class="form-item"> <div class="form-item">
<label>输入数量:</label> <label>输入数量:</label>
<a-input v-model="inputQuantity" placeholder="请输入数量" /> <a-input v-model="inputQuantity" placeholder="请输入数量" @change="calculateWeight" />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-item"> <div class="form-item">
<label>对应重量:</label> <label>计算重量(g):</label>
<a-input v-model="inputWeight" placeholder="请输入重量" /> <a-input v-model="calculatedWeight" placeholder="-" disabled />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-item"> <div class="form-item">
<label>称重次数:</label> <label>对应重量(g):</label>
<a-input v-model="weighingCount" placeholder="称重次数" disabled /> <a-input v-model="ahDeviceWeight" placeholder="-" disabled/>
</div> </div>
</div> </div>
<div class="image-placeholder large-image">实时画面</div>
<!-- 这里是摄像头画面 - 替换成FLV播放器 -->
<div class="video-container large-image">
<video
ref="cameraVideo"
autoplay
muted
playsinline
style="width: 100%; height: 100%; object-fit: cover;"
></video>
<!-- 加载状态 -->
<div v-if="cameraStatus === 'connecting'" class="video-overlay">
<a-spin />
<span style="margin-left: 8px;">加载中...</span>
</div>
<!-- 错误状态 -->
<div v-if="cameraStatus === 'error'" class="video-overlay error">
<icon-close-circle-fill style="color: #ff4d4f; font-size: 24px;" />
<span>连接失败</span>
<a-button size="small" type="primary" @click="reconnectCamera">重试</a-button>
</div>
<!-- 未连接状态 -->
<div v-if="cameraStatus === 'disconnected'" class="video-overlay">
<icon-pause-circle-fill style="color: #999; font-size: 24px;" />
<span>未连接</span>
<a-button size="small" type="primary" @click="initCamera">连接</a-button>
</div>
<!-- 摄像头状态标识 -->
<div v-if="cameraStatus === 'connected'" class="camera-badge">
<span class="live-badge">LIVE</span>
</div>
</div>
<div class="weighing-actions"> <div class="weighing-actions">
<a-button type="primary" @click="handleConfirm">确定</a-button> <a-button type="primary" @click="handleConfirm">确定</a-button>
<a-button style="margin-left: 12px;" @click="handleReset">重置</a-button> <a-button style="margin-left: 12px;" @click="handleReset">重置</a-button>
@@ -172,12 +208,12 @@
<div class="completion-icon"> <div class="completion-icon">
<a-icon type="check-circle" :size="64" style="color: #52c41a;" /> <a-icon type="check-circle" :size="64" style="color: #52c41a;" />
</div> </div>
<h2>创建完成</h2> <h2>{{ workOrderResp.matchResult === 'success' ? '创建成功' : '创建失败' }}</h2>
<p>任务已成功创建以下是任务详情</p> <p>{{ workOrderResp.matchResult === 'success' ? '任务创建成功' : '任务创建失败' }}以下是任务详情</p>
<div class="completion-info"> <div class="completion-info">
<div class="info-item"> <div class="info-item">
<span class="label">任务ID:</span> <span class="label">任务工单号:</span>
<span class="value">{{ taskId }}</span> <span class="value">{{ workOrderResp.orderNo }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">物料名称:</span> <span class="label">物料名称:</span>
@@ -185,15 +221,19 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">物料编码:</span> <span class="label">物料编码:</span>
<span class="value">{{ formData.materialCode }}</span> <span class="value">{{ formData.encoding }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">物料规格:</span> <span class="label">物料规格:</span>
<span class="value">{{ formData.materialSpec }}</span> <span class="value">{{ formData.materialSpec }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">重量:</span> <span class="label">物料总个数:</span>
<span class="value">{{ formData.weight }}</span> <span class="value">{{ workOrderResp.totalCount }}</span>
</div>
<div class="info-item">
<span class="label">物料总重量(g):</span>
<span class="value">{{ workOrderResp.totalWeight }}</span>
</div> </div>
</div> </div>
<div class="completion-actions"> <div class="completion-actions">
@@ -215,6 +255,7 @@
v-if="activeStep < 3" v-if="activeStep < 3"
type="primary" type="primary"
@click="handleNext" @click="handleNext"
:disabled="activeStep === 1 && formData.matchResult !== 'success'"
class="next-button" class="next-button"
> >
{{ activeStep === 2 ? '完成' : '下一步' }} {{ activeStep === 2 ? '完成' : '下一步' }}
@@ -225,9 +266,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref, reactive, onUnmounted } from 'vue'
import { Modal, Icon } from '@arco-design/web-vue' import { Modal, Message } from '@arco-design/web-vue'
import {getMaterialDetail, type WeighManageQuery} from "@/apis/weightManage/weightManage"; import { getMaterialDetail } from "@/apis/weightManage/weightManage";
import {addWorkOrder, type WorkOrderResp} from "@/apis/workOrder/workOrder";
import { Notification } from "@arco-design/web-vue"
import { catchPhoto } from '@/apis/material/materialInfo'
defineOptions({ name: 'WeightManage' })
// 动态导入flv.js
const flvjs = ref<any>(null)
// 当前步骤 // 当前步骤
const activeStep = ref(1) const activeStep = ref(1)
@@ -235,37 +287,298 @@ const activeStep = ref(1)
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
inputMaterialCode: '', // 输入的物料编码 inputMaterialCode: '', // 输入的物料编码
materialCode: '', // 物料编码 id: '', // 物料ID
encoding: '', // 物料编码
materialName: '', // 物料名称 materialName: '', // 物料名称
materialSpec: '', // 物料规格 materialSpec: '', // 物料规格
weight: 0, // 重量 unitWeight: 0, // 重量
imageUrl: '', // 样图URL photoUrl: '', // 样图URL
matchResult: '' // 比对结果 matchResult: '', // 比对结果
}) })
// 摄像头状态
const cameraStatus = ref<'connected' | 'connecting' | 'disconnected' | 'error'>('disconnected')
// 视频元素引用
const cameraVideo = ref<HTMLVideoElement | null>(null)
// FLV播放器实例
let player: any = null
const reconnectCount = ref(0)
const maxReconnectAttempts = 5
const reconnectTimer = ref<any>(null)
// 直接写死FLV流地址根据你的实际地址修改
const FLV_URL = 'http://192.168.2.87:8866/live?url=rtsp://admin:admin%40123@192.168.2.59:554/media/video1' // 摄像头的FLV地址
// 加载flv.js
const loadFlvJs = async () => {
if (typeof window !== 'undefined') {
const module = await import('flv.js')
flvjs.value = module.default
}
}
// 初始化摄像头
const initCamera = () => {
if (!cameraVideo.value || !flvjs.value) {
return
}
// 检查浏览器支持
if (!flvjs.value.isSupported()) {
Message.error('当前浏览器不支持FLV播放')
cameraStatus.value = 'error'
return
}
cameraStatus.value = 'connecting'
// 清除之前的重连定时器
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
try {
// 销毁旧的播放器
if (player) {
player.destroy()
player = null
}
// 创建新的播放器
player = flvjs.value.createPlayer({
type: 'flv',
url: FLV_URL,
isLive: true,
hasAudio: false,
hasVideo: true,
enableStashBuffer: false,
stashInitialSize: 128,
enableWorker: true,
autoCleanupSourceBuffer: true,
})
// 绑定视频元素
player.attachMediaElement(cameraVideo.value)
// 加载并播放
player.load()
player.play().then(() => {
cameraStatus.value = 'connected'
reconnectCount.value = 0 // 连接成功,重置重连计数
}).catch((error: any) => {
cameraStatus.value = 'error'
Message.error('摄像头播放失败,正在重连')
// 触发自动重连
handleReconnect()
})
// 监听各种事件
player.on(flvjs.value.Events.ERROR, (err: any) => {
cameraStatus.value = 'error'
// 触发自动重连
handleReconnect()
})
player.on(flvjs.value.Events.STALLED, () => {
// 可以在这里处理卡顿,但不立即重连
})
player.on(flvjs.value.Events.RECOVERED_EARLY, () => {
if (cameraStatus.value !== 'connected') {
cameraStatus.value = 'connected'
}
})
} catch (error) {
cameraStatus.value = 'error'
handleReconnect()
}
}
// 处理自动重连
const handleReconnect = () => {
// 只有当前在称重页面才重连
if (activeStep.value !== 2) return
// 检查重连次数
if (reconnectCount.value >= maxReconnectAttempts) {
Message.error('摄像头连接失败,请手动重试')
return
}
// 增加重连计数
reconnectCount.value++
// 计算重连延迟(指数退避)
const delay = Math.min(1000 * 2 ** (reconnectCount.value - 1), 30000)
// 清除旧的定时器
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
}
// 设置新的重连定时器
reconnectTimer.value = setTimeout(() => {
initCamera()
}, delay)
}
// 重新连接摄像头(手动)
const reconnectCamera = () => {
reconnectCount.value = 0 // 手动重连时重置计数
initCamera()
}
// 添加一个定时检查状态的函数
const checkCameraStatus = () => {
if (!player || !cameraVideo.value) return
try {
// 检查播放器状态
if (player.isPlaying && !player.isPlaying()) {
if (cameraStatus.value === 'connected') {
cameraStatus.value = 'error'
handleReconnect()
}
}
} catch (error) {
}
}
// 启动状态检查定时器
let statusCheckTimer: any = null
// 监听步骤变化
watch(activeStep, (newVal) => {
if (newVal === 2) {
// 进入称重登记页面,启动摄像头
reconnectCount.value = 0 // 重置重连计数
nextTick(() => {
initCamera()
})
// 启动状态检查每30秒检查一次
statusCheckTimer = setInterval(checkCameraStatus, 30000)
} else {
// 离开称重登记页面,停止摄像头和定时器
stopCamera()
if (statusCheckTimer) {
clearInterval(statusCheckTimer)
statusCheckTimer = null
}
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
reconnectCount.value = 0
}
})
// 组件卸载时清理所有定时器
onBeforeUnmount(() => {
stopCamera()
if (statusCheckTimer) {
clearInterval(statusCheckTimer)
statusCheckTimer = null
}
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
})
// 重新连接摄像头
// const reconnectCamera = () => {
// if (player) {
// player.unload()
// player.detachMediaElement()
// player.destroy()
// player = null
// }
// setTimeout(() => initCamera(), 1000)
// }
// 停止摄像头
const stopCamera = () => {
if (player) {
player.pause()
player.unload()
player.detachMediaElement()
player.destroy()
player = null
cameraStatus.value = 'disconnected'
}
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
reconnectCount.value = 0
}
// 监听步骤变化
watch(activeStep, (newVal) => {
if (newVal === 2) {
// 进入称重登记页面,启动摄像头
nextTick(() => {
initCamera()
})
} else {
// 离开称重登记页面,停止摄像头
stopCamera()
}
})
// 组件挂载时
onMounted(async () => {
await loadFlvJs()
})
// 组件卸载时
onBeforeUnmount(() => {
stopCamera()
})
const workOrderResp = ref<WorkOrderResp>({
id: '',
title: '',
orderNo: '',
materialName: '',
encoding: '',
unitWeight: '',
materialSpec: '',
photoUrl: '',
totalWeight: '',
totalCount: '',
createUserString: '',
updateUserString: '',
matchResult: 'failed'
})
// 称重登记页面数据 // 称重登记页面数据
const inputQuantity = ref('') const inputQuantity = ref('')
const inputWeight = ref('') const ahDeviceWeight = ref('10')
const calculatedWeight = ref('')
const weighingCount = ref(1) const weighingCount = ref(1)
// WebSocket连接
const ws = ref<WebSocket | null>(null)
const wsConnected = ref(false)
// 称重列表数据 // 称重列表数据
const weighingList = ref([ const weighingList = ref([
// 初始数据可以在这里添加 // 初始数据可以在这里添加
]) ])
// 任务ID
const taskId = computed(() => {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const random = Math.floor(1000 + Math.random() * 9000)
return `${date}${formData.materialCode}${random}`
})
// 称重表格列 // 称重表格列
const columns = [ const columns = [
{ {
title: '称重次数', title: '称重次数',
dataIndex: 'count', dataIndex: 'weightTime',
key: 'count' key: 'weightTime'
}, },
{ {
title: '数量', title: '数量',
@@ -274,16 +587,36 @@ const columns = [
className: 'green-bg' className: 'green-bg'
}, },
{ {
title: '重量', title: '重量(g)',
dataIndex: 'weight', dataIndex: 'weight',
key: 'weight', key: 'weight',
className: 'green-bg' className: 'green-bg'
}, },
{
title: '计算重量(g)',
dataIndex: 'calculatedWeight',
key: 'calculatedWeight',
className: 'green-bg'
},
{ {
title: '抓拍图片', title: '抓拍图片',
dataIndex: 'image', dataIndex: 'image',
key: 'image', key: 'image',
className: 'green-bg' className: 'green-bg',
// 使用自定义渲染
render: ({ record }) => {
if (record.image && record.image !== '未抓拍') {
return h('div', { class: 'image-preview' }, [
h('img', {
src: record.image,
alt: '抓拍图片',
style: 'width: 60px; height: 45px; object-fit: cover; border-radius: 4px; cursor: pointer;',
onClick: () => previewImage(record.image),
}),
])
}
return h('span', record.image || '-')
},
}, },
{ {
title: '操作', title: '操作',
@@ -293,62 +626,134 @@ const columns = [
} }
] ]
// 防抖函数
const debounce = <T extends (...args: any[]) => any>(func: T, delay: number): ((...args: Parameters<T>) => void) => {
let timer: ReturnType<typeof setTimeout> | null = null;
return function(...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
// 原始的物料编码变化处理函数
const originalHandleMaterialCodeChange = async () => {
// 确保输入框内容是完整的物料编码
const materialCode = formData.inputMaterialCode?.trim();
// 处理物料编码变化 // 无论是否有输入,先重置所有物料相关字段,确保新数据能完全覆盖旧数据
const handleMaterialCodeChange = async () => { formData.encoding = "";
if (formData.inputMaterialCode) {
try {
await fetchMaterialData(formData.inputMaterialCode)
} catch (error) {
console.error('获取物料数据失败:', error)
}
} else {
formData.materialCode = "";
formData.materialName = ""; formData.materialName = "";
formData.materialSpec = ""; formData.materialSpec = "";
formData.weight = 0; formData.unitWeight = 0;
formData.imageUrl = ""; formData.photoUrl = "";
formData.matchResult = ""; formData.matchResult = "";
// 如果有物料编码输入,获取物料数据
if (materialCode) {
try {
await fetchMaterialData(materialCode)
} catch (error) {
console.error('获取物料数据失败:', error)
// 即使获取失败,也保持字段为空,避免显示旧数据
}
} }
}; };
// 模拟调用后端接口 // 记录上次输入时间,用于判断是否是扫码枪输入
const fetchMaterialData = async (code: string) => { let lastInputTime = 0;
const query: WeighManageQuery = { // 记录输入内容,用于判断是否是新的扫码
materialCode: code let previousInput = '';
// 处理键盘按下事件
const handleKeyDown = (event: KeyboardEvent) => {
// 获取当前时间
const currentTime = Date.now();
// 如果输入速度非常快小于100ms认为是扫码枪输入
if (currentTime - lastInputTime < 100 && formData.inputMaterialCode) {
// 扫码枪通常会以回车键结束,这里不做处理
} else if (event.key.length === 1 && !event.ctrlKey && !event.altKey) {
// 如果是新的输入(不是快速连续输入),清空输入框
// 这样可以确保每次扫码都从空输入框开始
formData.inputMaterialCode = '';
} }
getMaterialDetail(query).then(res => {
// 更新上次输入时间
lastInputTime = currentTime;
};
// 带防抖的物料编码变化处理函数
const handleMaterialCodeChange = debounce(originalHandleMaterialCodeChange, 500); // 500ms防抖延迟
// 扫码核验获取物料信息
const fetchMaterialData = async (code: string) => {
getMaterialDetail(code).then(res => {
if (res.code == '0') { if (res.code == '0') {
// 更新表单数据 // 更新表单数据
formData.materialCode = res.data.materialCode formData.id = res.data?.id || "";
formData.materialName = res.data.materialName formData.encoding = res.data?.encoding || "";
formData.materialSpec = res.data.materialSpec formData.materialName = res.data?.materialName || "";
formData.weight = res.data.weight formData.materialSpec = res.data?.materialSpec || "";
// formData.imageUrl = res.data.imageUrl formData.unitWeight = res.data?.unitWeight || 0;
formData.imageUrl = "https://menjing.hzjj.cn/uploadPath/2024/01/20/XMT-Q3_20240120171349A257.jpg" formData.photoUrl = res.data?.photoUrl || "";
// 假设后端返回比对结果 // 假设后端返回比对结果
// formData.matchResult = res.data.matchResult || 'failed' // 这里根据实际接口返回调整 // formData.matchResult = res.data.matchResult || 'failed' // 这里根据实际接口返回调整
formData.matchResult = res.data.matchResult || 'success' // 这里根据实际接口返回调整 formData.matchResult = res.data?.matchResult || 'success' // 这里根据实际接口返回调整
return true return true
} }
}); });
} }
// 处理下一步 // 处理下一步
const handleNext = () => { const handleNext = async () => {
if (activeStep.value < 3) { if (activeStep.value < 3) {
if (activeStep.value === 2) { if (activeStep.value === 2) {
// 当在称重登记页面点击完成时,显示确认弹框 // 当在称重登记页面点击完成时,显示确认弹框
Modal.confirm({ Modal.confirm({
title: '确认完成', title: '确认完成',
content: '确定要完成称重登记吗?', content: '确定要完成称重登记吗?',
onOk: () => { onOk: async () => {
try {
// 准备工作订单数据
const workOrderData = {
materialId: formData.id,
materialName: formData.materialName,
workOrderInfos: weighingList.value
}
// 调用后端接口
addWorkOrder(workOrderData).then(res => {
if (res.code == '0') {
Notification.success({
title: '操作成功',
content: `工单创建成功!`
})
workOrderResp.value.matchResult = 'success';
workOrderResp.value.title = res.data?.title || "";
workOrderResp.value.orderNo = res.data?.orderNo || "";
workOrderResp.value.totalWeight = res.data?.totalWeight || "";
workOrderResp.value.totalCount = res.data?.totalCount || "";
// 关闭WebSocket连接
closeWebSocket()
// 跳转到完成页面
activeStep.value++ activeStep.value++
return true
}
});
} catch (error) {
console.error('创建工作订单失败:', error)
Message.error('创建工作订单失败')
}
} }
}) })
} else { } else {
activeStep.value++ activeStep.value++
// 进入称重页面时建立WebSocket连接
if (activeStep.value === 2) {
establishWebSocket()
}
} }
} }
} }
@@ -357,40 +762,131 @@ const handleNext = () => {
const handlePrevious = () => { const handlePrevious = () => {
if (activeStep.value > 1) { if (activeStep.value > 1) {
activeStep.value-- activeStep.value--
// 离开称重页面时关闭WebSocket连接
if (activeStep.value !== 2) {
closeWebSocket()
}
} }
} }
// 返回第一步 // 返回第一步
const handleBackToFirst = () => { const handleBackToFirst = () => {
activeStep.value = 1 activeStep.value = 1
// 离开称重页面时关闭WebSocket连接
closeWebSocket()
}
// 计算重量
const calculateWeight = () => {
if (inputQuantity.value && formData.unitWeight) {
const singleWeight = parseFloat(formData.unitWeight.toString())
const quantity = parseFloat(inputQuantity.value)
if (!isNaN(singleWeight) && !isNaN(quantity)) {
calculatedWeight.value = (singleWeight * quantity).toFixed(2)
}
} else {
calculatedWeight.value = ''
}
} }
// 处理确定 // 处理确定
const handleConfirm = () => { const handleConfirm = () => {
// 校验输入数量是否为空
if (!inputQuantity.value || inputQuantity.value.trim() === '') {
Message.error('请输入数量')
return
}
if (!ahDeviceWeight.value || ahDeviceWeight.value.trim() === '') {
Message.error('电子秤称重结果为空!')
return
}
// 生成新的列表数据 // 生成新的列表数据
const newItem = { const newItem = {
key: (weighingList.value.length + 1).toString(), key: (weighingList.value.length + 1).toString(),
count: weighingCount.value, weightTime: weighingCount.value,
name: formData.materialName, materialId: formData.id,
quantity: inputQuantity.value, quantity: inputQuantity.value,
weight: inputWeight.value, weight: ahDeviceWeight.value,
image: '图片' calculatedWeight: calculatedWeight.value,
image: captureUrl || '未抓拍',
} }
// 添加到列表 // 添加到列表
weighingList.value.push(newItem) weighingList.value.push(newItem)
// 重置输入 // 重置输入
inputQuantity.value = '' inputQuantity.value = ''
inputWeight.value = '' ahDeviceWeight.value = '10'
calculatedWeight.value = ''
// 称重次数累加 // 称重次数累加
weighingCount.value = weighingList.value.length + 1 weighingCount.value = weighingList.value.length + 1
Message.success('添加成功')
}
// 抓拍功能 - 内部方法,不暴露按钮
const capturePhoto = async (): Promise<string> => {
return new Promise(async (resolve, reject) => {
try {
const video = cameraVideo.value
if (!video) {
reject(new Error('视频未初始化'))
return
}
// 创建canvas抓取当前帧
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth || 640
canvas.height = video.videoHeight || 480
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// 转换为Blob
const blob = await new Promise<Blob>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9)
})
// 创建FormData
const formData = new FormData()
formData.append('file', blob, `capture_${Date.now()}.jpg`)
// 调用后端接口
const response = await catchPhoto(formData)
if (!response.code === '200') {
reject(new Error('上传失败'))
return
}
const imageUrl = await response.data
resolve(imageUrl)
} catch (error) {
reject(error)
}
})
}
// 图片预览函数
const previewImage = (url: string) => {
// 使用Arco Design的图片预览组件
Modal.info({
title: '图片预览',
content: () => h('div', { style: 'text-align: center;' }, [
h('img', {
src: url,
style: 'max-width: 100%; max-height: 500px; object-fit: contain;',
}),
]),
okText: '关闭',
hideCancel: true,
width: 'auto',
})
} }
// 处理重置 // 处理重置
const handleReset = () => { const handleReset = () => {
inputQuantity.value = '' inputQuantity.value = ''
inputWeight.value = '' ahDeviceWeight.value = ''
calculatedWeight.value = ''
} }
// 处理删除 // 处理删除
@@ -399,13 +895,61 @@ const handleDelete = (key) => {
weighingList.value = weighingList.value.filter(item => item.key !== key) weighingList.value = weighingList.value.filter(item => item.key !== key)
// 重新排序称重次数 // 重新排序称重次数
weighingList.value.forEach((item, index) => { weighingList.value.forEach((item, index) => {
item.count = index + 1 item.weightTime = index + 1
}) })
// 更新当前称重次数 // 更新当前称重次数
weighingCount.value = weighingList.value.length + 1 weighingCount.value = weighingList.value.length + 1
} }
defineOptions({ name: 'WeightManage' }) // 建立WebSocket连接
const establishWebSocket = () => {
try {
// 这里替换为实际的WebSocket服务器地址
const wsUrl = 'ws://localhost:6609/ws/scale'
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
Message.success('已和电子秤建立连接')
}
ws.value.onmessage = (event) => {
try {
if (event.data) {
ahDeviceWeight.value = event.data
}
} catch (error) {
console.error('WebSocket消息解析失败:', error)
}
}
ws.value.onclose = () => {
wsConnected.value = false
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
wsConnected.value = false
Message.error('称重连接失败')
}
} catch (error) {
console.error('建立WebSocket连接失败:', error)
Message.error('无法建立称重连接')
}
}
// 关闭WebSocket连接
const closeWebSocket = () => {
if (ws.value) {
ws.value.close()
ws.value = null
wsConnected.value = false
}
}
// 组件卸载时关闭WebSocket连接
onUnmounted(() => {
closeWebSocket()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -533,10 +1077,68 @@ defineOptions({ name: 'WeightManage' })
font-weight: bold; font-weight: bold;
} }
/* 视频容器 - 替换原来的image-placeholder */
.video-container {
position: relative;
width: 100%;
height: 240px;
background: #000;
border-radius: 4px;
overflow: hidden;
margin: 20px 0;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
gap: 10px;
}
.video-overlay.error {
background: rgba(255, 77, 79, 0.2);
}
.camera-badge {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
}
.live-badge {
background: #ff4d4f;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.large-image { .large-image {
width: 100%; width: 100%;
height: 200px; height: 240px;
margin: 20px 0;
} }
.input-placeholder { .input-placeholder {

View File

@@ -0,0 +1,284 @@
<template>
<div class="gi_table_page">
<GiTable
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1600 }"
:pagination="pagination"
:disabled-tools="['size']"
:disabled-column-keys="['name']"
:selected-keys="selectedKeys"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
@select-all="selectAll"
@select="select"
@refresh="search"
>
<template #toolbar-left>
<a-input-search v-model="queryForm.userName" placeholder="请输入姓名" allow-clear @search="search" />
<a-input-search v-model="queryForm.carNo" placeholder="请输入人员卡号" allow-clear @search="search" />
<a-input-search v-model="queryForm.orderNo" placeholder="请输入任务工单号" allow-clear @search="search" />
<a-input-search v-model="queryForm.materialName" placeholder="请输入物料名称" allow-clear @search="search" />
<a-input-search v-model="queryForm.encoding" placeholder="请输入物料编码" allow-clear @search="search" />
<a-date-picker
v-model="queryForm.startDate"
placeholder="请选择开始时间"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="height: 32px"
@change="search"
/>
<a-date-picker
v-model="queryForm.endDate"
placeholder="请选择结束时间"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="height: 32px"
@change="search"
/>
<a-button @click="reset">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</template>
<template #toolbar-right>
<a-button v-permission="['workOrder:record:delete']" type="outline" status="danger" @click="onDelete">
<template #icon><icon-delete /></template>
<template #default>删除</template>
</a-button>
<a-button v-permission="['workOrder:record:export']" @click="onExport">
<template #icon><icon-download /></template>
<template #default>导出</template>
</a-button>
</template>
<template #photoUrl="{ record }">
<a-image :src="record.photoUrl" width="55" />
</template>
<template #unitWeight="{ record }">
{{ record.unitWeight + 'g' }}
</template>
<template #totalWeight="{ record }">
{{ record.totalWeight + 'g' }}
</template>
<template #action="{ record }">
<a-space>
<a-link v-permission="['workOrder:record:detail']" title="详情" @click="onDetail(record)">详情</a-link>
<a-link v-permission="['workOrder:record:print']" title="打印" @click="onPrint(record)">打印</a-link>
<a-link v-permission="['workOrder:record:delete']" status="danger" :title="'删除'" @click="onDeleteOne(record.id)">删除</a-link>
</a-space>
</template>
</GiTable>
<!-- 详情弹窗 -->
<a-modal
v-model:visible="detailModalVisible"
title="称重详情"
width="800px"
:loading="detailLoading"
>
<GiTable
v-if="detailData.length > 0"
row-key="id"
:data="detailData"
:columns="detailColumns"
:scroll="{ x: '100%', y: '100%', minWidth: 700 }"
>
<template #imgUrl="{ record }">
<a-image :src="record.imgUrl" width="50" />
</template>
<template #calculatedWeight="{ record }">
{{ record.calculatedWeight + 'g' }}
</template>
<template #weight="{ record }">
{{ record.weight + 'g' }}
</template>
</GiTable>
<div v-else class="no-data">
<icon-loading style="font-size: 48px; color: rgb(var(--arcoblue-6));" />
<p>暂无称重数据</p>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import type { TableInstanceColumns } from '@/components/GiTable/type'
import { useDownload, useTable } from '@/hooks'
import { isMobile } from '@/utils'
import has from '@/utils/has'
import type GiTable from "@/components/GiTable/index.vue";
import {ref, reactive} from "vue";
import {Message} from "@arco-design/web-vue";
import { useRouter } from 'vue-router';
import {
deleteWorkOrder,
exportWorkOrder, getWorkOrder,
listWorkOrder,
type WorkOrderQuery,
type WorkOrderResp
} from "@/apis/workOrder/workOrder";
defineOptions({ name: 'Record' })
const router = useRouter()
const queryForm = reactive<WorkOrderQuery>({
orderNo: undefined,
materialName: undefined,
encoding: undefined,
userName: undefined,
carNo: undefined,
startDate: undefined,
endDate: undefined,
sort: ['w.id,desc']
})
const {
tableData: dataList,
loading,
pagination,
selectedKeys,
select,
selectAll,
search,
handleDelete,
} = useTable((page) => listWorkOrder({ ...queryForm, ...page }), { immediate: true })
// 创建工具函数统一处理列配置
const processColumns = (columns: TableInstanceColumns[]): TableInstanceColumns[] => {
return columns.map(column => {
const defaultConfig = {
ellipsis: true,
tooltip: true
};
return { ...defaultConfig, ...column };
});
};
// 定义列配置,使用工具函数处理
const columns = ref<TableInstanceColumns[]>(processColumns([
{ title: '标题', dataIndex: 'title', width: 180 },
{ title: '创建人', dataIndex: 'createUserString' },
{ title: '人员卡号', dataIndex: 'cardNo' },
{ title: '任务工单号', dataIndex: 'orderNo' },
{ title: '物料图片', dataIndex: 'photoUrl', slotName: 'photoUrl', minWidth: 180, ellipsis: true, tooltip: true },
{ title: '物料名称', dataIndex: 'materialName' },
{ title: '物料编码', dataIndex: 'encoding' },
{ title: '单位克重', dataIndex: 'unitWeight' ,slotName: 'unitWeight'},
{ title: '总重量', dataIndex: 'totalWeight' ,slotName: 'totalWeight'},
{ title: '称重次数', dataIndex: 'totalCount' },
{ title: '创建时间', dataIndex: 'createTime', width: 180 },
{
title: '操作',
dataIndex: 'action',
slotName: 'action',
width: 160,
align: 'center',
fixed: !isMobile() ? 'right' : undefined,
show: has.hasPermOr(['workOrder:record:detail', 'workOrder:record:update', 'workOrder:record:delete'])
}
]))
// 重置
const reset = () => {
queryForm.orderNo = undefined
queryForm.materialName = undefined
queryForm.encoding = undefined
queryForm.userName = undefined
queryForm.carNo = undefined
queryForm.startDate = undefined
queryForm.endDate = undefined
search()
}
// 详情弹窗状态
const detailModalVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any[]>([])
// 详情列配置
const detailColumns = ref<TableInstanceColumns[]>([
{ title: '称重次数', dataIndex: 'weightTime' },
{ title: '物料名称', dataIndex: 'materialName' },
{ title: '数量', dataIndex: 'quantity' },
{ title: '重量', dataIndex: 'weight', slotName: 'weight' },
{ title: '计算重量', dataIndex: 'calculatedWeight', slotName: 'calculatedWeight' },
{ title: '抓拍图片', dataIndex: 'imgUrl', slotName: 'imgUrl' }
])
// 详情
const onDetail = async (record: WorkOrderResp) => {
detailLoading.value = true
detailModalVisible.value = true
detailData.value = []
getWorkOrder(record.id).then(res => {
if (res.code == '0') {
detailData.value = res.data;
detailLoading.value = false
} else {
Message.error('获取详情失败')
}
});
};
// 删除
const onDeleteOne = (id) => {
return handleDelete(() => deleteWorkOrder(id), {
content: `是否确定删除该条数据?`,
showModal: true
})
}
// 删除
const onDelete = () => {
if (!selectedKeys.value.length) {
return Message.warning('请选择数据')
}
return handleDelete(() => deleteWorkOrder(selectedKeys.value), {
content: `是否确定删除选中的 ${selectedKeys.value.length} 条数据?`,
showModal: true
})
}
// 导出
const onExport = () => {
useDownload(() => exportWorkOrder(queryForm))
}
// 打印
const onPrint = (record: WorkOrderResp) => {
// 跳转到标签打印页面,并传递数据
router.push({
path: '/print',
query: {
materialName: record.materialName,
encoding: record.encoding,
orderNo: record.orderNo
}
})
}
</script>
<style scoped lang="scss">
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--color-text-4);
p {
margin-top: 16px;
font-size: 14px;
}
}
</style>