chore: initialize mini program frontend
This commit is contained in:
commit
fa73a3c184
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.DS_Store
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# WeChat DevTools local/private settings
|
||||||
|
project.private.config.json
|
||||||
|
|
||||||
|
# Dependency and build artifacts
|
||||||
|
node_modules/
|
||||||
|
miniprogram_npm/
|
||||||
|
|
||||||
|
# Logs and temporary files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
33
PRODUCT.md
Normal file
33
PRODUCT.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
This mini program is used by factory workers, administrators, and managers in daily production. Workers scan mold QR codes, clock in and out, and submit production reports on mobile phones in shop-floor conditions. Administrators maintain people, products, devices, QR codes, reviews, monitoring, and exceptions. Managers maintain attendance settings and inspect dashboards, exports, reconciliation, and cross-site operations.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
The product records production work, attendance-point context, equipment and mold usage, review status, and management exports for Ningbo Jiaheng Smart Technology. Success means production data is easy to enter on a phone, easy to audit, hard to misinterpret, and traceable across attendance points, people, products, equipment, molds, and reports.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Practical, precise, and work-focused. The interface should feel like an operational tool built for repeated use, with enough visual distinction to make scan, review, and management actions easy to find without looking like a marketing page.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Avoid landing-page composition, oversized hero sections, decorative storytelling, vague dashboard decoration, and UI that hides dense production information behind too much spacing. Avoid fragile controls that depend on expert gestures only; shop-floor users need obvious buttons, clear states, and forgiving inputs.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Make the current task obvious before adding secondary information.
|
||||||
|
2. Preserve production traceability: attendance point, product, process, stamping method, equipment, person, and status should stay visible where decisions are made.
|
||||||
|
3. Prefer dense but organized layouts over decorative card piles.
|
||||||
|
4. Use clear visual states for audit, exception, voided, cleaning, continuous-die, multi-person, timeout, and other operational labels.
|
||||||
|
5. Optimize for mobile field use: large targets, readable numbers, simple filters, and predictable pagination.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Use high-contrast text, clear labels, and touch targets sized for phone operation. Do not rely on color alone for critical states; pair color with text labels or watermarks. Keep motion minimal and nonessential.
|
||||||
41
app.json
Normal file
41
app.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
"pages/index/index",
|
||||||
|
"pages/clock/clock",
|
||||||
|
"pages/cleaningReport/cleaningReport",
|
||||||
|
"pages/reportForm/reportForm",
|
||||||
|
"pages/reportResult/reportResult",
|
||||||
|
"pages/records/records",
|
||||||
|
"pages/review/review",
|
||||||
|
"pages/manageProducts/manageProducts",
|
||||||
|
"pages/managePeople/managePeople",
|
||||||
|
"pages/manageEquipment/manageEquipment",
|
||||||
|
"pages/smartMonitor/smartMonitor",
|
||||||
|
"pages/manageNotices/manageNotices",
|
||||||
|
"pages/workSchedule/workSchedule",
|
||||||
|
"pages/reconciliationLedger/reconciliationLedger",
|
||||||
|
"pages/usageStats/usageStats",
|
||||||
|
"pages/usageStatsDetail/usageStatsDetail",
|
||||||
|
"pages/dashboard/dashboard",
|
||||||
|
"pages/deviceQr/deviceQr",
|
||||||
|
"pages/logs/logs"
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "嘉恒智能报工",
|
||||||
|
"navigationBarBackgroundColor": "#f7f8fa",
|
||||||
|
"backgroundColor": "#f7f8fa"
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "用于校验是否在厂区范围内扫码报工"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requiredPrivateInfos": [
|
||||||
|
"getLocation"
|
||||||
|
],
|
||||||
|
"style": "v2",
|
||||||
|
"componentFramework": "glass-easel",
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
|
"sitemapLocation": "sitemap.json"
|
||||||
|
}
|
||||||
960
app.wxss
Normal file
960
app.wxss
Normal file
@ -0,0 +1,960 @@
|
|||||||
|
page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 18%, rgba(74, 85, 99, 0.09) 0, rgba(74, 85, 99, 0.09) 10rpx, transparent 11rpx),
|
||||||
|
radial-gradient(circle at 82% 22%, rgba(74, 85, 99, 0.08) 0, rgba(74, 85, 99, 0.08) 12rpx, transparent 13rpx),
|
||||||
|
radial-gradient(circle at 22% 78%, rgba(74, 85, 99, 0.07) 0, rgba(74, 85, 99, 0.07) 11rpx, transparent 12rpx),
|
||||||
|
repeating-linear-gradient(135deg, rgba(108, 122, 140, 0.035) 0, rgba(108, 122, 140, 0.035) 2rpx, transparent 2rpx, transparent 18rpx),
|
||||||
|
#f2f5f9;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
page::before {
|
||||||
|
content: "⚙";
|
||||||
|
position: fixed;
|
||||||
|
right: -28rpx;
|
||||||
|
top: 190rpx;
|
||||||
|
z-index: 0;
|
||||||
|
color: rgba(71, 84, 103, 0.045);
|
||||||
|
font-size: 260rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: rotate(18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
page::after {
|
||||||
|
content: "JH";
|
||||||
|
position: fixed;
|
||||||
|
left: -8rpx;
|
||||||
|
bottom: 160rpx;
|
||||||
|
z-index: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
color: rgba(71, 84, 103, 0.14);
|
||||||
|
font-size: 150rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-text-fill-color: currentColor;
|
||||||
|
text-shadow:
|
||||||
|
0 1rpx 0 rgba(255, 255, 255, 0.42),
|
||||||
|
0 3rpx 0 rgba(78, 89, 105, 0.12),
|
||||||
|
0 10rpx 14rpx rgba(15, 23, 42, 0.06);
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 28rpx 24rpx 56rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll-view.page {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18rpx 4rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 2rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
width: 168rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
opacity: 0.12;
|
||||||
|
transform: rotate(-26deg);
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 50%, transparent 0rpx, transparent 13rpx, #4b5563 14rpx, #4b5563 22rpx, transparent 23rpx),
|
||||||
|
radial-gradient(circle at 86% 50%, #4b5563 0rpx, #4b5563 18rpx, transparent 19rpx),
|
||||||
|
linear-gradient(90deg, transparent 0rpx, transparent 32rpx, #4b5563 33rpx, #4b5563 134rpx, transparent 135rpx);
|
||||||
|
filter: drop-shadow(0 5rpx 4rpx rgba(15, 23, 42, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #6b778c;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin: 34rpx 4rpx 18rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: "";
|
||||||
|
width: 26rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 42%, rgba(47, 55, 65, 0.86) 43%, rgba(47, 55, 65, 0.86) 57%, transparent 58%),
|
||||||
|
radial-gradient(circle at 32% 28%, #ffffff 0, #dfe6ec 18%, #8c98a5 54%, #4a5563 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(72, 83, 97, 0.42),
|
||||||
|
0 6rpx 10rpx rgba(22, 34, 50, 0.16),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
padding: 26rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
border: 3rpx solid #aebbd0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 13rpx 0 rgba(147, 163, 185, 0.82),
|
||||||
|
0 24rpx 36rpx rgba(22, 34, 50, 0.18),
|
||||||
|
inset 0 0 0 1rpx rgba(174, 187, 208, 0.48),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before,
|
||||||
|
.stat::before,
|
||||||
|
.list-empty::before,
|
||||||
|
.modal-panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 16rpx;
|
||||||
|
top: 16rpx;
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 42%, rgba(47, 55, 65, 0.78) 43%, rgba(47, 55, 65, 0.78) 57%, transparent 58%),
|
||||||
|
radial-gradient(circle at 32% 28%, #ffffff 0, #dfe6ec 19%, #97a3af 58%, #475467 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 3rpx 0 rgba(72, 83, 97, 0.38),
|
||||||
|
0 6rpx 10rpx rgba(22, 34, 50, 0.14),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.85);
|
||||||
|
transform: rotate(-22deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:active {
|
||||||
|
border-color: #9eacc2;
|
||||||
|
transform: translateY(3rpx);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.82),
|
||||||
|
0 8rpx 0 rgba(147, 163, 185, 0.86),
|
||||||
|
0 16rpx 26rpx rgba(22, 34, 50, 0.15),
|
||||||
|
inset 0 0 0 1rpx rgba(174, 187, 208, 0.42),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tap-card {
|
||||||
|
border-color: #d9e5f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tap-card::after {
|
||||||
|
content: "进入审核";
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
bottom: 18rpx;
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-left {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
color: #6b778c;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #6b778c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.primary {
|
||||||
|
background: #e9f1ff;
|
||||||
|
color: #1456c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.warn {
|
||||||
|
background: #fff3df;
|
||||||
|
color: #b25e09;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.success {
|
||||||
|
background: #e7f7ef;
|
||||||
|
color: #087443;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 142rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
border: 1rpx solid #dce4ef;
|
||||||
|
border-left: 6rpx solid #1463ff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 12rpx 0 rgba(205, 216, 229, 0.62),
|
||||||
|
0 18rpx 28rpx rgba(22, 34, 50, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-row .pager-nav {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-control {
|
||||||
|
flex: 0 0 218rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 6rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input {
|
||||||
|
flex: 0 0 64rpx;
|
||||||
|
min-width: 0;
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-total-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 74rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #1463ff;
|
||||||
|
background: rgba(20, 99, 255, 0.1);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-control.mini {
|
||||||
|
flex-basis: 160rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-control.mini .page-jump-input {
|
||||||
|
flex-basis: 50rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-control.mini .page-total-label {
|
||||||
|
min-width: 58rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-control.mini .page-jump-action {
|
||||||
|
width: 30rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(121, 137, 160, 0.34),
|
||||||
|
0 12rpx 20rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 18rpx;
|
||||||
|
top: 50%;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
margin-top: -7rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 41%, rgba(47, 55, 65, 0.72) 42%, rgba(47, 55, 65, 0.72) 58%, transparent 59%),
|
||||||
|
radial-gradient(circle at 32% 28%, #ffffff 0, #dce3eb 20%, #8b97a4 62%, #46515f 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 2rpx 0 rgba(72, 83, 97, 0.34),
|
||||||
|
0 4rpx 7rpx rgba(22, 34, 50, 0.12);
|
||||||
|
transform: rotate(34deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(4rpx);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(121, 137, 160, 0.38),
|
||||||
|
0 8rpx 14rpx rgba(22, 34, 50, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 #0d3f93,
|
||||||
|
0 14rpx 22rpx rgba(20, 99, 255, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
color: #223044;
|
||||||
|
background: linear-gradient(180deg, #f7faff 0%, #e2e9f3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(180deg, #e04444 0%, #b91c1c 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 #861313,
|
||||||
|
0 14rpx 22rpx rgba(198, 40, 40, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:active {
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 #0d3f93,
|
||||||
|
0 8rpx 14rpx rgba(20, 99, 255, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary:active {
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(121, 137, 160, 0.4),
|
||||||
|
0 8rpx 14rpx rgba(22, 34, 50, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:active {
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 #861313,
|
||||||
|
0 8rpx 14rpx rgba(198, 40, 40, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn[disabled] {
|
||||||
|
opacity: 0.72;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(121, 137, 160, 0.24),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.mini {
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 34rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 680rpx;
|
||||||
|
padding: 34rpx 30rpx 30rpx;
|
||||||
|
border: 3rpx solid #aebbd0;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.86),
|
||||||
|
0 14rpx 0 rgba(147, 163, 185, 0.72),
|
||||||
|
0 28rpx 58rpx rgba(15, 23, 42, 0.26),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-tip {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-worker-actions {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.picker {
|
||||||
|
min-height: 78rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 6rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
line-height: 78rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
.textarea:focus,
|
||||||
|
.picker:active {
|
||||||
|
border-color: #1463ff;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(20, 99, 255, 0.06),
|
||||||
|
0 5rpx 0 rgba(144, 178, 235, 0.58),
|
||||||
|
0 10rpx 18rpx rgba(20, 99, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 132rpx;
|
||||||
|
padding-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 78rpx 24rpx;
|
||||||
|
border: 1rpx dashed #cfd9e6;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
color: #6b778c;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(205, 216, 229, 0.45),
|
||||||
|
0 16rpx 24rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1rpx;
|
||||||
|
margin: 22rpx 0;
|
||||||
|
background: #edf1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-danger {
|
||||||
|
color: #c5221f;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-success {
|
||||||
|
color: #087443;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-reason {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 4rpx;
|
||||||
|
color: #c5221f;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: super;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.55);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.82);
|
||||||
|
background: rgba(255, 247, 232, 0.88);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-person-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.58);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.86);
|
||||||
|
background: rgba(255, 247, 232, 0.9);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous-die-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.58);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.86);
|
||||||
|
background: rgba(255, 247, 232, 0.9);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lower-watermark,
|
||||||
|
.misc-watermark.lower-watermark,
|
||||||
|
.cleaning-watermark.lower-watermark,
|
||||||
|
.multi-person-watermark.lower-watermark,
|
||||||
|
.continuous-die-watermark.lower-watermark {
|
||||||
|
top: 78rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.third-watermark,
|
||||||
|
.misc-watermark.third-watermark,
|
||||||
|
.cleaning-watermark.third-watermark,
|
||||||
|
.multi-person-watermark.third-watermark,
|
||||||
|
.continuous-die-watermark.third-watermark {
|
||||||
|
top: 138rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeout-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 360rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
border: 2rpx solid rgba(197, 34, 31, 0.72);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(148, 27, 23, 0.92);
|
||||||
|
background: rgba(255, 238, 238, 0.92);
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
transform: rotate(8deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.system-auto {
|
||||||
|
border-color: #d92d20;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 13rpx 0 rgba(217, 45, 32, 0.42),
|
||||||
|
0 24rpx 36rpx rgba(197, 34, 31, 0.18),
|
||||||
|
inset 0 0 0 1rpx rgba(217, 45, 32, 0.32),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.system-auto.expanded {
|
||||||
|
border-color: #d92d20;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 13rpx 0 rgba(217, 45, 32, 0.52),
|
||||||
|
0 24rpx 38rpx rgba(197, 34, 31, 0.22),
|
||||||
|
inset 0 0 0 1rpx rgba(217, 45, 32, 0.36),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.voided {
|
||||||
|
border-color: #98a2b3;
|
||||||
|
background: linear-gradient(180deg, #f0f2f5 0%, #e5e8ed 100%);
|
||||||
|
color: #667085;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.72),
|
||||||
|
0 13rpx 0 rgba(118, 130, 148, 0.56),
|
||||||
|
0 22rpx 32rpx rgba(22, 34, 50, 0.13),
|
||||||
|
inset 0 0 0 1rpx rgba(152, 162, 179, 0.35),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.voided .value,
|
||||||
|
.card.voided .detail-product,
|
||||||
|
.card.voided .detail-title {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voided-watermark,
|
||||||
|
.modified-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 3;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voided-watermark {
|
||||||
|
border: 2rpx solid rgba(102, 112, 133, 0.7);
|
||||||
|
color: rgba(71, 84, 103, 0.88);
|
||||||
|
background: rgba(242, 244, 247, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modified-watermark {
|
||||||
|
border: 2rpx solid rgba(20, 99, 255, 0.62);
|
||||||
|
color: rgba(20, 86, 194, 0.82);
|
||||||
|
background: rgba(233, 241, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.old-value {
|
||||||
|
color: #8a97a8;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-pair {
|
||||||
|
padding: 5rpx 10rpx;
|
||||||
|
border: 1rpx solid rgba(197, 34, 31, 0.28);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 239, 239, 0.72);
|
||||||
|
color: #9f1c19;
|
||||||
|
font-size: 21rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-bar {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-cell {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-cell.end .date-step-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-cell .picker {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-step-btn {
|
||||||
|
width: 84rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 2rpx solid #b8c7dd;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(184, 199, 221, 0.72),
|
||||||
|
0 10rpx 16rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-step-btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-step-btn:active {
|
||||||
|
transform: translateY(4rpx);
|
||||||
|
box-shadow:
|
||||||
|
0 2rpx 0 rgba(184, 199, 221, 0.82),
|
||||||
|
0 6rpx 12rpx rgba(22, 34, 50, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-action-group {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 112rpx;
|
||||||
|
height: 112rpx;
|
||||||
|
min-width: 112rpx;
|
||||||
|
min-height: 112rpx;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-filter-btn::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-filter-btn.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 #0d3f93,
|
||||||
|
0 14rpx 22rpx rgba(20, 99, 255, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-filter-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 68rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
|
||||||
|
color: #223044;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 6rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-filter-switch switch {
|
||||||
|
transform: scale(0.78);
|
||||||
|
transform-origin: right center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
97
docs/api.md
Normal file
97
docs/api.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# 嘉恒智能报工小程序接口边界
|
||||||
|
|
||||||
|
小程序端不能直连 MySQL。生产环境需要一个 HTTPS 后端服务,使用环境变量读取数据库连接信息,并调用微信服务端接口完成手机号解密、订阅通知和小程序码生成。
|
||||||
|
|
||||||
|
## Excel 字段
|
||||||
|
|
||||||
|
本次读取到的 `/Users/souplearn/Downloads/产品清单.xlsx` 表头为:
|
||||||
|
|
||||||
|
`项目号、型材号、产品名称、物料编码、物料名称、使用设备、工序、冲压方式、操作人数、不包含物料转运节拍、标准节拍`
|
||||||
|
|
||||||
|
导入产品时:
|
||||||
|
|
||||||
|
- `型材号` 保存到 `products.profile_no`。
|
||||||
|
- `使用设备` 保存到 `products.device_no`。
|
||||||
|
- `不包含物料转运节拍` 忽略,不进入小程序业务。
|
||||||
|
- `标准工作量` 如果 Excel 没有提供,由后端按 `28800 / 标准节拍` 生成 8 小时日标准量。
|
||||||
|
- Excel 中空白 `项目号` 建议沿用上一行项目号,导入前端也应展示导入预览和错误行。
|
||||||
|
|
||||||
|
## 推荐接口
|
||||||
|
|
||||||
|
### 登录
|
||||||
|
|
||||||
|
- `POST /api/auth/wechat-login`
|
||||||
|
- `GET /api/auth/me`:获取当前账号和同一手机号下的所有角色。
|
||||||
|
- `POST /api/auth/switch-role`:已登录账号切换当前角色,返回新的 token。
|
||||||
|
- 入参:`code`、`encryptedData`、`iv` 或新版本手机号授权凭证。
|
||||||
|
- 逻辑:获取手机号后,在 `personnel` 表匹配;多个手机号时返回人员表中存在的手机号供用户选择。
|
||||||
|
- 返回:当前人员、角色、token。
|
||||||
|
|
||||||
|
### 产品
|
||||||
|
|
||||||
|
- `POST /api/products/import`:上传《产品清单》Excel,解析并 upsert。
|
||||||
|
- `GET /api/products`:分页、关键词、设备号筛选。
|
||||||
|
- `POST /api/products`:新增或修改产品。
|
||||||
|
- `DELETE /api/products`:按 `project_no + product_name + device_no + process_name` 删除产品。
|
||||||
|
|
||||||
|
产品唯一键为:`项目号 + 产品名称 + 使用设备号 + 工序`。
|
||||||
|
|
||||||
|
### 人员
|
||||||
|
|
||||||
|
- `POST /api/people/import`:上传《人员清单》Excel,字段为电话号、姓名、角色。
|
||||||
|
- `GET /api/people`:分页和关键词筛选。
|
||||||
|
- `POST /api/people`:新增或修改人员。
|
||||||
|
- `DELETE /api/people/:phone?role=worker`:删除手机号下的某个角色。
|
||||||
|
|
||||||
|
人员口径:一个手机号绑定一个姓名,同一手机号可以有多个角色;登录时如果该手机号只有一个角色,直接登录,如果有多个角色,小程序会要求选择本次登录角色。
|
||||||
|
|
||||||
|
### 设备二维码
|
||||||
|
|
||||||
|
- `POST /api/devices/:deviceNo/qrcode`
|
||||||
|
- 后端调用微信 `getUnlimitedQRCode`,scene 建议为 `deviceNo=20%23`,page 为 `pages/clock/clock`。
|
||||||
|
- 返回可打印的小程序码图片地址。
|
||||||
|
|
||||||
|
### 扫码报工
|
||||||
|
|
||||||
|
- `GET /api/clock/state?deviceNo=20%23`
|
||||||
|
- `POST /api/clock/start`
|
||||||
|
- `POST /api/clock/switch-device`
|
||||||
|
- `POST /api/clock/finish`
|
||||||
|
|
||||||
|
状态规则:
|
||||||
|
|
||||||
|
- 没有 active session:只返回 `开始上班`。
|
||||||
|
- 有 active session 且设备没出现过:只返回 `换设备`。
|
||||||
|
- 有 active session 且设备出现过:只返回 `下班报工`。
|
||||||
|
- 点击 `下班报工` 后进入 reporting session,30 分钟内返回 `继续报工`,员工可继续填写原报工。
|
||||||
|
- 超过 30 分钟未提交时返回 `重新报工`,员工点击后更新下班时间为重新报工时间,再重新填写报工。
|
||||||
|
|
||||||
|
### 报工提交
|
||||||
|
|
||||||
|
- `GET /api/reports/draft?sessionId=...`:按设备号拉取产品候选项。
|
||||||
|
- `POST /api/reports`:员工确认提交,`report_date` 使用确认提交当天日期。
|
||||||
|
- 报工草稿和提交都会校验 30 分钟填写有效期;超时必须重新扫码点击 `重新报工`。
|
||||||
|
- `GET /api/reports/mine`:员工按日期查看自己的报工记录。
|
||||||
|
|
||||||
|
### 审核
|
||||||
|
|
||||||
|
- `GET /api/reviews/pending`:管理员待审核报工。
|
||||||
|
- `POST /api/reviews/:reportId/approve`:管理员通过,或修正明细后通过。
|
||||||
|
- `POST /api/reviews/:reportId/reject`:管理员驳回并写原因。
|
||||||
|
- `PATCH /api/reports/:reportId/break-minutes`:仅经理可修改休息间隔,必须写 `report_audit_logs`。
|
||||||
|
- `GET /api/reviews/mine`:管理员自己的审核记录,支持日期筛选。
|
||||||
|
|
||||||
|
### 经理看板
|
||||||
|
|
||||||
|
- `GET /api/dashboard/reports?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&page=1`
|
||||||
|
- 后端只统计 `approved` 报工,并按 `report_date + employee_phone` 合并。
|
||||||
|
- 返回实际节拍、标准节拍、节拍正负百分比、报工数量、标准工作量、工作量正负百分比。
|
||||||
|
|
||||||
|
## 关键计算口径
|
||||||
|
|
||||||
|
- 报工日期:员工点击提交后再确认的日期。
|
||||||
|
- 有效工时:`下班时间 - 上班时间 - 休息间隔`,休息间隔只能经理修改。
|
||||||
|
- 实际节拍:`有效工时秒数 / (成品数量 + 不良数量)`,报废计入不良数量。
|
||||||
|
- 标准节拍:按明细产出数量加权平均。
|
||||||
|
- 标准工作量:使用产品表导入或手动维护的 `标准工作量`,多条明细时累加。
|
||||||
|
- 工作量对比:`(成品数量 - 标准工作量) / 标准工作量`。
|
||||||
144
docs/database.sql
Normal file
144
docs/database.sql
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
-- 嘉恒智能报工小程序 MySQL schema
|
||||||
|
-- 建议 MySQL 8.0+,字符集统一 utf8mb4。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS personnel (
|
||||||
|
phone VARCHAR(20) NOT NULL COMMENT '电话号',
|
||||||
|
name VARCHAR(64) NOT NULL COMMENT '姓名',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS person_roles (
|
||||||
|
phone VARCHAR(20) NOT NULL COMMENT '电话号',
|
||||||
|
role ENUM('worker', 'admin', 'manager') NOT NULL COMMENT '角色',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (phone, role),
|
||||||
|
CONSTRAINT fk_person_roles_person FOREIGN KEY (phone) REFERENCES personnel(phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员角色表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
project_no VARCHAR(64) NOT NULL COMMENT '项目号',
|
||||||
|
product_name VARCHAR(255) NOT NULL COMMENT '产品名称',
|
||||||
|
profile_no VARCHAR(128) NULL COMMENT '型材号,来自现有产品清单',
|
||||||
|
material_code VARCHAR(128) NULL COMMENT '物料编码',
|
||||||
|
material_name VARCHAR(255) NULL COMMENT '物料名称',
|
||||||
|
supplier VARCHAR(255) NULL COMMENT '供应商',
|
||||||
|
device_no VARCHAR(64) NOT NULL COMMENT '使用设备号',
|
||||||
|
process_name VARCHAR(128) NOT NULL COMMENT '工序',
|
||||||
|
stamping_method VARCHAR(128) NULL COMMENT '冲压方式',
|
||||||
|
operator_count DECIMAL(10,2) NOT NULL DEFAULT 1 COMMENT '操作人数',
|
||||||
|
standard_beat DECIMAL(12,3) NOT NULL COMMENT '标准节拍,秒/件',
|
||||||
|
standard_workload DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '标准工作量,建议按8小时折算',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (project_no, product_name, device_no, process_name),
|
||||||
|
KEY idx_products_device_no (device_no),
|
||||||
|
KEY idx_products_material_code (material_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work_sessions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
employee_phone VARCHAR(20) NOT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NULL,
|
||||||
|
status ENUM('active', 'reporting', 'submitted', 'cancelled') NOT NULL DEFAULT 'active',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_sessions_employee_status (employee_phone, status),
|
||||||
|
CONSTRAINT fk_sessions_employee FOREIGN KEY (employee_phone) REFERENCES personnel(phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='扫码上班流程';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS work_session_devices (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
session_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
device_no VARCHAR(64) NOT NULL,
|
||||||
|
scanned_at DATETIME NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_session_device (session_id, device_no),
|
||||||
|
KEY idx_session_devices_device (device_no),
|
||||||
|
CONSTRAINT fk_session_devices_session FOREIGN KEY (session_id) REFERENCES work_sessions(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一次报工流程中的设备序列';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS production_reports (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
session_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
employee_phone VARCHAR(20) NOT NULL,
|
||||||
|
report_date DATE NOT NULL COMMENT '以员工确认提交日期为准',
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
duration_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
break_minutes DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '休息间隔,仅经理可改',
|
||||||
|
effective_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
total_good_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
total_output_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
actual_beat DECIMAL(12,3) NOT NULL DEFAULT 0,
|
||||||
|
standard_beat DECIMAL(12,3) NOT NULL DEFAULT 0,
|
||||||
|
expected_workload DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
pace_rate DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
workload_rate DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
reviewer_phone VARCHAR(20) NULL,
|
||||||
|
reviewed_at DATETIME NULL,
|
||||||
|
reject_reason VARCHAR(500) NULL,
|
||||||
|
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_report_session (session_id),
|
||||||
|
KEY idx_reports_employee_date (employee_phone, report_date),
|
||||||
|
KEY idx_reports_status_date (status, report_date),
|
||||||
|
KEY idx_reports_reviewer (reviewer_phone, reviewed_at),
|
||||||
|
CONSTRAINT fk_reports_session FOREIGN KEY (session_id) REFERENCES work_sessions(id),
|
||||||
|
CONSTRAINT fk_reports_employee FOREIGN KEY (employee_phone) REFERENCES personnel(phone),
|
||||||
|
CONSTRAINT fk_reports_reviewer FOREIGN KEY (reviewer_phone) REFERENCES personnel(phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报工主表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS production_report_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
report_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
device_no VARCHAR(64) NOT NULL,
|
||||||
|
project_no VARCHAR(64) NOT NULL,
|
||||||
|
product_name VARCHAR(255) NOT NULL,
|
||||||
|
material_code VARCHAR(128) NULL,
|
||||||
|
material_name VARCHAR(255) NULL,
|
||||||
|
process_name VARCHAR(128) NULL,
|
||||||
|
standard_beat DECIMAL(12,3) NOT NULL DEFAULT 0,
|
||||||
|
standard_workload DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
good_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
defect_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
scrap_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
allocated_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_report_items_report (report_id),
|
||||||
|
KEY idx_report_items_product (project_no, product_name),
|
||||||
|
CONSTRAINT fk_report_items_report FOREIGN KEY (report_id) REFERENCES production_reports(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报工明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS report_audit_logs (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
report_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
reviewer_phone VARCHAR(20) NOT NULL,
|
||||||
|
action ENUM('approve', 'approve_with_correction', 'reject', 'manager_break_update') NOT NULL,
|
||||||
|
before_json JSON NULL,
|
||||||
|
after_json JSON NULL,
|
||||||
|
remark VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_audit_report (report_id),
|
||||||
|
KEY idx_audit_reviewer (reviewer_phone, created_at),
|
||||||
|
CONSTRAINT fk_audit_report FOREIGN KEY (report_id) REFERENCES production_reports(id),
|
||||||
|
CONSTRAINT fk_audit_reviewer FOREIGN KEY (reviewer_phone) REFERENCES personnel(phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_qrcodes (
|
||||||
|
device_no VARCHAR(64) NOT NULL,
|
||||||
|
qr_scene VARCHAR(128) NOT NULL,
|
||||||
|
qr_url VARCHAR(500) NULL,
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (device_no),
|
||||||
|
CONSTRAINT fk_qrcode_creator FOREIGN KEY (created_by) REFERENCES personnel(phone)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备二维码';
|
||||||
9
docs/env.example
Normal file
9
docs/env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
MYSQL_HOST=121.40.97.156
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=jiaheng_erp
|
||||||
|
MYSQL_USER=root
|
||||||
|
MYSQL_PASSWORD=change_me
|
||||||
|
|
||||||
|
WECHAT_APP_ID=your_app_id
|
||||||
|
WECHAT_APP_SECRET=your_app_secret
|
||||||
|
JWT_SECRET=change_me
|
||||||
1167
docs/superpowers/plans/2026-06-23-continuous-die-changeover-count.md
Normal file
1167
docs/superpowers/plans/2026-06-23-continuous-die-changeover-count.md
Normal file
File diff suppressed because it is too large
Load Diff
1689
docs/superpowers/plans/2026-06-23-usage-stats.md
Normal file
1689
docs/superpowers/plans/2026-06-23-usage-stats.md
Normal file
File diff suppressed because it is too large
Load Diff
280
pages/cleaningReport/cleaningReport.js
Normal file
280
pages/cleaningReport/cleaningReport.js
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
const locationGuard = require('../../utils/locationGuard')
|
||||||
|
|
||||||
|
const decodeRepeated = value => {
|
||||||
|
let text = String(value || '').trim()
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(text)
|
||||||
|
if (decoded === text) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = decoded
|
||||||
|
} catch (error) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemKey = item => `${item.attendancePointName || ''}||${item.moldName || item.productName || item.name}||${item.processName || ''}`
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
items: [],
|
||||||
|
attendancePointName: '',
|
||||||
|
equipmentOptions: [],
|
||||||
|
equipmentLabels: [],
|
||||||
|
filling: false,
|
||||||
|
submitting: false,
|
||||||
|
deviceModalVisible: false,
|
||||||
|
activeDeviceIndex: -1,
|
||||||
|
deviceSelectOptions: [],
|
||||||
|
pendingDeviceNos: [],
|
||||||
|
currentLocation: null,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
const user = api.getCurrentUser()
|
||||||
|
if (!user || user.role !== 'worker') {
|
||||||
|
wx.showToast({ title: '请先用冲压工人登录', icon: 'none' })
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.reLaunch({ url: '/pages/index/index' })
|
||||||
|
}, 800)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attendancePointName = decodeRepeated(options.attendancePointName || '')
|
||||||
|
this.setData({ attendancePointName })
|
||||||
|
this.loadCleaningEquipment(attendancePointName)
|
||||||
|
this.addResolvedItem({
|
||||||
|
attendancePointName,
|
||||||
|
moldName: decodeRepeated(options.moldName || ''),
|
||||||
|
processName: decodeRepeated(options.processName || ''),
|
||||||
|
stampingMethod: decodeRepeated(options.stampingMethod || ''),
|
||||||
|
isCleaning: options.isCleaning === '1',
|
||||||
|
displayName: decodeRepeated(options.displayName || ''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addResolvedItem(raw) {
|
||||||
|
const item = {
|
||||||
|
attendancePointName: String(raw.attendancePointName || this.data.attendancePointName || '').trim(),
|
||||||
|
moldName: String(raw.moldName || raw.name || '').trim(),
|
||||||
|
processName: String(raw.processName || '').trim(),
|
||||||
|
stampingMethod: String(raw.stampingMethod || '').trim(),
|
||||||
|
isCleaning: !!raw.isCleaning || String(raw.stampingMethod || '').trim() === '清洗',
|
||||||
|
displayName: raw.displayName || api.moldDisplayName(raw.moldName || raw.name, raw.processName, raw.stampingMethod),
|
||||||
|
deviceNo: '',
|
||||||
|
deviceNos: [],
|
||||||
|
quantity: '',
|
||||||
|
}
|
||||||
|
if (!item.moldName) {
|
||||||
|
wx.showToast({ title: '未识别清洗产品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!item.processName) {
|
||||||
|
wx.showToast({ title: '未识别工序', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!item.isCleaning) {
|
||||||
|
wx.showToast({ title: '只能添加冲压方式为清洗的二维码', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.data.attendancePointName && item.attendancePointName !== this.data.attendancePointName) {
|
||||||
|
wx.showToast({ title: '只能添加同一考勤点的清洗产品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exists = this.data.items.some(existing => itemKey(existing) === itemKey(item))
|
||||||
|
if (exists) {
|
||||||
|
wx.showToast({ title: '该清洗产品已添加', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
items: this.data.items.concat(item),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async loadCleaningEquipment(attendancePointName = this.data.attendancePointName) {
|
||||||
|
try {
|
||||||
|
const result = await api.listEquipment({
|
||||||
|
attendancePointName,
|
||||||
|
deviceType: '清洗设备',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
})
|
||||||
|
const equipmentOptions = result.rows || []
|
||||||
|
this.setData({
|
||||||
|
equipmentOptions,
|
||||||
|
equipmentLabels: equipmentOptions.map(item => item.displayName || item.deviceNo),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载清洗设备失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractScene(scanResult) {
|
||||||
|
const raw = decodeRepeated(scanResult)
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const patterns = [
|
||||||
|
/(?:^|[?&])scene=([^&]+)/i,
|
||||||
|
/(?:^|[?&])mold=([^&]+)/i,
|
||||||
|
]
|
||||||
|
for (let index = 0; index < patterns.length; index += 1) {
|
||||||
|
const matched = raw.match(patterns[index])
|
||||||
|
if (matched && matched[1]) {
|
||||||
|
const value = decodeRepeated(matched[1])
|
||||||
|
return value.startsWith('mold=') ? value : `mold=${value}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw.startsWith('mold=') ? raw : ''
|
||||||
|
},
|
||||||
|
async addByScanResult(scanResult) {
|
||||||
|
const scene = this.extractScene(scanResult)
|
||||||
|
if (!scene) {
|
||||||
|
wx.showToast({ title: '未识别清洗二维码', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = await api.resolveMoldScene(scene)
|
||||||
|
await locationGuard.ensureFactoryLocation(resolved.attendancePointName)
|
||||||
|
this.addResolvedItem(resolved)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '二维码解析失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startFill() {
|
||||||
|
if (!this.data.items.length) {
|
||||||
|
wx.showToast({ title: '请先添加清洗产品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ filling: true })
|
||||||
|
},
|
||||||
|
async continueAdd() {
|
||||||
|
try {
|
||||||
|
await locationGuard.ensureFactoryLocation(this.data.attendancePointName)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '无法继续扫码',
|
||||||
|
content: error.message || '当前位置不在允许范围内',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.scanCode({
|
||||||
|
onlyFromCamera: false,
|
||||||
|
success: res => {
|
||||||
|
const candidates = [res.path, res.result, res.rawData]
|
||||||
|
const raw = candidates.find(item => item) || ''
|
||||||
|
this.addByScanResult(raw)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
abandon() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '放弃报工',
|
||||||
|
content: '确认放弃本次清洗报工?',
|
||||||
|
confirmText: '放弃',
|
||||||
|
confirmColor: '#d92d20',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.reLaunch({ url: '/pages/index/index' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onQuantityInput(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
this.setData({
|
||||||
|
[`items[${index}].quantity`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
openDeviceSelector(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const item = this.data.items[index]
|
||||||
|
if (!item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.data.equipmentOptions.length) {
|
||||||
|
wx.showToast({ title: '暂无清洗设备', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selected = new Set(item.deviceNos && item.deviceNos.length ? item.deviceNos : (item.deviceNo ? String(item.deviceNo).split('、') : []))
|
||||||
|
const deviceSelectOptions = this.data.equipmentOptions.map(equipment => ({
|
||||||
|
...equipment,
|
||||||
|
checked: selected.has(equipment.deviceNo),
|
||||||
|
}))
|
||||||
|
this.setData({
|
||||||
|
activeDeviceIndex: index,
|
||||||
|
pendingDeviceNos: Array.from(selected).filter(Boolean),
|
||||||
|
deviceSelectOptions,
|
||||||
|
deviceModalVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeDeviceSelector() {
|
||||||
|
this.setData({
|
||||||
|
deviceModalVisible: false,
|
||||||
|
activeDeviceIndex: -1,
|
||||||
|
deviceSelectOptions: [],
|
||||||
|
pendingDeviceNos: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeviceSelectionChange(e) {
|
||||||
|
const pendingDeviceNos = e.detail.value || []
|
||||||
|
const selected = new Set(pendingDeviceNos)
|
||||||
|
this.setData({
|
||||||
|
pendingDeviceNos,
|
||||||
|
deviceSelectOptions: this.data.deviceSelectOptions.map(item => ({
|
||||||
|
...item,
|
||||||
|
checked: selected.has(item.deviceNo),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
confirmDeviceSelection() {
|
||||||
|
const index = Number(this.data.activeDeviceIndex)
|
||||||
|
if (index < 0 || !this.data.items[index]) {
|
||||||
|
this.closeDeviceSelector()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.data.pendingDeviceNos.length) {
|
||||||
|
wx.showToast({ title: '请至少选择一台清洗设备', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deviceNos = this.data.pendingDeviceNos
|
||||||
|
this.setData({
|
||||||
|
[`items[${index}].deviceNos`]: deviceNos,
|
||||||
|
[`items[${index}].deviceNo`]: deviceNos.join('、'),
|
||||||
|
deviceModalVisible: false,
|
||||||
|
activeDeviceIndex: -1,
|
||||||
|
deviceSelectOptions: [],
|
||||||
|
pendingDeviceNos: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (this.data.submitting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const missingDevice = this.data.items.find(item => !item.deviceNo)
|
||||||
|
if (missingDevice) {
|
||||||
|
wx.showToast({ title: `${missingDevice.moldName}请选择清洗设备号`, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invalid = this.data.items.find(item => Number(item.quantity || 0) <= 0)
|
||||||
|
if (invalid) {
|
||||||
|
wx.showToast({ title: `${invalid.moldName}请填写清洗数量`, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
wx.showLoading({ title: '提交中' })
|
||||||
|
try {
|
||||||
|
const locationResult = await locationGuard.ensureFactoryLocation(this.data.attendancePointName)
|
||||||
|
const report = await api.submitCleaningReport(this.data.items, locationResult.current)
|
||||||
|
wx.reLaunch({
|
||||||
|
url: `/pages/reportResult/reportResult?reportId=${report.id}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '提交失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/cleaningReport/cleaningReport.json
Normal file
3
pages/cleaningReport/cleaningReport.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "清洗报工"
|
||||||
|
}
|
||||||
94
pages/cleaningReport/cleaningReport.wxml
Normal file
94
pages/cleaningReport/cleaningReport.wxml
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<scroll-view class="page safe-bottom" scroll-y type="list">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">清洗报工</text>
|
||||||
|
<text class="subtitle">添加本次清洗产品,只填写清洗数量,不计算工时</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="label">已添加清洗产品</text>
|
||||||
|
<text class="value">{{items.length}} 项</text>
|
||||||
|
<text wx:if="{{attendancePointName}}" class="subtitle">考勤点 {{attendancePointName}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary">清洗</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!items.length}}" class="list-empty">暂无清洗产品</view>
|
||||||
|
<view wx:for="{{items}}" wx:key="displayName" wx:for-index="index" class="card cleaning-card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.displayName || item.moldName}}</text>
|
||||||
|
<text wx:if="{{item.attendancePointName}}" class="subtitle">考勤点 {{item.attendancePointName}}</text>
|
||||||
|
<text class="subtitle">工序 {{item.processName}} · 冲压方式 {{item.stampingMethod || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill success">已添加</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{filling}}" class="form-item">
|
||||||
|
<text class="label">清洗设备号</text>
|
||||||
|
<view
|
||||||
|
wx:if="{{equipmentOptions.length}}"
|
||||||
|
class="picker multi-device-picker {{item.deviceNo ? '' : 'field-error'}}"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="openDeviceSelector"
|
||||||
|
>
|
||||||
|
{{item.deviceNo || '请选择清洗设备号(可多选)'}}
|
||||||
|
</view>
|
||||||
|
<view wx:else class="readonly">暂无清洗设备,请先在设备管理中维护</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{filling}}" class="form-item">
|
||||||
|
<text class="label">清洗数量</text>
|
||||||
|
<input
|
||||||
|
class="input number-input"
|
||||||
|
type="digit"
|
||||||
|
value="{{item.quantity}}"
|
||||||
|
placeholder="请输入清洗数量"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindinput="onQuantityInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!filling}}" class="cleaning-actions">
|
||||||
|
<button class="btn primary full" bindtap="startFill">开始报工</button>
|
||||||
|
<button class="btn secondary full" bindtap="continueAdd">继续添加清洗产品</button>
|
||||||
|
<button class="btn danger full" bindtap="abandon">放弃报工</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="cleaning-actions">
|
||||||
|
<button class="btn secondary full" bindtap="continueAdd">继续添加清洗产品</button>
|
||||||
|
<button class="btn primary full" disabled="{{submitting}}" bindtap="submit">{{submitting ? '提交中' : '提交清洗报工'}}</button>
|
||||||
|
<button class="btn danger full" bindtap="abandon">放弃报工</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{deviceModalVisible}}" class="modal-mask" catchtap="closeDeviceSelector">
|
||||||
|
<view class="modal-panel device-modal-panel" catchtap="noop">
|
||||||
|
<view class="device-modal-head">
|
||||||
|
<view>
|
||||||
|
<text class="device-modal-title">选择清洗设备</text>
|
||||||
|
<text class="subtitle">已选择 {{pendingDeviceNos.length}} 台</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<checkbox-group bindchange="onDeviceSelectionChange">
|
||||||
|
<scroll-view class="device-option-list" scroll-y>
|
||||||
|
<label
|
||||||
|
wx:for="{{deviceSelectOptions}}"
|
||||||
|
wx:key="deviceNo"
|
||||||
|
class="device-option {{item.checked ? 'checked' : ''}}"
|
||||||
|
>
|
||||||
|
<checkbox value="{{item.deviceNo}}" checked="{{item.checked}}" />
|
||||||
|
<view class="device-option-text">
|
||||||
|
<text class="device-option-title">{{item.deviceNo}}</text>
|
||||||
|
<text wx:if="{{item.remark}}" class="device-option-subtitle">{{item.remark}}</text>
|
||||||
|
</view>
|
||||||
|
</label>
|
||||||
|
</scroll-view>
|
||||||
|
</checkbox-group>
|
||||||
|
<view class="device-modal-actions">
|
||||||
|
<button class="btn secondary full" bindtap="closeDeviceSelector">取消</button>
|
||||||
|
<button class="btn primary full" bindtap="confirmDeviceSelection">确定</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
142
pages/cleaningReport/cleaningReport.wxss
Normal file
142
pages/cleaningReport/cleaningReport.wxss
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
.cleaning-card {
|
||||||
|
border-left: 6rpx solid #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-card .form-item {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
padding-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-actions .btn {
|
||||||
|
width: 88%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-device-picker {
|
||||||
|
min-height: 84rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding-top: 22rpx;
|
||||||
|
padding-bottom: 22rpx;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-device-picker.field-error {
|
||||||
|
border-color: #e5484d;
|
||||||
|
background: linear-gradient(180deg, #fff7f7 0%, #fff1f1 100%);
|
||||||
|
color: #b42318;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(180, 35, 24, 0.08),
|
||||||
|
0 0 0 4rpx rgba(229, 72, 77, 0.12),
|
||||||
|
0 6rpx 0 rgba(255, 197, 197, 0.65),
|
||||||
|
0 10rpx 18rpx rgba(180, 35, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 22rpx calc(env(safe-area-inset-bottom) + 30rpx);
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 86vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-modal-title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option-list {
|
||||||
|
height: 56vh;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
border: 2rpx solid #b8c6dc;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(174, 188, 208, 0.46),
|
||||||
|
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #e6edf6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option.checked {
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-option-subtitle {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-modal-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
612
pages/clock/clock.js
Normal file
612
pages/clock/clock.js
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
const locationGuard = require('../../utils/locationGuard')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
user: null,
|
||||||
|
moldName: '',
|
||||||
|
processName: '',
|
||||||
|
attendancePointName: '',
|
||||||
|
stampingMethod: '',
|
||||||
|
isCleaning: false,
|
||||||
|
isMisc: false,
|
||||||
|
moldDisplayName: '',
|
||||||
|
sceneToken: '',
|
||||||
|
state: null,
|
||||||
|
roleChoices: [],
|
||||||
|
selectionToken: '',
|
||||||
|
toastVisible: false,
|
||||||
|
toastMessage: '',
|
||||||
|
locationChecking: true,
|
||||||
|
locationAllowed: false,
|
||||||
|
locationMessage: '正在校验当前位置',
|
||||||
|
currentLocation: null,
|
||||||
|
occupiedDetail: null,
|
||||||
|
temporaryWorkerModalVisible: false,
|
||||||
|
temporaryWorkerPrompt: '',
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
},
|
||||||
|
toastTimer: null,
|
||||||
|
onLoad(options) {
|
||||||
|
this.resolveOpenMold(options)
|
||||||
|
},
|
||||||
|
async resolveOpenMold(options) {
|
||||||
|
const scene = this.decodeRepeated(options.scene || '')
|
||||||
|
let moldName = this.extractMoldName(options, scene)
|
||||||
|
let processName = this.extractProcessName(options, scene)
|
||||||
|
let attendancePointName = this.decodeRepeated(options.attendancePointName || options.attendance_point_name || '')
|
||||||
|
let stampingMethod = this.decodeRepeated(options.stampingMethod || options.stamping_method || '')
|
||||||
|
let isCleaning = false
|
||||||
|
let isMisc = false
|
||||||
|
let moldDisplayName = api.moldDisplayName(moldName, processName, stampingMethod)
|
||||||
|
const sceneToken = this.extractSceneToken(scene || options.moldName || '')
|
||||||
|
if (!moldName && sceneToken && api.getCurrentUser()) {
|
||||||
|
try {
|
||||||
|
const resolved = await api.resolveMoldScene(sceneToken)
|
||||||
|
moldName = resolved.moldName
|
||||||
|
processName = resolved.processName
|
||||||
|
attendancePointName = resolved.attendancePointName || ''
|
||||||
|
stampingMethod = resolved.stampingMethod || ''
|
||||||
|
isCleaning = !!resolved.isCleaning
|
||||||
|
isMisc = !!resolved.isMisc
|
||||||
|
moldDisplayName = resolved.displayName
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message || '未找到模具二维码')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setData({ moldName, processName, attendancePointName, stampingMethod, isCleaning, isMisc, moldDisplayName, sceneToken })
|
||||||
|
this.verifyOpenLocation()
|
||||||
|
},
|
||||||
|
async resolveSceneAfterLogin() {
|
||||||
|
if (this.data.moldName || !this.data.sceneToken) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = await api.resolveMoldScene(this.data.sceneToken)
|
||||||
|
this.setData({
|
||||||
|
moldName: resolved.moldName,
|
||||||
|
processName: resolved.processName,
|
||||||
|
attendancePointName: resolved.attendancePointName || '',
|
||||||
|
stampingMethod: resolved.stampingMethod || '',
|
||||||
|
isCleaning: !!resolved.isCleaning,
|
||||||
|
isMisc: !!resolved.isMisc,
|
||||||
|
moldDisplayName: resolved.displayName,
|
||||||
|
})
|
||||||
|
await this.verifyOpenLocation()
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message || '未找到模具二维码')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extractMoldName(options, scene) {
|
||||||
|
const explicit = options.moldName || options.deviceNo
|
||||||
|
if (explicit) {
|
||||||
|
const value = this.decodeRepeated(explicit)
|
||||||
|
return value.startsWith('mold=') ? '' : value
|
||||||
|
}
|
||||||
|
const raw = this.decodeRepeated(scene)
|
||||||
|
const moldMatched = raw.match(/(?:^|[?&])?moldName=([^&]+)/)
|
||||||
|
if (moldMatched && moldMatched[1]) {
|
||||||
|
return this.decodeRepeated(moldMatched[1])
|
||||||
|
}
|
||||||
|
const oldMatched = raw.match(/(?:^|[?&])?deviceNo=([^&]+)/)
|
||||||
|
if (oldMatched && oldMatched[1]) {
|
||||||
|
return this.decodeRepeated(oldMatched[1])
|
||||||
|
}
|
||||||
|
if (raw.startsWith('mold=')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
},
|
||||||
|
extractProcessName(options, scene) {
|
||||||
|
const explicit = options.processName || options.process_name
|
||||||
|
if (explicit) {
|
||||||
|
return this.decodeRepeated(explicit)
|
||||||
|
}
|
||||||
|
const raw = this.decodeRepeated(scene)
|
||||||
|
const matched = raw.match(/(?:^|[?&])?(?:processName|process_name)=([^&]+)/)
|
||||||
|
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
|
||||||
|
},
|
||||||
|
extractSceneToken(scene) {
|
||||||
|
const raw = this.decodeRepeated(scene)
|
||||||
|
const matched = raw.match(/(?:^|[?&])?mold=([^&]+)/)
|
||||||
|
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
|
||||||
|
},
|
||||||
|
decodeRepeated(value) {
|
||||||
|
let text = String(value || '').trim()
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(text)
|
||||||
|
if (decoded === text) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = decoded
|
||||||
|
} catch (error) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
isCleaningProcess() {
|
||||||
|
return !!this.data.isCleaning
|
||||||
|
|| String(this.data.stampingMethod || '').trim() === '清洗'
|
||||||
|
},
|
||||||
|
openCleaningReport() {
|
||||||
|
const moldName = encodeURIComponent(this.data.moldName || '')
|
||||||
|
const processName = encodeURIComponent(this.data.processName || '')
|
||||||
|
const stampingMethod = encodeURIComponent(this.data.stampingMethod || '')
|
||||||
|
const displayName = encodeURIComponent(this.data.moldDisplayName || '')
|
||||||
|
const attendancePointName = encodeURIComponent(this.data.attendancePointName || '')
|
||||||
|
wx.redirectTo({
|
||||||
|
url: `/pages/cleaningReport/cleaningReport?moldName=${moldName}&processName=${processName}&attendancePointName=${attendancePointName}&stampingMethod=${stampingMethod}&isCleaning=1&displayName=${displayName}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
if (this.data.locationAllowed) {
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
if (this.toastTimer) {
|
||||||
|
clearTimeout(this.toastTimer)
|
||||||
|
this.toastTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showTip(message, duration = 5000) {
|
||||||
|
if (this.toastTimer) {
|
||||||
|
clearTimeout(this.toastTimer)
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
toastVisible: true,
|
||||||
|
toastMessage: String(message || ''),
|
||||||
|
})
|
||||||
|
this.toastTimer = setTimeout(() => {
|
||||||
|
this.setData({ toastVisible: false, toastMessage: '' })
|
||||||
|
this.toastTimer = null
|
||||||
|
}, duration)
|
||||||
|
},
|
||||||
|
async verifyOpenLocation() {
|
||||||
|
this.setData({
|
||||||
|
locationChecking: true,
|
||||||
|
locationAllowed: false,
|
||||||
|
locationMessage: '正在校验当前位置',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const locationScope = api.getCurrentUser() ? 'accessible' : 'public'
|
||||||
|
const locationResult = await locationGuard.ensureFactoryLocation(this.data.attendancePointName, locationScope)
|
||||||
|
this.setData({
|
||||||
|
locationChecking: false,
|
||||||
|
locationAllowed: true,
|
||||||
|
locationMessage: '',
|
||||||
|
currentLocation: locationResult.current,
|
||||||
|
})
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({
|
||||||
|
locationChecking: false,
|
||||||
|
locationAllowed: false,
|
||||||
|
locationMessage: error.message || '当前位置不在允许范围内,不能扫码报工',
|
||||||
|
currentLocation: null,
|
||||||
|
user: null,
|
||||||
|
state: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
let user = api.getCurrentUser()
|
||||||
|
let state = null
|
||||||
|
this.setData({ occupiedDetail: null })
|
||||||
|
if (user && !this.data.moldName && this.data.sceneToken) {
|
||||||
|
const shouldContinue = await this.resolveSceneAfterLogin()
|
||||||
|
if (!shouldContinue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user && this.data.moldName) {
|
||||||
|
try {
|
||||||
|
user = await api.refreshCurrentUser() || user
|
||||||
|
if (this.isCleaningProcess()) {
|
||||||
|
this.setData({ user, state: null }, () => {
|
||||||
|
this.openCleaningReport()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = await api.getClockState(
|
||||||
|
this.data.moldName,
|
||||||
|
this.data.processName,
|
||||||
|
this.data.attendancePointName,
|
||||||
|
this.data.currentLocation,
|
||||||
|
)
|
||||||
|
if (state && state.isCleaning) {
|
||||||
|
this.setData({
|
||||||
|
user,
|
||||||
|
state: null,
|
||||||
|
stampingMethod: state.stampingMethod || '',
|
||||||
|
isCleaning: true,
|
||||||
|
isMisc: false,
|
||||||
|
}, () => {
|
||||||
|
this.openCleaningReport()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state && state.isMisc) {
|
||||||
|
this.setData({
|
||||||
|
stampingMethod: state.stampingMethod || '',
|
||||||
|
isMisc: true,
|
||||||
|
moldDisplayName: state.displayName || this.data.moldDisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (await this.handleMoldOccupiedError(error)) {
|
||||||
|
user = api.getCurrentUser()
|
||||||
|
this.setData({ user, state: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.handleUnfinishedReportError(error)) {
|
||||||
|
user = api.getCurrentUser()
|
||||||
|
this.setData({ user, state: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.showTip(error.message)
|
||||||
|
user = api.getCurrentUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setData({ user, state })
|
||||||
|
},
|
||||||
|
async wechatLogin(e) {
|
||||||
|
const phoneCode = e.detail && e.detail.code
|
||||||
|
if (!phoneCode) {
|
||||||
|
this.showTip('未获得手机号授权', 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '登录中' })
|
||||||
|
try {
|
||||||
|
const result = await api.loginWithPhoneCode(phoneCode)
|
||||||
|
if (result.needsTemporaryWorker) {
|
||||||
|
wx.hideLoading()
|
||||||
|
await this.confirmTemporaryWorker(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.needsSelection) {
|
||||||
|
this.setData({
|
||||||
|
roleChoices: result.matchedPeople,
|
||||||
|
selectionToken: result.selectionToken,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message)
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmTemporaryWorker(result) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.temporaryWorkerResult = result
|
||||||
|
this.temporaryWorkerResolve = resolve
|
||||||
|
this.setData({
|
||||||
|
temporaryWorkerModalVisible: true,
|
||||||
|
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTemporaryWorkerNameInput(e) {
|
||||||
|
this.setData({ temporaryWorkerName: e.detail.value })
|
||||||
|
},
|
||||||
|
closeTemporaryWorkerModal() {
|
||||||
|
this.setData({
|
||||||
|
temporaryWorkerModalVisible: false,
|
||||||
|
temporaryWorkerPrompt: '',
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
})
|
||||||
|
this.temporaryWorkerResult = null
|
||||||
|
if (this.temporaryWorkerResolve) {
|
||||||
|
this.temporaryWorkerResolve()
|
||||||
|
this.temporaryWorkerResolve = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitTemporaryWorker() {
|
||||||
|
const result = this.temporaryWorkerResult
|
||||||
|
const name = String(this.data.temporaryWorkerName || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
this.showTip('请输入姓名', 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result || !result.temporaryToken) {
|
||||||
|
this.showTip('临时工注册信息已失效,请重新登录')
|
||||||
|
this.closeTemporaryWorkerModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ temporaryWorkerModalVisible: false })
|
||||||
|
wx.showLoading({ title: '定位中' })
|
||||||
|
try {
|
||||||
|
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
|
||||||
|
wx.showLoading({ title: '创建中' })
|
||||||
|
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
|
||||||
|
await this.load()
|
||||||
|
wx.showToast({ title: '临时工已生效', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message)
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.closeTemporaryWorkerModal()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async chooseRole(e) {
|
||||||
|
const role = e.currentTarget.dataset.role
|
||||||
|
wx.showLoading({ title: '登录中' })
|
||||||
|
try {
|
||||||
|
await api.selectRole(this.data.selectionToken, role)
|
||||||
|
this.setData({ roleChoices: [], selectionToken: '' })
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message)
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canOperate() {
|
||||||
|
const { user, moldName, state } = this.data
|
||||||
|
if (!user) {
|
||||||
|
this.showTip('请先登录', 3000)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (user.role !== 'worker') {
|
||||||
|
this.showTip('只有冲压工人可以报工', 3000)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!moldName) {
|
||||||
|
this.showTip('缺少模具名称', 3000)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
this.showTip('当前模具码不可用', 3000)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
formatAutoSubmitHours(value) {
|
||||||
|
const hours = Number(value || 15)
|
||||||
|
if (!Number.isFinite(hours)) {
|
||||||
|
return '15'
|
||||||
|
}
|
||||||
|
return Number.isInteger(hours) ? String(hours) : String(Math.round(hours * 100) / 100)
|
||||||
|
},
|
||||||
|
confirmStartWork() {
|
||||||
|
const hoursText = this.formatAutoSubmitHours(this.data.state && this.data.state.autoSubmitHours)
|
||||||
|
return new Promise(resolve => {
|
||||||
|
wx.showModal({
|
||||||
|
title: '开始上班',
|
||||||
|
content: `该班次时长最多为${hoursText}个小时。${hoursText}小时后未下班报工,系统将自动报工`,
|
||||||
|
confirmText: '开始上班',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: res => resolve(!!res.confirm),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async switchMold() {
|
||||||
|
if (!this.canOperate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.switchDevice(
|
||||||
|
this.data.moldName,
|
||||||
|
this.data.processName,
|
||||||
|
this.data.attendancePointName,
|
||||||
|
this.data.currentLocation,
|
||||||
|
)
|
||||||
|
wx.showToast({ title: '已记录换模具', icon: 'success' })
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
if (await this.handleMoldOccupiedError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.handleUnfinishedReportError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.showTip(error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async finishWork() {
|
||||||
|
if (!this.canOperate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = await api.finishWork(
|
||||||
|
this.data.moldName,
|
||||||
|
this.data.processName,
|
||||||
|
this.data.attendancePointName,
|
||||||
|
this.data.currentLocation,
|
||||||
|
)
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/reportForm/reportForm?sessionId=${session.id}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (await this.handleMoldOccupiedError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.handleUnfinishedReportError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.showTip(error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleAction() {
|
||||||
|
if (!this.canOperate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { moldName, state } = this.data
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (state.action === 'start') {
|
||||||
|
const confirmed = await this.confirmStartWork()
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await api.startWork(moldName, this.data.processName, this.data.attendancePointName, this.data.currentLocation)
|
||||||
|
wx.showToast({ title: '已开始上班', icon: 'success' })
|
||||||
|
await this.load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.action === 'switch') {
|
||||||
|
await this.switchMold()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.action === 'report' || (state.action === 'finish' && state.endAtText && state.session)) {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/reportForm/reportForm?sessionId=${state.session.id}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.action === 'refinish') {
|
||||||
|
await this.finishWork()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.finishWork()
|
||||||
|
} catch (error) {
|
||||||
|
if (await this.handleMoldOccupiedError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (await this.handleUnfinishedReportError(error)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.showTip(error.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolveUnfinishedReportDetail(error) {
|
||||||
|
if (!error || error.statusCode !== 400) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let detail = error.detail
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(detail)
|
||||||
|
} catch (parseError) {
|
||||||
|
detail = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail && detail.code === 'unfinished_report_mold_mismatch' ? detail : null
|
||||||
|
},
|
||||||
|
async openUnfinishedReport(detail) {
|
||||||
|
let sessionId = detail.session_id
|
||||||
|
if (detail.reporting_expired) {
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
const session = await api.finishWork(
|
||||||
|
this.data.moldName,
|
||||||
|
this.data.processName,
|
||||||
|
this.data.attendancePointName,
|
||||||
|
this.data.currentLocation,
|
||||||
|
)
|
||||||
|
sessionId = session.id
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/reportForm/reportForm?sessionId=${sessionId}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleUnfinishedReportError(error) {
|
||||||
|
const detail = this.resolveUnfinishedReportDetail(error)
|
||||||
|
if (!detail || !detail.session_id) {
|
||||||
|
return Promise.resolve(false)
|
||||||
|
}
|
||||||
|
const message = detail.message || error.message || '当前有未完成的报工'
|
||||||
|
return new Promise(resolve => {
|
||||||
|
wx.showModal({
|
||||||
|
title: '未完成报工',
|
||||||
|
content: `${message}\n是否立刻前往未完成的报工?`,
|
||||||
|
cancelText: '否',
|
||||||
|
confirmText: '是',
|
||||||
|
confirmColor: '#1463ff',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.openUnfinishedReport(detail)
|
||||||
|
} catch (openError) {
|
||||||
|
this.showTip(openError.message || '打开未完成报工失败')
|
||||||
|
} finally {
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => resolve(true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resolveMoldOccupiedDetail(error) {
|
||||||
|
if (!error || error.statusCode !== 423) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let detail = error.detail
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(detail)
|
||||||
|
} catch (parseError) {
|
||||||
|
detail = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detail && detail.code === 'mold_occupied' ? detail : null
|
||||||
|
},
|
||||||
|
handleMoldOccupiedError(error) {
|
||||||
|
const detail = this.resolveMoldOccupiedDetail(error)
|
||||||
|
if (!detail) {
|
||||||
|
return Promise.resolve(false)
|
||||||
|
}
|
||||||
|
this.setData({ occupiedDetail: detail })
|
||||||
|
return new Promise(resolve => {
|
||||||
|
wx.showModal({
|
||||||
|
title: '模具已被占用',
|
||||||
|
content: detail.message || `已经被${detail.occupied_name || detail.occupied_phone || '其他员工'}占用,请联系管理员解除占用`,
|
||||||
|
confirmText: '反馈给管理员',
|
||||||
|
cancelText: '确定',
|
||||||
|
confirmColor: '#1463ff',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '反馈中' })
|
||||||
|
try {
|
||||||
|
await api.createMoldLockFeedback(detail.lock_id)
|
||||||
|
wx.showToast({ title: '反馈成功', icon: 'success' })
|
||||||
|
} catch (feedbackError) {
|
||||||
|
this.showTip(feedbackError.message || '反馈失败')
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
resolve(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => resolve(true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async feedbackMoldOccupied() {
|
||||||
|
const detail = this.data.occupiedDetail
|
||||||
|
if (!detail || !detail.lock_id) {
|
||||||
|
this.showTip('占用信息缺失,无法反馈')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '反馈中' })
|
||||||
|
try {
|
||||||
|
await api.createMoldLockFeedback(detail.lock_id)
|
||||||
|
wx.showToast({ title: '反馈成功', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
this.showTip(error.message || '反馈失败')
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
})
|
||||||
3
pages/clock/clock.json
Normal file
3
pages/clock/clock.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "报工打卡"
|
||||||
|
}
|
||||||
108
pages/clock/clock.wxml
Normal file
108
pages/clock/clock.wxml
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<view class="page safe-bottom">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">报工打卡</text>
|
||||||
|
<view class="device-badge">
|
||||||
|
<text class="device-label">考勤点</text>
|
||||||
|
<text class="subtitle">{{attendancePointName || '-'}}</text>
|
||||||
|
<text class="device-label">当前模具</text>
|
||||||
|
<text class="device-no">{{moldDisplayName || moldName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{locationAllowed}}">
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<text class="label">上班打卡时间</text>
|
||||||
|
<text class="value">{{state.startAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="text-right">
|
||||||
|
<text class="label">下班打卡时间</text>
|
||||||
|
<text class="value">{{state.endAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="label">当前人员</text>
|
||||||
|
<block wx:if="{{user}}">
|
||||||
|
<text class="value">{{user.name}} · {{user.phone}}</text>
|
||||||
|
<text class="subtitle">{{user.role === 'worker' ? '冲压工人' : user.role === 'admin' ? '管理员' : '经理'}}</text>
|
||||||
|
</block>
|
||||||
|
<text wx:else class="value">未登录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!user}}" class="card">
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{roleChoices.length}}" class="role-list">
|
||||||
|
<text class="label">请选择角色</text>
|
||||||
|
<button
|
||||||
|
wx:for="{{roleChoices}}"
|
||||||
|
wx:key="role"
|
||||||
|
class="role-btn"
|
||||||
|
data-role="{{item.role}}"
|
||||||
|
bindtap="chooseRole"
|
||||||
|
>
|
||||||
|
{{item.name}} · {{item.roleName}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{state && user && state.action === 'switch_or_finish'}}" class="clock-action-row">
|
||||||
|
<button class="btn secondary clock-action-btn" bindtap="switchMold">换模具</button>
|
||||||
|
<button class="btn primary clock-action-btn" bindtap="finishWork">打卡下班</button>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
wx:elif="{{state && user}}"
|
||||||
|
class="btn primary full clock-btn"
|
||||||
|
bindtap="handleAction"
|
||||||
|
>
|
||||||
|
{{state.actionText}}
|
||||||
|
</button>
|
||||||
|
<view wx:elif="{{occupiedDetail}}" class="card occupied-card">
|
||||||
|
<text class="value">模具已被占用</text>
|
||||||
|
<text class="subtitle">{{occupiedDetail.message || '该模具已被其他员工占用,请联系管理员解除占用'}}</text>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="feedbackMoldOccupied">反馈给管理员</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="list-empty">当前模具码不可用</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view wx:else class="card">
|
||||||
|
<text class="value">{{locationChecking ? '正在校验位置' : '无法打开报工页面'}}</text>
|
||||||
|
<text class="subtitle">{{locationMessage}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{toastVisible}}" class="clock-toast">
|
||||||
|
<text>{{toastMessage}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
|
||||||
|
<view class="temp-worker-panel" catchtap="noop">
|
||||||
|
<text class="temp-worker-title">创建临时工</text>
|
||||||
|
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
|
||||||
|
<view class="temp-worker-field">
|
||||||
|
<text class="temp-worker-label">姓名</text>
|
||||||
|
<input
|
||||||
|
class="input temp-worker-input"
|
||||||
|
value="{{temporaryWorkerName}}"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
bindinput="onTemporaryWorkerNameInput"
|
||||||
|
confirm-type="done"
|
||||||
|
cursor-spacing="24"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="button-row temp-worker-actions">
|
||||||
|
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
|
||||||
|
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
105
pages/clock/clock.wxss
Normal file
105
pages/clock/clock.wxss
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
.clock-btn {
|
||||||
|
margin-top: 36rpx;
|
||||||
|
height: 104rpx;
|
||||||
|
line-height: 104rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-action-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 104rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 104rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.occupied-card {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
border-color: #d92d20;
|
||||||
|
background: linear-gradient(180deg, #fffafa 0%, #fff1f1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 14rpx 22rpx;
|
||||||
|
border: 1rpx solid #b8c4d4;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(225, 232, 241, 0.92)),
|
||||||
|
#edf2f7;
|
||||||
|
box-shadow:
|
||||||
|
0 9rpx 0 rgba(173, 185, 201, 0.72),
|
||||||
|
0 16rpx 22rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-label {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-no {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 46rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 rgba(144, 178, 235, 0.62),
|
||||||
|
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 72rpx;
|
||||||
|
right: 72rpx;
|
||||||
|
top: 48%;
|
||||||
|
z-index: 120;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: rgba(32, 37, 45, 0.9);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-all;
|
||||||
|
box-shadow:
|
||||||
|
0 12rpx 0 rgba(15, 23, 42, 0.2),
|
||||||
|
0 22rpx 34rpx rgba(15, 23, 42, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
134
pages/dashboard/dashboard.js
Normal file
134
pages/dashboard/dashboard.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
const util = require('../../utils/util')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
refreshing: false,
|
||||||
|
filterVoided: true,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
const defaultDate = util.today()
|
||||||
|
this.setData({
|
||||||
|
startDate: options.startDate || defaultDate,
|
||||||
|
endDate: options.endDate || defaultDate,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
try {
|
||||||
|
const result = await api.listDashboard({
|
||||||
|
startDate: this.data.startDate,
|
||||||
|
endDate: this.data.endDate,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
includeVoided: !this.data.filterVoided,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
rows: result.rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStartChange(e) {
|
||||||
|
this.setData({ startDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onEndChange(e) {
|
||||||
|
this.setData({ endDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
shiftRangeDate(field, days) {
|
||||||
|
const otherField = field === 'startDate' ? 'endDate' : 'startDate'
|
||||||
|
const current = this.data[field]
|
||||||
|
const other = this.data[otherField]
|
||||||
|
const base = current || other || util.today()
|
||||||
|
const updates = {
|
||||||
|
[field]: util.addDays(base, days),
|
||||||
|
page: 1,
|
||||||
|
}
|
||||||
|
if (!current && !other) {
|
||||||
|
updates[otherField] = base
|
||||||
|
}
|
||||||
|
this.setData(updates)
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
decreaseStartDate() {
|
||||||
|
this.shiftRangeDate('startDate', -1)
|
||||||
|
},
|
||||||
|
increaseEndDate() {
|
||||||
|
this.shiftRangeDate('endDate', 1)
|
||||||
|
},
|
||||||
|
toggleFilterVoided() {
|
||||||
|
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onFilterVoidedChange(e) {
|
||||||
|
this.setData({ filterVoided: !!e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
async exportFile() {
|
||||||
|
wx.showLoading({ title: '导出中' })
|
||||||
|
try {
|
||||||
|
const filePath = await api.exportDashboard({
|
||||||
|
startDate: this.data.startDate,
|
||||||
|
endDate: this.data.endDate,
|
||||||
|
includeVoided: !this.data.filterVoided,
|
||||||
|
})
|
||||||
|
wx.openDocument({
|
||||||
|
filePath,
|
||||||
|
fileType: 'xlsx',
|
||||||
|
showMenu: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/dashboard/dashboard.json
Normal file
3
pages/dashboard/dashboard.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "报工看板"
|
||||||
|
}
|
||||||
136
pages/dashboard/dashboard.wxml
Normal file
136
pages/dashboard/dashboard.wxml
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<view class="dashboard-header-row">
|
||||||
|
<view class="dashboard-header-main">
|
||||||
|
<text class="title">报工看板</text>
|
||||||
|
<text class="subtitle">同人同日同项目、产品、工序、冲压方式的已审核报工自动合并</text>
|
||||||
|
</view>
|
||||||
|
<view class="void-filter-switch">
|
||||||
|
<text>过滤作废</text>
|
||||||
|
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="filter-bar date-filter-bar">
|
||||||
|
<view class="date-filter-cell">
|
||||||
|
<button class="date-step-btn" bindtap="decreaseStartDate">-</button>
|
||||||
|
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
|
||||||
|
<view class="picker">{{startDate || '开始日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="date-filter-cell end">
|
||||||
|
<button class="date-step-btn" bindtap="increaseEndDate">+</button>
|
||||||
|
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
|
||||||
|
<view class="picker">{{endDate || '结束日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn primary full export-btn" bindtap="exportFile">导出看板数据</button>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无已审核报工</view>
|
||||||
|
<view wx:for="{{rows}}" wx:key="id" class="card dashboard-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}}">
|
||||||
|
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
|
||||||
|
<text wx:elif="{{item.correctionPairs.length}}" class="modified-watermark">更改</text>
|
||||||
|
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">超时-系统自动提交</text>
|
||||||
|
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length ? 'lower-watermark' : ''}}">清洗</text>
|
||||||
|
<text wx:if="{{item.isMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length ? 'lower-watermark' : ''}}">包含杂活</text>
|
||||||
|
<text wx:if="{{item.isMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
|
||||||
|
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning || item.isMultiPerson ? 'lower-watermark' : ''}} {{item.isMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.employeePhone}} · {{item.projectNo}} / {{item.productName}}</text>
|
||||||
|
<text class="subtitle">{{item.processDisplayName || item.productName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.isMisc ? '杂活事项' : '工序'}} {{item.processName || '-'}} · 冲压方式 {{item.stampingMethod || '-'}} · {{item.reportCount}} 条报工</text>
|
||||||
|
<text wx:if="{{!item.isCleaning && !item.isMisc}}" class="subtitle">材料库存批次号 {{item.rawMaterialBatchNo || '-'}}</text>
|
||||||
|
<text wx:if="{{item.shiftDistributionText}}" class="subtitle">{{item.shiftDistributionText}}</text>
|
||||||
|
<text wx:else class="subtitle">有效工时 {{item.effectiveMinutes}} 分钟</text>
|
||||||
|
<view wx:if="{{item.correctionPairs.length}}" class="correction-list">
|
||||||
|
<view wx:for="{{item.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
|
||||||
|
<text>{{pair.label}} </text>
|
||||||
|
<text class="old-value">{{pair.oldValue}}</text>
|
||||||
|
<text> {{pair.newValue}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pill success">已合并</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="board-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">{{item.isMisc ? '处理杂活' : '成品数量'}}</text>
|
||||||
|
<text class="value {{item.workloadCompareClass}}">
|
||||||
|
{{item.isMisc ? '不计产量' : item.totalGood}}
|
||||||
|
<text wx:if="{{item.workloadReason}}" class="metric-reason">{{item.workloadReason}}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">不良数量</text>
|
||||||
|
<text class="value">{{item.totalDefect}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">标准工作量</text>
|
||||||
|
<text class="value">{{item.expectedWorkload}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">实际节拍</text>
|
||||||
|
<text class="value {{item.beatCompareClass}}">
|
||||||
|
{{item.actualBeat}} 秒
|
||||||
|
<text wx:if="{{item.beatReason}}" class="metric-reason">{{item.beatReason}}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">标准节拍</text>
|
||||||
|
<text class="value">{{item.standardBeat}} 秒</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">节拍对比</text>
|
||||||
|
<text class="value {{item.beatCompareClass}}">{{item.paceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isMisc}}">
|
||||||
|
<text class="label">工作量对比</text>
|
||||||
|
<text class="value {{item.workloadCompareClass}}">{{item.workloadText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.isMisc}}">
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{item.effectiveMinutes}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!item.isCleaning && !item.isMisc}}">
|
||||||
|
<text class="label">材料库存批次号</text>
|
||||||
|
<text class="value">{{item.rawMaterialBatchNo || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.isContinuousDie}}">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<text class="value">{{item.changeoverCount || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.reviewRemark}}" class="dashboard-remark">
|
||||||
|
<text class="label">备注</text>
|
||||||
|
<text class="value">{{item.reviewRemark}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="salary-divider"></view>
|
||||||
|
<view class="salary-row">
|
||||||
|
<text class="label">参考工资</text>
|
||||||
|
<text class="salary-value">¥{{item.referenceWage || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
79
pages/dashboard/dashboard.wxss
Normal file
79
pages/dashboard/dashboard.wxss
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
.dashboard-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-main {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header-row .void-filter-switch {
|
||||||
|
align-self: flex-end;
|
||||||
|
width: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(18, 163, 117, 0.55);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(8, 116, 67, 0.72);
|
||||||
|
background: rgba(226, 255, 243, 0.82);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
padding-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salary-divider {
|
||||||
|
height: 2rpx;
|
||||||
|
margin: 20rpx 0 16rpx;
|
||||||
|
background: #e1e8f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-remark {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border: 1rpx solid #dfe7f2;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-remark .value {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.salary-value {
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
489
pages/deviceQr/deviceQr.js
Normal file
489
pages/deviceQr/deviceQr.js
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
keyword: '',
|
||||||
|
selectedMold: null,
|
||||||
|
moldOptions: [],
|
||||||
|
filteredMoldOptions: [],
|
||||||
|
batchMoldOptions: [],
|
||||||
|
batchKeyword: '',
|
||||||
|
dropdownVisible: false,
|
||||||
|
selectedMoldKeys: [],
|
||||||
|
selectedMoldCount: 0,
|
||||||
|
allFilteredSelected: false,
|
||||||
|
batchModalVisible: false,
|
||||||
|
batchGenerating: false,
|
||||||
|
generatedMoldName: '',
|
||||||
|
qrPath: '',
|
||||||
|
qrUrl: '',
|
||||||
|
qrImageUrl: '',
|
||||||
|
qrLoadError: '',
|
||||||
|
taskRows: [],
|
||||||
|
taskPage: 1,
|
||||||
|
taskPageInput: '1',
|
||||||
|
taskPageSize: 5,
|
||||||
|
taskTotal: 0,
|
||||||
|
taskTotalPages: 1,
|
||||||
|
taskLoading: false,
|
||||||
|
swipedTaskId: null,
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.loadMolds()
|
||||||
|
this.loadBatchTasks()
|
||||||
|
this.startTaskPolling()
|
||||||
|
},
|
||||||
|
onHide() {
|
||||||
|
this.stopTaskPolling()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
this.stopTaskPolling()
|
||||||
|
},
|
||||||
|
startTaskPolling() {
|
||||||
|
this.stopTaskPolling()
|
||||||
|
this.taskTimer = setInterval(() => {
|
||||||
|
this.loadBatchTasks({ silent: true })
|
||||||
|
}, 5000)
|
||||||
|
},
|
||||||
|
stopTaskPolling() {
|
||||||
|
if (this.taskTimer) {
|
||||||
|
clearInterval(this.taskTimer)
|
||||||
|
this.taskTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadMolds() {
|
||||||
|
try {
|
||||||
|
const moldOptions = await api.listMoldNames({ limit: 500 })
|
||||||
|
const availableKeys = new Set(moldOptions.map(item => item.uniqueKey))
|
||||||
|
const selectedMoldKeys = this.data.selectedMoldKeys.filter(key => availableKeys.has(key))
|
||||||
|
this.setData({
|
||||||
|
moldOptions,
|
||||||
|
selectedMoldKeys,
|
||||||
|
selectedMoldCount: selectedMoldKeys.length,
|
||||||
|
...this.buildFilteredState(this.data.keyword, moldOptions, selectedMoldKeys),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载模具失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadBatchTasks(options = {}) {
|
||||||
|
if (this.data.taskLoading && options.silent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!options.silent) {
|
||||||
|
this.setData({ taskLoading: true })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api.listMoldQrBatchTasks({
|
||||||
|
page: this.data.taskPage,
|
||||||
|
pageSize: this.data.taskPageSize,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
taskRows: result.rows,
|
||||||
|
taskPage: result.page,
|
||||||
|
taskPageInput: String(result.page || 1),
|
||||||
|
taskTotal: result.total,
|
||||||
|
taskTotalPages: result.totalPages,
|
||||||
|
swipedTaskId: result.rows.some(row => row.id === this.data.swipedTaskId) ? this.data.swipedTaskId : null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!options.silent) {
|
||||||
|
wx.showToast({ title: error.message || '加载任务失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!options.silent) {
|
||||||
|
this.setData({ taskLoading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filterMolds(keyword, source = this.data.moldOptions) {
|
||||||
|
const text = String(keyword || '').trim().toLowerCase()
|
||||||
|
if (!text) {
|
||||||
|
return source.slice(0, 500)
|
||||||
|
}
|
||||||
|
return source.filter(item => (
|
||||||
|
String(item.displayName || '').toLowerCase().includes(text)
|
||||||
|
|| String(item.moldName || item.name || '').toLowerCase().includes(text)
|
||||||
|
|| String(item.processName || '').toLowerCase().includes(text)
|
||||||
|
|| String(item.stampingMethod || '').toLowerCase().includes(text)
|
||||||
|
|| String(item.attendancePointName || '').toLowerCase().includes(text)
|
||||||
|
)).slice(0, 500)
|
||||||
|
},
|
||||||
|
buildFilteredState(keyword, source = this.data.moldOptions, selectedKeys = this.data.selectedMoldKeys) {
|
||||||
|
const selectedSet = new Set(selectedKeys || [])
|
||||||
|
const filteredMoldOptions = this.filterMolds(keyword, source).map(item => ({
|
||||||
|
...item,
|
||||||
|
checked: selectedSet.has(item.uniqueKey),
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
filteredMoldOptions,
|
||||||
|
allFilteredSelected: filteredMoldOptions.length > 0 && filteredMoldOptions.every(item => item.checked),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildBatchState(selectedKeys = this.data.selectedMoldKeys, keyword = this.data.batchKeyword) {
|
||||||
|
const selectedSet = new Set(selectedKeys || [])
|
||||||
|
const batchMoldOptions = this.filterMolds(keyword, this.data.moldOptions).map(item => ({
|
||||||
|
...item,
|
||||||
|
checked: selectedSet.has(item.uniqueKey),
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
batchMoldOptions,
|
||||||
|
allFilteredSelected: batchMoldOptions.length > 0 && batchMoldOptions.every(item => item.checked),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setBatchSelection(selectedMoldKeys) {
|
||||||
|
this.setData({
|
||||||
|
selectedMoldKeys,
|
||||||
|
selectedMoldCount: selectedMoldKeys.length,
|
||||||
|
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, selectedMoldKeys),
|
||||||
|
...this.buildBatchState(selectedMoldKeys),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onInput(e) {
|
||||||
|
const keyword = e.detail.value
|
||||||
|
this.setData({
|
||||||
|
keyword,
|
||||||
|
selectedMold: null,
|
||||||
|
...this.buildFilteredState(keyword),
|
||||||
|
dropdownVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
showDropdown() {
|
||||||
|
this.setData({
|
||||||
|
dropdownVisible: true,
|
||||||
|
...this.buildFilteredState(this.data.keyword),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selectMold(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const mold = this.data.filteredMoldOptions[index]
|
||||||
|
if (!mold) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
selectedMold: mold,
|
||||||
|
keyword: mold.displayName,
|
||||||
|
...this.buildFilteredState(mold.displayName),
|
||||||
|
dropdownVisible: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
openBatchModal() {
|
||||||
|
this.setData({
|
||||||
|
batchModalVisible: true,
|
||||||
|
dropdownVisible: false,
|
||||||
|
batchKeyword: '',
|
||||||
|
...this.buildBatchState(this.data.selectedMoldKeys, ''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeBatchModal() {
|
||||||
|
if (this.data.batchGenerating) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ batchModalVisible: false })
|
||||||
|
},
|
||||||
|
onBatchSelectionChange(e) {
|
||||||
|
const visibleKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
|
||||||
|
const selectedVisibleKeys = e.detail.value || []
|
||||||
|
const selectedSet = new Set(this.data.selectedMoldKeys.filter(key => !visibleKeys.includes(key)))
|
||||||
|
selectedVisibleKeys.forEach(key => selectedSet.add(key))
|
||||||
|
this.setBatchSelection(Array.from(selectedSet))
|
||||||
|
},
|
||||||
|
onBatchInput(e) {
|
||||||
|
const batchKeyword = e.detail.value
|
||||||
|
this.setData({
|
||||||
|
batchKeyword,
|
||||||
|
...this.buildBatchState(this.data.selectedMoldKeys, batchKeyword),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleSelectAll() {
|
||||||
|
const batchKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
|
||||||
|
if (!batchKeys.length) {
|
||||||
|
wx.showToast({ title: '当前没有可选模具', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selectedKeys = []
|
||||||
|
const selectedSet = new Set(this.data.selectedMoldKeys)
|
||||||
|
if (this.data.allFilteredSelected) {
|
||||||
|
batchKeys.forEach(key => selectedSet.delete(key))
|
||||||
|
} else {
|
||||||
|
batchKeys.forEach(key => selectedSet.add(key))
|
||||||
|
}
|
||||||
|
selectedKeys = Array.from(selectedSet)
|
||||||
|
this.setBatchSelection(selectedKeys)
|
||||||
|
},
|
||||||
|
getSelectedMolds() {
|
||||||
|
const selectedSet = new Set(this.data.selectedMoldKeys)
|
||||||
|
return this.data.moldOptions.filter(item => selectedSet.has(item.uniqueKey))
|
||||||
|
},
|
||||||
|
batchZipFilePath(fileName) {
|
||||||
|
const safeName = String(fileName || `模具二维码_${Date.now()}.zip`).replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
return `${wx.env.USER_DATA_PATH}/${safeName}`
|
||||||
|
},
|
||||||
|
shareZipFile(filePath, fileName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!wx.shareFileMessage) {
|
||||||
|
reject(new Error('当前微信版本不支持转发ZIP文件'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.shareFileMessage({
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isRequestTimeout(error) {
|
||||||
|
const message = `${error && error.message || ''} ${error && error.errMsg || ''}`.toLowerCase()
|
||||||
|
return message.includes('timeout') || message.includes('超时')
|
||||||
|
},
|
||||||
|
async createBatchTask() {
|
||||||
|
const molds = this.getSelectedMolds()
|
||||||
|
if (!molds.length) {
|
||||||
|
wx.showToast({ title: '请先勾选模具', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ batchGenerating: true })
|
||||||
|
wx.showLoading({ title: '创建任务中' })
|
||||||
|
try {
|
||||||
|
const task = await api.createMoldQrBatchTask(molds)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: `已创建${task.itemCount || molds.length}个二维码任务`, icon: 'success' })
|
||||||
|
this.setData({
|
||||||
|
batchModalVisible: false,
|
||||||
|
selectedMoldKeys: [],
|
||||||
|
selectedMoldCount: 0,
|
||||||
|
taskPage: 1,
|
||||||
|
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, []),
|
||||||
|
...this.buildBatchState([], this.data.batchKeyword),
|
||||||
|
})
|
||||||
|
this.loadBatchTasks()
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
if (this.isRequestTimeout(error)) {
|
||||||
|
this.setData({
|
||||||
|
batchModalVisible: false,
|
||||||
|
taskPage: 1,
|
||||||
|
})
|
||||||
|
this.loadBatchTasks()
|
||||||
|
wx.showModal({
|
||||||
|
title: '创建结果待确认',
|
||||||
|
content: '请求超时,但任务可能已经创建。已刷新任务列表,请先查看列表状态,避免重复创建。',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: error.message || '创建任务失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.setData({ batchGenerating: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changeTaskPage(e) {
|
||||||
|
const direction = Number(e.currentTarget.dataset.direction || 0)
|
||||||
|
const nextPage = Math.max(1, Math.min(this.data.taskTotalPages, this.data.taskPage + direction))
|
||||||
|
if (nextPage === this.data.taskPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ taskPage: nextPage }, () => this.loadBatchTasks())
|
||||||
|
},
|
||||||
|
onTaskPageInput(e) {
|
||||||
|
this.setData({ taskPageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpTaskPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.taskPageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.taskPage, this.data.taskTotalPages))
|
||||||
|
if (target === this.data.taskPage) {
|
||||||
|
this.setData({ taskPageInput: String(this.data.taskPage || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ taskPage: target }, () => this.loadBatchTasks())
|
||||||
|
},
|
||||||
|
refreshBatchTasks() {
|
||||||
|
this.loadBatchTasks()
|
||||||
|
},
|
||||||
|
onTaskTouchStart(e) {
|
||||||
|
const touch = e.touches && e.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.taskTouchStartX = touch.clientX
|
||||||
|
this.taskTouchStartY = touch.clientY
|
||||||
|
},
|
||||||
|
onTaskTouchMove(e) {
|
||||||
|
const touch = e.touches && e.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deltaX = touch.clientX - this.taskTouchStartX
|
||||||
|
const deltaY = touch.clientY - this.taskTouchStartY
|
||||||
|
if (Math.abs(deltaX) < 45 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = Number(e.currentTarget.dataset.id)
|
||||||
|
if (deltaX < 0) {
|
||||||
|
this.setData({ swipedTaskId: id })
|
||||||
|
} else if (this.data.swipedTaskId === id) {
|
||||||
|
this.setData({ swipedTaskId: null })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTaskTouchEnd() {
|
||||||
|
this.taskTouchStartX = 0
|
||||||
|
this.taskTouchStartY = 0
|
||||||
|
},
|
||||||
|
async stopBatchTask(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const task = this.data.taskRows[index]
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '停止中' })
|
||||||
|
try {
|
||||||
|
await api.stopMoldQrBatchTask(task.id)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '已停止', icon: 'success' })
|
||||||
|
this.loadBatchTasks()
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: error.message || '停止失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async resumeBatchTask(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const task = this.data.taskRows[index]
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '继续中' })
|
||||||
|
try {
|
||||||
|
await api.resumeMoldQrBatchTask(task.id)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '已继续生成', icon: 'success' })
|
||||||
|
this.loadBatchTasks()
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: error.message || '继续失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async shareBatchTask(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const task = this.data.taskRows[index]
|
||||||
|
if (!task || !task.zipUrl) {
|
||||||
|
wx.showToast({ title: 'ZIP还未生成完成', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '准备转发' })
|
||||||
|
try {
|
||||||
|
const fileName = task.fileName || `模具二维码_${Date.now()}.zip`
|
||||||
|
const filePath = await api.downloadFileByUrl(task.zipUrl, this.batchZipFilePath(fileName), 120000)
|
||||||
|
wx.hideLoading()
|
||||||
|
await this.shareZipFile(filePath, fileName)
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
const message = error && error.errMsg && error.errMsg.includes('cancel')
|
||||||
|
? '已取消转发'
|
||||||
|
: (error.message || '转发失败')
|
||||||
|
wx.showToast({ title: message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteBatchTask(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const task = this.data.taskRows[index]
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除ZIP任务',
|
||||||
|
content: '删除后会同时删除这条记录及对应ZIP包,是否确定删除?',
|
||||||
|
cancelText: '否',
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#d92d20',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '删除中' })
|
||||||
|
try {
|
||||||
|
await api.deleteMoldQrBatchTask(task.id)
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
this.setData({ swipedTaskId: null })
|
||||||
|
this.loadBatchTasks()
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async generate() {
|
||||||
|
const mold = this.data.selectedMold
|
||||||
|
if (!mold || !mold.moldName || !mold.processName) {
|
||||||
|
wx.showToast({ title: '请选择产品名称、工序和冲压方式', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.showLoading({ title: '生成中' })
|
||||||
|
try {
|
||||||
|
const result = await api.generateMoldQr(mold)
|
||||||
|
const qrUrl = this.withCacheBuster(result.qrUrl)
|
||||||
|
this.setData({
|
||||||
|
generatedMoldName: result.displayName || result.moldName || result.deviceNo,
|
||||||
|
qrPath: result.qrPath,
|
||||||
|
qrUrl,
|
||||||
|
qrImageUrl: '',
|
||||||
|
qrLoadError: '',
|
||||||
|
})
|
||||||
|
if (!result.qrUrl) {
|
||||||
|
wx.showToast({ title: '微信配置未生成图片', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
this.prepareQrImage(qrUrl)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
withCacheBuster(url) {
|
||||||
|
if (!url) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const separator = url.indexOf('?') === -1 ? '?' : '&'
|
||||||
|
return `${url}${separator}_t=${Date.now()}`
|
||||||
|
},
|
||||||
|
prepareQrImage(url) {
|
||||||
|
wx.downloadFile({
|
||||||
|
url,
|
||||||
|
success: res => {
|
||||||
|
if (res.statusCode === 200 && res.tempFilePath) {
|
||||||
|
this.setData({ qrImageUrl: res.tempFilePath, qrLoadError: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ qrImageUrl: url })
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.setData({
|
||||||
|
qrImageUrl: url,
|
||||||
|
qrLoadError: '二维码图片下载失败,已切换为直接显示',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onQrLoad() {
|
||||||
|
this.setData({ qrLoadError: '' })
|
||||||
|
},
|
||||||
|
onQrError() {
|
||||||
|
this.setData({ qrLoadError: '二维码图片加载失败,点击空白区域可放大查看' })
|
||||||
|
},
|
||||||
|
preview() {
|
||||||
|
if (!this.data.qrUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.previewImage({
|
||||||
|
urls: [this.data.qrUrl],
|
||||||
|
current: this.data.qrUrl,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/deviceQr/deviceQr.json
Normal file
3
pages/deviceQr/deviceQr.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "模具二维码"
|
||||||
|
}
|
||||||
191
pages/deviceQr/deviceQr.wxml
Normal file
191
pages/deviceQr/deviceQr.wxml
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
<view class="page safe-bottom">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">模具二维码</text>
|
||||||
|
<text class="subtitle">选择产品名称、工序和冲压方式生成扫码报工入口</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card qr-work-card">
|
||||||
|
<view class="form-item">
|
||||||
|
<view class="field-head">
|
||||||
|
<text class="label">产品名称 / 工序 / 冲压方式</text>
|
||||||
|
<button class="btn mini secondary batch-entry-btn" bindtap="openBatchModal">批量生成</button>
|
||||||
|
</view>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
value="{{keyword}}"
|
||||||
|
placeholder="输入产品、工序或冲压方式搜索模具"
|
||||||
|
bindfocus="showDropdown"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
<view wx:if="{{dropdownVisible}}" class="mold-dropdown">
|
||||||
|
<view
|
||||||
|
wx:for="{{filteredMoldOptions}}"
|
||||||
|
wx:key="uniqueKey"
|
||||||
|
class="mold-option"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="selectMold"
|
||||||
|
>
|
||||||
|
<text class="mold-option-name">{{item.moldName || item.name}}</text>
|
||||||
|
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
|
||||||
|
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
|
||||||
|
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!filteredMoldOptions.length}}" class="mold-empty">未找到模具</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<button class="btn primary full" bindtap="generate">生成二维码</button>
|
||||||
|
|
||||||
|
<view wx:if="{{qrUrl}}" class="qr-result">
|
||||||
|
<view class="qr-frame" bindtap="preview">
|
||||||
|
<image
|
||||||
|
class="qr-image"
|
||||||
|
src="{{qrImageUrl || qrUrl}}"
|
||||||
|
mode="widthFix"
|
||||||
|
show-menu-by-longpress="{{true}}"
|
||||||
|
bindload="onQrLoad"
|
||||||
|
binderror="onQrError"
|
||||||
|
></image>
|
||||||
|
</view>
|
||||||
|
<text wx:if="{{qrLoadError}}" class="qr-error">{{qrLoadError}}</text>
|
||||||
|
<text class="value">模具 {{generatedMoldName}}</text>
|
||||||
|
<text wx:if="{{selectedMold && selectedMold.attendancePointName}}" class="subtitle">考勤点 {{selectedMold.attendancePointName}}</text>
|
||||||
|
<text class="subtitle">{{qrPath}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card batch-task-card">
|
||||||
|
<view class="task-head">
|
||||||
|
<view>
|
||||||
|
<text class="task-title">ZIP任务列表</text>
|
||||||
|
<text class="subtitle">批量二维码后台生成,完成后可转发ZIP</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary" loading="{{taskLoading}}" bindtap="refreshBatchTasks">刷新</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!taskRows.length}}" class="mold-empty">暂无ZIP任务</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{taskRows}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-index="taskIndex"
|
||||||
|
class="task-swipe-wrap"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
bindtouchstart="onTaskTouchStart"
|
||||||
|
bindtouchmove="onTaskTouchMove"
|
||||||
|
bindtouchend="onTaskTouchEnd"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="task-delete-btn"
|
||||||
|
data-index="{{taskIndex}}"
|
||||||
|
catchtap="deleteBatchTask"
|
||||||
|
>
|
||||||
|
<text>删除</text>
|
||||||
|
</view>
|
||||||
|
<view class="task-item {{swipedTaskId == item.id ? 'swiped' : ''}}">
|
||||||
|
<view class="task-row">
|
||||||
|
<view class="task-main">
|
||||||
|
<text class="task-file">{{item.fileName}}</text>
|
||||||
|
<text class="task-meta">二维码数量 {{item.itemCount}} · {{item.statusName}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="task-side">
|
||||||
|
<text class="task-status {{item.isCompleted ? 'success' : ''}} {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'danger' : ''}}">{{item.progressPercent}}%</text>
|
||||||
|
<button
|
||||||
|
wx:if="{{item.isPending || item.isRunning}}"
|
||||||
|
class="btn mini secondary task-small-btn"
|
||||||
|
data-index="{{taskIndex}}"
|
||||||
|
catchtap="stopBatchTask"
|
||||||
|
>停止</button>
|
||||||
|
<button
|
||||||
|
wx:if="{{item.isPaused}}"
|
||||||
|
class="btn mini primary task-small-btn"
|
||||||
|
data-index="{{taskIndex}}"
|
||||||
|
catchtap="resumeBatchTask"
|
||||||
|
>继续</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'failed' : ''}}" style="width: {{item.progressPercent}}%;"></view>
|
||||||
|
</view>
|
||||||
|
<view class="task-time-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">开始时间</text>
|
||||||
|
<text class="value">{{item.startedAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">完成时间</text>
|
||||||
|
<text class="value">{{item.finishedAtText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text wx:if="{{item.errorMessage}}" class="task-error">{{item.errorMessage}}</text>
|
||||||
|
<view class="task-actions-row">
|
||||||
|
<button
|
||||||
|
wx:if="{{item.isCompleted && item.zipUrl}}"
|
||||||
|
class="btn primary full task-action-btn"
|
||||||
|
data-index="{{taskIndex}}"
|
||||||
|
catchtap="shareBatchTask"
|
||||||
|
>转发ZIP</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{taskRows.length}}" class="button-row pager-row task-pager">
|
||||||
|
<button class="btn secondary pager-nav" data-direction="-1" bindtap="changeTaskPage" disabled="{{taskPage <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{taskPageInput}}" bindinput="onTaskPageInput" bindconfirm="jumpTaskPage" />
|
||||||
|
<text class="page-total-label">/ {{taskTotalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpTaskPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" data-direction="1" bindtap="changeTaskPage" disabled="{{taskPage >= taskTotalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{batchModalVisible}}" class="modal-mask batch-modal-mask" catchtap="closeBatchModal">
|
||||||
|
<view class="modal-panel batch-modal-panel" catchtap="noop">
|
||||||
|
<view class="batch-head">
|
||||||
|
<view>
|
||||||
|
<text class="batch-title">批量生成二维码</text>
|
||||||
|
<text class="subtitle">已选择 {{selectedMoldCount}} 个模具</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary batch-select-btn" bindtap="toggleSelectAll">
|
||||||
|
{{allFilteredSelected ? '取消全选' : '全选'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="batch-search">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
value="{{batchKeyword}}"
|
||||||
|
placeholder="筛选产品、工序、冲压方式或考勤点"
|
||||||
|
bindinput="onBatchInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<checkbox-group bindchange="onBatchSelectionChange">
|
||||||
|
<scroll-view class="batch-list" scroll-y>
|
||||||
|
<label
|
||||||
|
wx:for="{{batchMoldOptions}}"
|
||||||
|
wx:key="uniqueKey"
|
||||||
|
class="batch-option {{item.checked ? 'checked' : ''}}"
|
||||||
|
>
|
||||||
|
<checkbox value="{{item.uniqueKey}}" checked="{{item.checked}}" />
|
||||||
|
<view class="batch-option-text">
|
||||||
|
<text class="mold-option-name">{{item.moldName || item.name}}</text>
|
||||||
|
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
|
||||||
|
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
|
||||||
|
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
|
||||||
|
</view>
|
||||||
|
</label>
|
||||||
|
<view wx:if="{{!batchMoldOptions.length}}" class="mold-empty">未找到模具</view>
|
||||||
|
</scroll-view>
|
||||||
|
</checkbox-group>
|
||||||
|
<view class="batch-actions">
|
||||||
|
<button class="btn secondary full" bindtap="closeBatchModal">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn primary full"
|
||||||
|
loading="{{batchGenerating}}"
|
||||||
|
bindtap="createBatchTask"
|
||||||
|
>生成ZIP任务</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
440
pages/deviceQr/deviceQr.wxss
Normal file
440
pages/deviceQr/deviceQr.wxss
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
.qr-work-card {
|
||||||
|
border-left: 6rpx solid #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-head .label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-entry-btn {
|
||||||
|
flex: 0 0 168rpx;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-dropdown {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 420rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
border: 2rpx solid #b8c6dc;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(174, 188, 208, 0.46),
|
||||||
|
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #e6edf6;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option-name {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option-process {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option-tag {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.58);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: #9a4a05;
|
||||||
|
background: #fff7e8;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option-tag.continuous-tag {
|
||||||
|
color: #b45f00;
|
||||||
|
border-color: rgba(217, 119, 6, 0.58);
|
||||||
|
background: #fff4e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-option:active {
|
||||||
|
background: #eef5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-empty {
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
color: #667085;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-mask {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 28rpx 20rpx calc(env(safe-area-inset-bottom) + 28rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-panel {
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-select-btn {
|
||||||
|
flex: 0 0 156rpx;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-search {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-list {
|
||||||
|
height: 64vh;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
border: 2rpx solid #b8c6dc;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(174, 188, 208, 0.46),
|
||||||
|
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #e6edf6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-option.checked {
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-option:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-option-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.24fr);
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-task-card {
|
||||||
|
border-left: 6rpx solid #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-swipe-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 150rpx;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0 16rpx 16rpx 0;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #d92d20;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: normal;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-delete-btn text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 22rpx;
|
||||||
|
border: 2rpx solid #c8d3e3;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 9rpx 0 rgba(180, 193, 213, 0.48),
|
||||||
|
0 15rpx 24rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.swiped {
|
||||||
|
transform: translateX(-150rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-file {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
min-width: 96rpx;
|
||||||
|
padding: 8rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
color: #1463ff;
|
||||||
|
background: #eef5ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-side {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-small-btn {
|
||||||
|
min-width: 108rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.success {
|
||||||
|
color: #087443;
|
||||||
|
background: #e8fff4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.paused {
|
||||||
|
color: #8a5a00;
|
||||||
|
background: #fff7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status.danger {
|
||||||
|
color: #b42318;
|
||||||
|
background: #fff1f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #dbe4f0;
|
||||||
|
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 8rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(90deg, #1463ff 0%, #12a375 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.failed {
|
||||||
|
background: linear-gradient(90deg, #d92d20 0%, #f79009 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.paused {
|
||||||
|
background: linear-gradient(90deg, #f79009 0%, #fdb022 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-time-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-error {
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-pager {
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 26rpx;
|
||||||
|
padding-top: 26rpx;
|
||||||
|
border-top: 2rpx solid #e6edf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-frame {
|
||||||
|
width: 520rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 520rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 12rpx 0 rgba(205, 216, 229, 0.58),
|
||||||
|
0 18rpx 28rpx rgba(22, 34, 50, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-error {
|
||||||
|
color: #b42318;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
634
pages/index/index.js
Normal file
634
pages/index/index.js
Normal file
@ -0,0 +1,634 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
const locationGuard = require('../../utils/locationGuard')
|
||||||
|
|
||||||
|
const avatarKey = phone => `jh_wrs_avatar_${phone}`
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
activeTab: 'home',
|
||||||
|
currentUser: null,
|
||||||
|
currentRoleName: '',
|
||||||
|
currentRolesText: '',
|
||||||
|
canSwitchRole: false,
|
||||||
|
avatarPath: '',
|
||||||
|
avatarText: '',
|
||||||
|
roleChoices: [],
|
||||||
|
selectionToken: '',
|
||||||
|
summary: {},
|
||||||
|
showOverview: false,
|
||||||
|
showWorkerTrend: false,
|
||||||
|
workerTrend: {
|
||||||
|
total: 0,
|
||||||
|
max: 0,
|
||||||
|
points: [],
|
||||||
|
segments: [],
|
||||||
|
},
|
||||||
|
notices: [],
|
||||||
|
noticeList: [],
|
||||||
|
actions: [],
|
||||||
|
currentAttendancePointName: '',
|
||||||
|
locationStatusText: '当前位置:未进入考勤点',
|
||||||
|
locationEntered: false,
|
||||||
|
locationInitialized: false,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
temporaryWorkerModalVisible: false,
|
||||||
|
temporaryWorkerPrompt: '',
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load({ refreshLocation: !this.data.locationInitialized }).catch(error => {
|
||||||
|
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async load(options = {}) {
|
||||||
|
let currentUser = api.getCurrentUser()
|
||||||
|
let summary = {}
|
||||||
|
let showOverview = false
|
||||||
|
let showWorkerTrend = false
|
||||||
|
let workerTrend = { total: 0, max: 0, points: [], segments: [] }
|
||||||
|
let notices = []
|
||||||
|
let locationView = {
|
||||||
|
currentAttendancePointName: this.data.currentAttendancePointName,
|
||||||
|
locationStatusText: this.data.locationStatusText,
|
||||||
|
locationEntered: this.data.locationEntered,
|
||||||
|
}
|
||||||
|
let locationInitialized = this.data.locationInitialized
|
||||||
|
if (currentUser) {
|
||||||
|
try {
|
||||||
|
currentUser = await api.refreshCurrentUser() || currentUser
|
||||||
|
showOverview = currentUser.role !== 'worker'
|
||||||
|
showWorkerTrend = currentUser.role === 'worker'
|
||||||
|
if (showOverview) {
|
||||||
|
summary = await api.getHomeSummary(currentUser)
|
||||||
|
}
|
||||||
|
if (showWorkerTrend) {
|
||||||
|
workerTrend = await this.loadWorkerTrend()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
currentUser = api.getCurrentUser()
|
||||||
|
showOverview = !!currentUser && currentUser.role !== 'worker'
|
||||||
|
showWorkerTrend = !!currentUser && currentUser.role === 'worker'
|
||||||
|
}
|
||||||
|
if (currentUser) {
|
||||||
|
notices = await this.loadNotices()
|
||||||
|
if (options.refreshLocation || !locationInitialized) {
|
||||||
|
locationView = await this.refreshLocationStatus()
|
||||||
|
locationInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
locationInitialized = false
|
||||||
|
locationView = {
|
||||||
|
currentAttendancePointName: '',
|
||||||
|
locationStatusText: '当前位置:未进入考勤点',
|
||||||
|
locationEntered: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const actions = this.buildActions(currentUser)
|
||||||
|
const overviewItems = this.buildOverviewItems(currentUser, summary)
|
||||||
|
const userView = this.buildUserView(currentUser)
|
||||||
|
this.setData({
|
||||||
|
currentUser,
|
||||||
|
summary,
|
||||||
|
overviewItems,
|
||||||
|
showOverview,
|
||||||
|
showWorkerTrend,
|
||||||
|
workerTrend,
|
||||||
|
notices,
|
||||||
|
noticeList: notices.slice(0, 3),
|
||||||
|
actions,
|
||||||
|
...locationView,
|
||||||
|
locationInitialized,
|
||||||
|
...userView,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load({ refreshLocation: true })
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadNotices() {
|
||||||
|
try {
|
||||||
|
const result = await api.listNotices({ page: 1, pageSize: 6 })
|
||||||
|
return result.rows || []
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshLocationStatus() {
|
||||||
|
try {
|
||||||
|
const result = await locationGuard.getCurrentAttendancePoint()
|
||||||
|
if (result && result.factory) {
|
||||||
|
return {
|
||||||
|
currentAttendancePointName: result.factory.name,
|
||||||
|
locationStatusText: `当前位置:已进入 ${result.factory.name} 考勤点`,
|
||||||
|
locationEntered: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
currentAttendancePointName: '',
|
||||||
|
locationStatusText: '当前位置:未进入考勤点',
|
||||||
|
locationEntered: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentAttendancePointName: '',
|
||||||
|
locationStatusText: '当前位置:未进入考勤点',
|
||||||
|
locationEntered: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildUserView(user) {
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
currentRoleName: '',
|
||||||
|
currentRolesText: '',
|
||||||
|
canSwitchRole: false,
|
||||||
|
avatarPath: '',
|
||||||
|
avatarText: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentRoleName: user.roleName || api.roleNames[user.role] || user.role,
|
||||||
|
currentRolesText: (user.roleNames || []).join('、') || user.roleName || '',
|
||||||
|
canSwitchRole: !!(user.rolesDisplay && user.rolesDisplay.length > 1),
|
||||||
|
avatarPath: wx.getStorageSync(avatarKey(user.phone)) || '',
|
||||||
|
avatarText: this.buildDefaultAvatarText(user.name),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildOverviewItems(user, summary = {}) {
|
||||||
|
if (!user || user.role === 'worker') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const today = this.formatDate(new Date())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'products',
|
||||||
|
label: '产品',
|
||||||
|
value: summary.productCount || 0,
|
||||||
|
url: '/pages/manageProducts/manageProducts?readonly=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'people',
|
||||||
|
label: '人员',
|
||||||
|
value: summary.peopleCount || 0,
|
||||||
|
url: '/pages/managePeople/managePeople?readonly=1',
|
||||||
|
},
|
||||||
|
...(user.role === 'admin' ? [{
|
||||||
|
key: 'pending',
|
||||||
|
label: '待审核',
|
||||||
|
value: summary.pendingCount || 0,
|
||||||
|
alert: (summary.pendingCount || 0) > 0,
|
||||||
|
url: '/pages/records/records?mode=pending&status=pending',
|
||||||
|
}] : []),
|
||||||
|
...(user.role === 'admin' ? [{
|
||||||
|
key: 'approved',
|
||||||
|
label: '今日通过',
|
||||||
|
value: summary.approvedToday || 0,
|
||||||
|
url: `/pages/records/records?mode=audit&status=approved&startDate=${today}&endDate=${today}`,
|
||||||
|
}] : []),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
buildDefaultAvatarText(name) {
|
||||||
|
const text = String(name || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
return '嘉'
|
||||||
|
}
|
||||||
|
const givenName = text.length > 1 ? text.slice(1) : text
|
||||||
|
return givenName.slice(0, 2)
|
||||||
|
},
|
||||||
|
switchTab(e) {
|
||||||
|
const activeTab = e.currentTarget.dataset.tab
|
||||||
|
this.setData({ activeTab })
|
||||||
|
},
|
||||||
|
buildRecentDates(days = 7) {
|
||||||
|
const dates = []
|
||||||
|
const today = new Date()
|
||||||
|
for (let index = days - 1; index >= 0; index -= 1) {
|
||||||
|
const date = new Date(today)
|
||||||
|
date.setDate(today.getDate() - index)
|
||||||
|
const value = this.formatDate(date)
|
||||||
|
dates.push({
|
||||||
|
value,
|
||||||
|
label: value.slice(5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
},
|
||||||
|
formatDate(date) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const monthValue = date.getMonth() + 1
|
||||||
|
const dayValue = date.getDate()
|
||||||
|
const month = monthValue < 10 ? `0${monthValue}` : `${monthValue}`
|
||||||
|
const day = dayValue < 10 ? `0${dayValue}` : `${dayValue}`
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
},
|
||||||
|
async loadWorkerTrend() {
|
||||||
|
const dates = this.buildRecentDates(7)
|
||||||
|
const result = await api.listReports({
|
||||||
|
startDate: dates[0].value,
|
||||||
|
endDate: dates[dates.length - 1].value,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
})
|
||||||
|
const totals = dates.reduce((map, item) => {
|
||||||
|
map[item.value] = 0
|
||||||
|
return map
|
||||||
|
}, {})
|
||||||
|
;(result.rows || []).forEach(report => {
|
||||||
|
if (totals[report.reportDate] === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totals[report.reportDate] += Number(report.metrics && report.metrics.totalGood || 0)
|
||||||
|
})
|
||||||
|
const points = dates.map(item => ({
|
||||||
|
date: item.value,
|
||||||
|
label: item.label,
|
||||||
|
value: totals[item.value] || 0,
|
||||||
|
}))
|
||||||
|
const total = points.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
const max = points.reduce((largest, item) => Math.max(largest, item.value), 0)
|
||||||
|
return this.buildTrendView(total, max, points)
|
||||||
|
},
|
||||||
|
buildTrendView(total, max, points) {
|
||||||
|
const maxValue = Math.max(1, max || 0)
|
||||||
|
const lastIndex = Math.max(1, points.length - 1)
|
||||||
|
const chartInset = 8
|
||||||
|
const chartSpan = 100 - chartInset * 2
|
||||||
|
const plottedPoints = points.map((item, index) => {
|
||||||
|
const left = chartInset + (index * chartSpan / lastIndex)
|
||||||
|
const top = 82 - (Number(item.value || 0) / maxValue) * 68
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
left: left.toFixed(2),
|
||||||
|
top: top.toFixed(2),
|
||||||
|
edgeClass: index === 0 ? 'first' : (index === points.length - 1 ? 'last' : ''),
|
||||||
|
valueText: String(item.value || 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const segments = []
|
||||||
|
for (let index = 0; index < plottedPoints.length - 1; index += 1) {
|
||||||
|
const start = plottedPoints[index]
|
||||||
|
const end = plottedPoints[index + 1]
|
||||||
|
const dx = Number(end.left) - Number(start.left)
|
||||||
|
const dy = Number(end.top) - Number(start.top)
|
||||||
|
const dxRpx = dx * 5.6
|
||||||
|
const dyRpx = dy * 2.2
|
||||||
|
const width = Math.sqrt(dxRpx * dxRpx + dyRpx * dyRpx) / 5.6
|
||||||
|
const angle = Math.atan2(dyRpx, dxRpx) * 180 / Math.PI
|
||||||
|
segments.push({
|
||||||
|
left: start.left,
|
||||||
|
top: start.top,
|
||||||
|
width: width.toFixed(2),
|
||||||
|
angle: angle.toFixed(2),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { total, max, points: plottedPoints, segments }
|
||||||
|
},
|
||||||
|
buildActions(user) {
|
||||||
|
if (!user) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'worker') {
|
||||||
|
return [
|
||||||
|
{ key: 'records', icon: '记', title: '我的报工记录', desc: '按日期查看提交和审核状态', url: '/pages/records/records' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return [
|
||||||
|
{ key: 'products', icon: '产', title: '产品清单', desc: '导入、添加、修改产品', url: '/pages/manageProducts/manageProducts' },
|
||||||
|
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
|
||||||
|
{ key: 'review', icon: '审', title: '报工审核', desc: '处理待审核报工', url: '/pages/review/review' },
|
||||||
|
{ key: 'audit', icon: '核', title: '审核记录', desc: '查看已审核报工', url: '/pages/records/records?mode=audit' },
|
||||||
|
{ key: 'monitor', icon: '监', title: '智能监控', desc: '查看模具占用和反馈', url: '/pages/smartMonitor/smartMonitor' },
|
||||||
|
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
|
||||||
|
{ key: 'moldQr', icon: '码', title: '模具二维码', desc: '生成模具扫码入口', url: '/pages/deviceQr/deviceQr' },
|
||||||
|
{ key: 'equipment', icon: '设', title: '设备管理', desc: '维护设备号和备注', url: '/pages/manageEquipment/manageEquipment' },
|
||||||
|
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'products', icon: '产', title: '产品清单', desc: '导入或添加新产品', url: '/pages/manageProducts/manageProducts' },
|
||||||
|
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
|
||||||
|
{ key: 'schedule', icon: '勤', title: '考勤设置', desc: '维护作息时间和考勤范围', url: '/pages/workSchedule/workSchedule' },
|
||||||
|
{ key: 'ledger', icon: '账', title: '对账小账本', desc: '按月核对最后工序成品数量', url: '/pages/reconciliationLedger/reconciliationLedger' },
|
||||||
|
{ key: 'dashboard', icon: '看', title: '报工看板', desc: '按日期查看工人日报', url: '/pages/dashboard/dashboard' },
|
||||||
|
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
|
||||||
|
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
decodeRepeated(value) {
|
||||||
|
let text = String(value || '').trim()
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(text)
|
||||||
|
if (decoded === text) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = decoded
|
||||||
|
} catch (error) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
},
|
||||||
|
extractMoldName(scanResult) {
|
||||||
|
const raw = this.decodeRepeated(scanResult)
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/(?:^|[?&])moldName=([^&]+)/i,
|
||||||
|
/(?:^|[?&])mold_name=([^&]+)/i,
|
||||||
|
/(?:^|[?&])deviceNo=([^&]+)/i,
|
||||||
|
/(?:^|[?&])device_no=([^&]+)/i,
|
||||||
|
/(?:^|[?&])scene=([^&]+)/i,
|
||||||
|
/moldName=([^&]+)/i,
|
||||||
|
/deviceNo=([^&]+)/i,
|
||||||
|
]
|
||||||
|
for (let index = 0; index < patterns.length; index += 1) {
|
||||||
|
const matched = raw.match(patterns[index])
|
||||||
|
if (matched && matched[1]) {
|
||||||
|
const value = this.decodeRepeated(matched[1])
|
||||||
|
if (value.includes('moldName=') || value.includes('deviceNo=')) {
|
||||||
|
return this.extractMoldName(value)
|
||||||
|
}
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
},
|
||||||
|
async scanDevice() {
|
||||||
|
wx.showLoading({ title: '定位中' })
|
||||||
|
try {
|
||||||
|
await locationGuard.ensureFactoryLocation()
|
||||||
|
} catch (error) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({
|
||||||
|
title: '无法扫码报工',
|
||||||
|
content: error.message || '当前位置不在允许范围内',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.scanCode({
|
||||||
|
onlyFromCamera: false,
|
||||||
|
success: async res => {
|
||||||
|
const candidates = [res.path, res.result, res.rawData]
|
||||||
|
const moldName = candidates.reduce((found, item) => (
|
||||||
|
found || this.extractMoldName(item)
|
||||||
|
), '')
|
||||||
|
if (!moldName) {
|
||||||
|
wx.showToast({ title: '未识别模具名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const paramName = moldName.startsWith('mold=') ? 'scene' : 'moldName'
|
||||||
|
if (paramName === 'scene') {
|
||||||
|
try {
|
||||||
|
wx.showLoading({ title: '校验中' })
|
||||||
|
const resolved = await api.resolveMoldScene(moldName)
|
||||||
|
await locationGuard.ensureFactoryLocation(resolved.attendancePointName)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '无法扫码报工',
|
||||||
|
content: error.message || '当前位置不在该考勤点范围内',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/clock/clock?${paramName}=${encodeURIComponent(moldName)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: error => {
|
||||||
|
if (error.errMsg && error.errMsg.includes('cancel')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showToast({ title: '扫码失败', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async wechatLogin(e) {
|
||||||
|
const phoneCode = e.detail && e.detail.code
|
||||||
|
if (!phoneCode) {
|
||||||
|
wx.showToast({ title: '未获得手机号授权', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '登录中' })
|
||||||
|
try {
|
||||||
|
const result = await api.loginWithPhoneCode(phoneCode)
|
||||||
|
if (result.needsTemporaryWorker) {
|
||||||
|
wx.hideLoading()
|
||||||
|
await this.confirmTemporaryWorker(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.needsSelection) {
|
||||||
|
this.setData({
|
||||||
|
roleChoices: result.matchedPeople,
|
||||||
|
selectionToken: result.selectionToken,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.load({ refreshLocation: true })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmTemporaryWorker(result) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.temporaryWorkerResult = result
|
||||||
|
this.temporaryWorkerResolve = resolve
|
||||||
|
this.setData({
|
||||||
|
temporaryWorkerModalVisible: true,
|
||||||
|
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onTemporaryWorkerNameInput(e) {
|
||||||
|
this.setData({ temporaryWorkerName: e.detail.value })
|
||||||
|
},
|
||||||
|
closeTemporaryWorkerModal() {
|
||||||
|
this.setData({
|
||||||
|
temporaryWorkerModalVisible: false,
|
||||||
|
temporaryWorkerPrompt: '',
|
||||||
|
temporaryWorkerName: '',
|
||||||
|
})
|
||||||
|
this.temporaryWorkerResult = null
|
||||||
|
if (this.temporaryWorkerResolve) {
|
||||||
|
this.temporaryWorkerResolve()
|
||||||
|
this.temporaryWorkerResolve = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitTemporaryWorker() {
|
||||||
|
const result = this.temporaryWorkerResult
|
||||||
|
const name = String(this.data.temporaryWorkerName || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
wx.showToast({ title: '请输入姓名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result || !result.temporaryToken) {
|
||||||
|
wx.showToast({ title: '临时工注册信息已失效,请重新登录', icon: 'none' })
|
||||||
|
this.closeTemporaryWorkerModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ temporaryWorkerModalVisible: false })
|
||||||
|
wx.showLoading({ title: '定位中' })
|
||||||
|
try {
|
||||||
|
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
|
||||||
|
wx.showLoading({ title: '创建中' })
|
||||||
|
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
|
||||||
|
await this.load({ refreshLocation: true })
|
||||||
|
wx.showToast({ title: '临时工已生效', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.closeTemporaryWorkerModal()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
api.logout()
|
||||||
|
this.setData({ locationInitialized: false })
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
chooseAvatar() {
|
||||||
|
const user = this.data.currentUser
|
||||||
|
if (!user) {
|
||||||
|
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = tempFilePath => {
|
||||||
|
const saveToStorage = path => {
|
||||||
|
wx.setStorageSync(avatarKey(user.phone), path)
|
||||||
|
this.setData({ avatarPath: path })
|
||||||
|
}
|
||||||
|
if (wx.saveFile) {
|
||||||
|
wx.saveFile({
|
||||||
|
tempFilePath,
|
||||||
|
success: res => saveToStorage(res.savedFilePath),
|
||||||
|
fail: () => saveToStorage(tempFilePath),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveToStorage(tempFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wx.chooseMedia) {
|
||||||
|
wx.chooseMedia({
|
||||||
|
count: 1,
|
||||||
|
mediaType: ['image'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: res => {
|
||||||
|
const file = res.tempFiles && res.tempFiles[0]
|
||||||
|
if (file && file.tempFilePath) {
|
||||||
|
save(file.tempFilePath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: res => {
|
||||||
|
const path = res.tempFilePaths && res.tempFilePaths[0]
|
||||||
|
if (path) {
|
||||||
|
save(path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
switchCurrentRole() {
|
||||||
|
const user = this.data.currentUser
|
||||||
|
const choices = (user && user.rolesDisplay ? user.rolesDisplay : [])
|
||||||
|
.filter(item => item.role !== user.role)
|
||||||
|
if (!choices.length) {
|
||||||
|
wx.showToast({ title: '没有可切换的角色', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList: choices.map(item => item.roleName),
|
||||||
|
success: async res => {
|
||||||
|
const target = choices[res.tapIndex]
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '切换中' })
|
||||||
|
try {
|
||||||
|
await api.switchRole(target.role)
|
||||||
|
await this.load({ refreshLocation: true })
|
||||||
|
wx.showToast({ title: '已切换角色', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async chooseRole(e) {
|
||||||
|
const role = e.currentTarget.dataset.role
|
||||||
|
wx.showLoading({ title: '登录中' })
|
||||||
|
try {
|
||||||
|
await api.selectRole(this.data.selectionToken, role)
|
||||||
|
this.setData({ roleChoices: [], selectionToken: '' })
|
||||||
|
await this.load({ refreshLocation: true })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openAction(e) {
|
||||||
|
wx.navigateTo({
|
||||||
|
url: e.currentTarget.dataset.url,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openOverview(e) {
|
||||||
|
const url = e.currentTarget.dataset.url
|
||||||
|
const disabledText = e.currentTarget.dataset.disabledText
|
||||||
|
if (!url) {
|
||||||
|
wx.showToast({ title: disabledText || '当前角色无权限', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.navigateTo({ url })
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
resetDemo() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '退出当前账号后需要重新进行手机号快捷登录。',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
api.logout()
|
||||||
|
this.setData({ roleChoices: [], selectionToken: '', locationInitialized: false })
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
4
pages/index/index.json
Normal file
4
pages/index/index.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
}
|
||||||
|
}
|
||||||
268
pages/index/index.wxml
Normal file
268
pages/index/index.wxml
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
<view class="index-shell">
|
||||||
|
<scroll-view
|
||||||
|
class="page index-page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<view class="header-row">
|
||||||
|
<view class="brand-block">
|
||||||
|
<text class="title metallic-title">嘉恒智能报工</text>
|
||||||
|
<text class="subtitle">宁波嘉恒智能科技有限公司</text>
|
||||||
|
<view wx:if="{{currentUser}}" class="location-status">
|
||||||
|
<text>当前位置:</text>
|
||||||
|
<block wx:if="{{locationEntered}}">
|
||||||
|
<text>已进入 </text>
|
||||||
|
<text class="location-point-name">{{currentAttendancePointName}}</text>
|
||||||
|
<text> 考勤点</text>
|
||||||
|
</block>
|
||||||
|
<text wx:else class="location-not-entered">未进入考勤点</text>
|
||||||
|
</view>
|
||||||
|
<text wx:if="{{currentUser}}" class="location-refresh-tip">下拉刷新位置</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{currentUser}}" class="header-user" bindtap="switchCurrentRole">
|
||||||
|
<view class="avatar small-avatar">
|
||||||
|
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
|
||||||
|
<text wx:else class="avatar-text">{{avatarText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-user-text">
|
||||||
|
<text class="header-name">{{currentUser.name}}</text>
|
||||||
|
<view class="role-line">
|
||||||
|
<text>{{currentRoleName}}</text>
|
||||||
|
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-pane" hidden="{{activeTab !== 'home'}}">
|
||||||
|
<button class="scan-btn" bindtap="scanDevice">扫一扫</button>
|
||||||
|
|
||||||
|
<view wx:if="{{currentUser}}" class="notice-card">
|
||||||
|
<view class="notice-hero">
|
||||||
|
<view class="notice-badge">通知</view>
|
||||||
|
<swiper wx:if="{{notices.length}}" class="notice-swiper" autoplay circular vertical interval="3200" duration="420">
|
||||||
|
<swiper-item wx:for="{{notices}}" wx:key="id">
|
||||||
|
<view class="notice-slide">
|
||||||
|
<text class="notice-title">{{item.title}}</text>
|
||||||
|
<text class="notice-content">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
<view wx:else class="notice-empty">
|
||||||
|
<text class="notice-title">暂无通知</text>
|
||||||
|
<text class="notice-content">今天暂无新的通知。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{noticeList.length}}" class="notice-list">
|
||||||
|
<view wx:for="{{noticeList}}" wx:key="id" class="notice-list-row">
|
||||||
|
<text class="notice-dot"></text>
|
||||||
|
<text class="notice-list-title">{{item.title}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!currentUser}}" class="card">
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{roleChoices.length}}" class="role-list">
|
||||||
|
<text class="label">请选择角色</text>
|
||||||
|
<button
|
||||||
|
wx:for="{{roleChoices}}"
|
||||||
|
wx:key="role"
|
||||||
|
class="role-btn"
|
||||||
|
data-role="{{item.role}}"
|
||||||
|
bindtap="chooseRole"
|
||||||
|
>
|
||||||
|
{{item.name}} · {{item.roleName}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{showOverview}}">
|
||||||
|
<view class="section-title">概览</view>
|
||||||
|
<view class="grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{overviewItems}}"
|
||||||
|
wx:key="key"
|
||||||
|
class="stat tappable-stat stat-{{item.key}} {{item.alert ? 'stat-alert' : ''}}"
|
||||||
|
data-url="{{item.url}}"
|
||||||
|
data-disabled-text="{{item.disabledText}}"
|
||||||
|
bindtap="openOverview"
|
||||||
|
>
|
||||||
|
<text class="label">{{item.label}}</text>
|
||||||
|
<text class="stat-number">{{item.value || 0}}</text>
|
||||||
|
<text class="stat-link">查看清单</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block wx:if="{{showWorkerTrend}}">
|
||||||
|
<view class="section-title">最近7天成品数量</view>
|
||||||
|
<view class="card trend-card">
|
||||||
|
<view class="row trend-head">
|
||||||
|
<view>
|
||||||
|
<text class="label">7天合计</text>
|
||||||
|
<text class="value">{{workerTrend.total || 0}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="text-right">
|
||||||
|
<text class="label">单日最高</text>
|
||||||
|
<text class="value">{{workerTrend.max || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="trend-plot">
|
||||||
|
<view class="trend-axis trend-axis-top">{{workerTrend.max || 0}}</view>
|
||||||
|
<view class="trend-axis trend-axis-bottom">0</view>
|
||||||
|
<view class="trend-plot-body">
|
||||||
|
<view class="trend-grid-line top"></view>
|
||||||
|
<view class="trend-grid-line middle"></view>
|
||||||
|
<view class="trend-grid-line bottom"></view>
|
||||||
|
<view
|
||||||
|
wx:for="{{workerTrend.segments}}"
|
||||||
|
wx:key="index"
|
||||||
|
class="trend-segment"
|
||||||
|
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.width}}%; transform: rotate({{item.angle}}deg);"
|
||||||
|
></view>
|
||||||
|
<view
|
||||||
|
wx:for="{{workerTrend.points}}"
|
||||||
|
wx:key="date"
|
||||||
|
class="trend-point-wrap {{item.edgeClass}}"
|
||||||
|
style="left: {{item.left}}%; top: {{item.top}}%;"
|
||||||
|
>
|
||||||
|
<text wx:if="{{item.value}}" class="trend-point-value">{{item.valueText}}</text>
|
||||||
|
<text class="trend-point"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="trend-label-row">
|
||||||
|
<text
|
||||||
|
wx:for="{{workerTrend.points}}"
|
||||||
|
wx:key="date"
|
||||||
|
class="trend-date-label {{item.edgeClass}}"
|
||||||
|
style="left: {{item.left}}%;"
|
||||||
|
>{{item.label}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-pane" hidden="{{activeTab !== 'workbench'}}">
|
||||||
|
<view class="section-title">工作台</view>
|
||||||
|
<view wx:if="{{!actions.length}}" class="list-empty">请选择账号</view>
|
||||||
|
<view wx:else class="workbench-panel">
|
||||||
|
<view class="app-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{actions}}"
|
||||||
|
wx:key="key"
|
||||||
|
class="app-item"
|
||||||
|
data-url="{{item.url}}"
|
||||||
|
data-action="{{item.action}}"
|
||||||
|
bindtap="openAction"
|
||||||
|
>
|
||||||
|
<view class="app-icon app-icon-{{item.key}}">{{item.icon}}</view>
|
||||||
|
<text class="app-title">{{item.title}}</text>
|
||||||
|
<text class="app-desc">{{item.desc}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tab-pane" hidden="{{activeTab !== 'mine'}}">
|
||||||
|
<view class="section-title">我的</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!currentUser}}" class="card">
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{roleChoices.length}}" class="role-list">
|
||||||
|
<text class="label">请选择角色</text>
|
||||||
|
<button
|
||||||
|
wx:for="{{roleChoices}}"
|
||||||
|
wx:key="role"
|
||||||
|
class="role-btn"
|
||||||
|
data-role="{{item.role}}"
|
||||||
|
bindtap="chooseRole"
|
||||||
|
>
|
||||||
|
{{item.name}} · {{item.roleName}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="card profile-card">
|
||||||
|
<view class="profile-head">
|
||||||
|
<view class="avatar large-avatar">
|
||||||
|
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
|
||||||
|
<text wx:else class="avatar-text large-avatar-text">{{avatarText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="profile-main">
|
||||||
|
<text class="profile-name">{{currentUser.name}}</text>
|
||||||
|
<text class="subtitle">{{currentUser.phone}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="info-row" bindtap="switchCurrentRole">
|
||||||
|
<text class="label inline-label">当前角色</text>
|
||||||
|
<view class="info-value">
|
||||||
|
<text>{{currentRoleName}}</text>
|
||||||
|
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="label inline-label">全部角色</text>
|
||||||
|
<text class="value">{{currentRolesText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{currentUser.role !== 'manager'}}" class="info-row">
|
||||||
|
<text class="label inline-label">考勤点</text>
|
||||||
|
<text class="value">{{currentUser.attendancePointsText || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="chooseAvatar">更换头像</button>
|
||||||
|
<button class="btn danger full" bindtap="resetDemo">退出登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="bottom-bar">
|
||||||
|
<view class="bottom-item {{activeTab === 'home' ? 'active' : ''}}" data-tab="home" bindtap="switchTab">
|
||||||
|
<text class="bottom-icon">⌂</text>
|
||||||
|
<text>首页</text>
|
||||||
|
</view>
|
||||||
|
<view class="bottom-item {{activeTab === 'workbench' ? 'active' : ''}}" data-tab="workbench" bindtap="switchTab">
|
||||||
|
<text class="bottom-icon">▦</text>
|
||||||
|
<text>工作台</text>
|
||||||
|
</view>
|
||||||
|
<view class="bottom-item {{activeTab === 'mine' ? 'active' : ''}}" data-tab="mine" bindtap="switchTab">
|
||||||
|
<text class="bottom-icon">○</text>
|
||||||
|
<text>我的</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
|
||||||
|
<view class="temp-worker-panel" catchtap="noop">
|
||||||
|
<text class="temp-worker-title">创建临时工</text>
|
||||||
|
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
|
||||||
|
<view class="temp-worker-field">
|
||||||
|
<text class="temp-worker-label">姓名</text>
|
||||||
|
<input
|
||||||
|
class="input temp-worker-input"
|
||||||
|
value="{{temporaryWorkerName}}"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
bindinput="onTemporaryWorkerNameInput"
|
||||||
|
confirm-type="done"
|
||||||
|
cursor-spacing="24"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="button-row temp-worker-actions">
|
||||||
|
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
|
||||||
|
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
806
pages/index/index.wxss
Normal file
806
pages/index/index.wxss
Normal file
@ -0,0 +1,806 @@
|
|||||||
|
.index-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f2f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page {
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom) + 150rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-page .header::after {
|
||||||
|
left: 18rpx;
|
||||||
|
right: auto;
|
||||||
|
top: 128rpx;
|
||||||
|
width: 150rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
opacity: 0.06;
|
||||||
|
transform: rotate(14deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-status {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-refresh-tip {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
color: #7a8798;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-not-entered {
|
||||||
|
color: #c5221f;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-point-name {
|
||||||
|
color: #087443;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metallic-title {
|
||||||
|
display: inline-block;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 900;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, #38414d 0%, #dfe6ec 7%, #7f8b97 14%, #f8fbfd 22%, #59636f 31%, #eef3f6 41%, #3f4853 52%, #c4cdd6 64%, #ffffff 73%, #626c78 84%, #2f3741 100%),
|
||||||
|
linear-gradient(180deg, #f9fbfc 0%, #a7b0bb 18%, #ffffff 34%, #5b6470 54%, #d3dae2 74%, #3b4450 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
-webkit-text-stroke: 1rpx rgba(47, 55, 65, 0.42);
|
||||||
|
text-shadow:
|
||||||
|
0 1rpx 0 rgba(255, 255, 255, 0.92),
|
||||||
|
0 3rpx 0 rgba(87, 96, 108, 0.78),
|
||||||
|
0 6rpx 0 rgba(38, 45, 54, 0.62),
|
||||||
|
0 12rpx 16rpx rgba(15, 23, 42, 0.28),
|
||||||
|
0 20rpx 28rpx rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 300rpx;
|
||||||
|
padding: 10rpx 12rpx;
|
||||||
|
border: 1rpx solid #e1e7f0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(205, 216, 229, 0.54),
|
||||||
|
0 14rpx 22rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user-text {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-name {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-arrow {
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e8f1ff;
|
||||||
|
color: #1456c2;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-avatar {
|
||||||
|
width: 66rpx;
|
||||||
|
height: 66rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-avatar {
|
||||||
|
width: 112rpx;
|
||||||
|
height: 112rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
display: block;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-avatar-text {
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 112rpx;
|
||||||
|
line-height: 112rpx;
|
||||||
|
margin: 0 0 28rpx;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow:
|
||||||
|
0 12rpx 0 #0d3f93,
|
||||||
|
0 20rpx 30rpx rgba(20, 86, 194, 0.28),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-btn:active {
|
||||||
|
transform: translateY(5rpx);
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 #0d3f93,
|
||||||
|
0 14rpx 22rpx rgba(20, 86, 194, 0.22),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
border: 1rpx solid #dce5f2;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 14rpx 0 rgba(205, 216, 229, 0.62),
|
||||||
|
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-card::before,
|
||||||
|
.workbench-panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 18rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.54;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 42%, rgba(47, 55, 65, 0.78) 43%, rgba(47, 55, 65, 0.78) 57%, transparent 58%),
|
||||||
|
radial-gradient(circle at 32% 28%, #ffffff 0, #dfe6ec 19%, #97a3af 58%, #475467 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 3rpx 0 rgba(72, 83, 97, 0.38),
|
||||||
|
0 6rpx 10rpx rgba(22, 34, 50, 0.14),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.85);
|
||||||
|
transform: rotate(28deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-hero {
|
||||||
|
position: relative;
|
||||||
|
min-height: 178rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
background: linear-gradient(135deg, #1456c2 0%, #15847c 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -14rpx;
|
||||||
|
bottom: 14rpx;
|
||||||
|
width: 180rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
opacity: 0.14;
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 50%, transparent 0rpx, transparent 12rpx, #ffffff 13rpx, #ffffff 20rpx, transparent 21rpx),
|
||||||
|
radial-gradient(circle at 86% 50%, #ffffff 0rpx, #ffffff 16rpx, transparent 17rpx),
|
||||||
|
linear-gradient(90deg, transparent 0rpx, transparent 30rpx, #ffffff 31rpx, #ffffff 142rpx, transparent 143rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 42rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-swiper {
|
||||||
|
height: 106rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-slide,
|
||||||
|
.notice-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list {
|
||||||
|
padding: 16rpx 26rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-height: 46rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-dot {
|
||||||
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-list-title {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-card {
|
||||||
|
padding-bottom: 14rpx;
|
||||||
|
border-left: 6rpx solid #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-head {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-plot {
|
||||||
|
position: relative;
|
||||||
|
height: 360rpx;
|
||||||
|
padding: 18rpx 8rpx 0 58rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-axis {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 48rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-axis-top {
|
||||||
|
top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-axis-bottom {
|
||||||
|
bottom: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-plot-body {
|
||||||
|
position: relative;
|
||||||
|
height: 244rpx;
|
||||||
|
margin-right: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-grid-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1rpx;
|
||||||
|
background: #e6eaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-grid-line.top {
|
||||||
|
top: 14%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-grid-line.middle {
|
||||||
|
top: 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-grid-line.bottom {
|
||||||
|
top: 82%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-segment {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(180deg, #6aa1ff 0%, #1463ff 100%);
|
||||||
|
box-shadow: 0 5rpx 10rpx rgba(20, 99, 255, 0.22);
|
||||||
|
transform-origin: 0 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-point-wrap {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
width: 1rpx;
|
||||||
|
height: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-point {
|
||||||
|
position: absolute;
|
||||||
|
left: -11rpx;
|
||||||
|
top: -11rpx;
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border: 5rpx solid #1463ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 12rpx rgba(20, 99, 255, 0.2),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-point-value {
|
||||||
|
position: absolute;
|
||||||
|
left: -44rpx;
|
||||||
|
bottom: 18rpx;
|
||||||
|
width: 88rpx;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 21rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-label-row {
|
||||||
|
position: relative;
|
||||||
|
height: 40rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
margin-right: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-date-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 78rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tappable-stat {
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tappable-stat:active {
|
||||||
|
background: #f3f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-products {
|
||||||
|
border-left-color: #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-people {
|
||||||
|
border-left-color: #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-pending {
|
||||||
|
border-left-color: #d9822b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-alert {
|
||||||
|
border-color: #ffb4a8;
|
||||||
|
border-left-color: #d92d20;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(217, 45, 32, 0.13), rgba(255, 255, 255, 0.92) 48%),
|
||||||
|
#fff7f5;
|
||||||
|
box-shadow:
|
||||||
|
0 14rpx 0 rgba(244, 179, 169, 0.7),
|
||||||
|
0 22rpx 34rpx rgba(180, 35, 24, 0.14),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-alert .stat-number,
|
||||||
|
.stat-alert .stat-link {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-approved {
|
||||||
|
border-left-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: #6b778c;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
padding-bottom: 28rpx;
|
||||||
|
border-left: 6rpx solid #12a375;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
display: block;
|
||||||
|
color: #1f2933;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 72rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
color: #1f2933;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-list {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 rgba(144, 178, 235, 0.62),
|
||||||
|
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-panel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24rpx 18rpx 10rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 14rpx 0 rgba(205, 216, 229, 0.62),
|
||||||
|
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 178rpx;
|
||||||
|
padding: 16rpx 8rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item:active {
|
||||||
|
background: #f3f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item:active .app-icon {
|
||||||
|
transform: translateY(4rpx);
|
||||||
|
box-shadow:
|
||||||
|
0 5rpx 0 rgba(74, 91, 115, 0.42),
|
||||||
|
0 8rpx 14rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.56),
|
||||||
|
inset 0 -8rpx 12rpx rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 78rpx;
|
||||||
|
height: 78rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.62);
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow:
|
||||||
|
0 2rpx 0 rgba(255, 255, 255, 0.82),
|
||||||
|
0 4rpx 8rpx rgba(15, 23, 42, 0.18);
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(77, 103, 145, 0.42),
|
||||||
|
0 16rpx 22rpx rgba(22, 34, 50, 0.16),
|
||||||
|
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
|
||||||
|
inset 0 -10rpx 14rpx rgba(15, 23, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon::before,
|
||||||
|
.app-icon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 40%, rgba(47, 55, 65, 0.72) 41%, rgba(47, 55, 65, 0.72) 59%, transparent 60%),
|
||||||
|
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.96) 0, rgba(226, 232, 240, 0.96) 20%, rgba(132, 146, 164, 0.88) 62%, rgba(59, 70, 84, 0.86) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 2rpx 0 rgba(49, 61, 77, 0.36),
|
||||||
|
inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon::before {
|
||||||
|
left: 10rpx;
|
||||||
|
top: 10rpx;
|
||||||
|
transform: rotate(24deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon::after {
|
||||||
|
right: 10rpx;
|
||||||
|
bottom: 10rpx;
|
||||||
|
transform: rotate(-28deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-products,
|
||||||
|
.app-icon-device,
|
||||||
|
.app-icon-moldQr,
|
||||||
|
.app-icon-equipment {
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-review,
|
||||||
|
.app-icon-audit {
|
||||||
|
background: linear-gradient(160deg, #fffdf7 0%, #ffe2aa 34%, #f0a23a 68%, #a95605 100%);
|
||||||
|
color: #a95605;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(143, 84, 16, 0.38),
|
||||||
|
0 16rpx 22rpx rgba(104, 62, 17, 0.14),
|
||||||
|
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
|
||||||
|
inset 0 -10rpx 14rpx rgba(94, 47, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-people,
|
||||||
|
.app-icon-dashboard,
|
||||||
|
.app-icon-schedule {
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #c9f2df 32%, #5fd19d 64%, #087443 100%);
|
||||||
|
color: #087443;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(24, 117, 77, 0.38),
|
||||||
|
0 16rpx 22rpx rgba(20, 92, 63, 0.14),
|
||||||
|
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
|
||||||
|
inset 0 -10rpx 14rpx rgba(0, 81, 49, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-records,
|
||||||
|
.app-icon-notices {
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #e6d8ff 32%, #b68dff 64%, #6d35c7 100%);
|
||||||
|
color: #6d35c7;
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(98, 67, 151, 0.38),
|
||||||
|
0 16rpx 22rpx rgba(81, 54, 125, 0.14),
|
||||||
|
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
|
||||||
|
inset 0 -10rpx 14rpx rgba(65, 29, 122, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-desc {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #7a8699;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
padding: 10rpx 22rpx calc(env(safe-area-inset-bottom) + 10rpx);
|
||||||
|
border-top: 1rpx solid #e4e9f1;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f6f9fd 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 -8rpx 0 rgba(205, 216, 229, 0.42),
|
||||||
|
0 -14rpx 28rpx rgba(22, 34, 50, 0.1),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 94rpx;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-item.active {
|
||||||
|
background: linear-gradient(180deg, #eef5ff 0%, #dceaff 100%);
|
||||||
|
color: #1463ff;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.95),
|
||||||
|
0 7rpx 0 rgba(144, 178, 235, 0.46),
|
||||||
|
0 12rpx 20rpx rgba(20, 86, 194, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-item.active text:not(.bottom-icon) {
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-item text:not(.bottom-icon) {
|
||||||
|
color: #7a8699;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
border: 1rpx solid #c8d2df;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #e7edf5 100%);
|
||||||
|
color: #8b96a8;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow:
|
||||||
|
0 5rpx 0 rgba(157, 170, 188, 0.4),
|
||||||
|
0 8rpx 12rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-item.active .bottom-icon {
|
||||||
|
width: 58rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
border-color: rgba(255, 255, 255, 0.72);
|
||||||
|
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #72a9ff 58%, #1456c2 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 34rpx;
|
||||||
|
text-shadow: 0 2rpx 4rpx rgba(15, 23, 42, 0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 #0d3f93,
|
||||||
|
0 14rpx 20rpx rgba(20, 86, 194, 0.24),
|
||||||
|
inset 0 3rpx 0 rgba(255, 255, 255, 0.78),
|
||||||
|
inset 0 -8rpx 14rpx rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
18
pages/logs/logs.js
Normal file
18
pages/logs/logs.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// logs.js
|
||||||
|
const util = require('../../utils/util.js')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
logs: []
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.setData({
|
||||||
|
logs: (wx.getStorageSync('logs') || []).map(log => {
|
||||||
|
return {
|
||||||
|
date: util.formatTime(new Date(log)),
|
||||||
|
timeStamp: log
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
4
pages/logs/logs.json
Normal file
4
pages/logs/logs.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"usingComponents": {
|
||||||
|
}
|
||||||
|
}
|
||||||
6
pages/logs/logs.wxml
Normal file
6
pages/logs/logs.wxml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<!--logs.wxml-->
|
||||||
|
<scroll-view class="scrollarea" scroll-y type="list">
|
||||||
|
<block wx:for="{{logs}}" wx:key="timeStamp" wx:for-item="log">
|
||||||
|
<view class="log-item">{{index + 1}}. {{log.date}}</view>
|
||||||
|
</block>
|
||||||
|
</scroll-view>
|
||||||
16
pages/logs/logs.wxss
Normal file
16
pages/logs/logs.wxss
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
page {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.scrollarea {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
.log-item {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.log-item:last-child {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
216
pages/manageEquipment/manageEquipment.js
Normal file
216
pages/manageEquipment/manageEquipment.js
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
attendancePointName: '',
|
||||||
|
deviceNo: '',
|
||||||
|
deviceType: '冲压设备',
|
||||||
|
remark: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceTypeOptions = ['冲压设备', '清洗设备']
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
keyword: '',
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
formVisible: false,
|
||||||
|
formTitle: '新增设备',
|
||||||
|
attendancePoints: [],
|
||||||
|
attendancePointLabels: [],
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
filterAttendancePointLabels: ['全部考勤点'],
|
||||||
|
filterAttendancePointIndex: 0,
|
||||||
|
filterAttendancePointName: '',
|
||||||
|
deviceTypeOptions,
|
||||||
|
refreshing: false,
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
await this.loadAttendancePoints()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
async loadAttendancePoints() {
|
||||||
|
try {
|
||||||
|
const points = await api.listAccessibleAttendancePoints()
|
||||||
|
const attendancePoints = points || []
|
||||||
|
this.setData({
|
||||||
|
attendancePoints,
|
||||||
|
attendancePointLabels: attendancePoints.map(item => item.name),
|
||||||
|
filterAttendancePointLabels: ['全部考勤点'].concat(attendancePoints.map(item => item.name)),
|
||||||
|
})
|
||||||
|
if (!this.data.form.attendancePointName && attendancePoints.length) {
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePointName': attendancePoints[0].name,
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
try {
|
||||||
|
const result = await api.listEquipment({
|
||||||
|
keyword: this.data.keyword,
|
||||||
|
attendancePointName: this.data.filterAttendancePointName,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
rows: result.rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.setData({ keyword: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onFilterAttendancePointChange(e) {
|
||||||
|
const filterAttendancePointIndex = Number(e.detail.value)
|
||||||
|
const point = this.data.attendancePoints[filterAttendancePointIndex - 1]
|
||||||
|
this.setData({
|
||||||
|
filterAttendancePointIndex,
|
||||||
|
filterAttendancePointName: point ? point.name : '',
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`form.${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeviceTypeChange(e) {
|
||||||
|
const index = Number(e.detail.value)
|
||||||
|
this.setData({
|
||||||
|
'form.deviceType': deviceTypeOptions[index] || '冲压设备',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onAttendancePointChange(e) {
|
||||||
|
const attendancePointIndex = Number(e.detail.value)
|
||||||
|
const point = this.data.attendancePoints[attendancePointIndex]
|
||||||
|
this.setData({
|
||||||
|
attendancePointIndex,
|
||||||
|
'form.attendancePointName': point ? point.name : '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
openCreate() {
|
||||||
|
const firstPoint = this.data.attendancePoints[0]
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
formTitle: '新增设备',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
edit(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const equipment = this.data.rows[index]
|
||||||
|
const attendancePointIndex = Math.max(0, this.data.attendancePoints.findIndex(item => item.name === equipment.attendancePointName))
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
...equipment,
|
||||||
|
originalAttendancePointName: equipment.attendancePointName,
|
||||||
|
originalDeviceNo: equipment.deviceNo,
|
||||||
|
},
|
||||||
|
attendancePointIndex,
|
||||||
|
formTitle: '编辑设备',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeForm() {
|
||||||
|
this.setData({
|
||||||
|
formVisible: false,
|
||||||
|
form: { ...emptyForm, attendancePointName: this.data.attendancePoints[0] ? this.data.attendancePoints[0].name : '' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearForm() {
|
||||||
|
const firstPoint = this.data.attendancePoints[0]
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
try {
|
||||||
|
if (!this.data.form.attendancePointName) {
|
||||||
|
wx.showToast({ title: '请选择考勤点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await api.saveEquipment(this.data.form)
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({ form: { ...emptyForm }, formVisible: false })
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const equipment = this.data.rows[index]
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除设备',
|
||||||
|
content: equipment.deviceNo,
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#d92d20',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteEquipment(equipment)
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/manageEquipment/manageEquipment.json
Normal file
3
pages/manageEquipment/manageEquipment.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "设备管理"
|
||||||
|
}
|
||||||
93
pages/manageEquipment/manageEquipment.wxml
Normal file
93
pages/manageEquipment/manageEquipment.wxml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">设备管理</text>
|
||||||
|
<text class="subtitle">维护员工报工可选择的设备号和设备类型</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<button class="btn primary full add-btn" bindtap="openCreate">新增设备</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="equipment-filter-card">
|
||||||
|
<input class="input search" placeholder="搜索考勤点、设备号、类型、备注" value="{{keyword}}" bindinput="onKeywordInput" />
|
||||||
|
<picker
|
||||||
|
range="{{filterAttendancePointLabels}}"
|
||||||
|
value="{{filterAttendancePointIndex}}"
|
||||||
|
bindchange="onFilterAttendancePointChange"
|
||||||
|
>
|
||||||
|
<view class="picker filter-picker">{{filterAttendancePointLabels[filterAttendancePointIndex] || '全部考勤点'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无设备</view>
|
||||||
|
<view wx:for="{{rows}}" wx:key="uniqueKey" wx:for-index="index" class="card equipment-card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.deviceNo}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.remark || '暂无备注'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary">{{item.deviceType || '冲压设备'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
|
||||||
|
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
|
||||||
|
<view class="modal-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{formTitle}}</text>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">考勤点</text>
|
||||||
|
<picker
|
||||||
|
wx:if="{{attendancePoints.length}}"
|
||||||
|
range="{{attendancePointLabels}}"
|
||||||
|
value="{{attendancePointIndex}}"
|
||||||
|
bindchange="onAttendancePointChange"
|
||||||
|
>
|
||||||
|
<view class="picker">{{form.attendancePointName || '请选择考勤点'}}</view>
|
||||||
|
</picker>
|
||||||
|
<view wx:else class="readonly">暂无可选考勤点</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">设备号</text>
|
||||||
|
<input class="input" value="{{form.deviceNo}}" data-field="deviceNo" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">设备类型</text>
|
||||||
|
<picker range="{{deviceTypeOptions}}" value="{{form.deviceType === '清洗设备' ? 1 : 0}}" bindchange="onDeviceTypeChange">
|
||||||
|
<view class="picker">{{form.deviceType || '冲压设备'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">备注</text>
|
||||||
|
<textarea class="textarea" value="{{form.remark}}" data-field="remark" bindinput="onInput"></textarea>
|
||||||
|
</view>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="closeForm">关闭</button>
|
||||||
|
<button class="btn primary full" bindtap="save">保存设备</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
72
pages/manageEquipment/manageEquipment.wxss
Normal file
72
pages/manageEquipment/manageEquipment.wxss
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
.search {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-filter-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-picker {
|
||||||
|
min-height: 82rpx;
|
||||||
|
line-height: 82rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-card {
|
||||||
|
border-left: 6rpx solid #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
195
pages/manageNotices/manageNotices.js
Normal file
195
pages/manageNotices/manageNotices.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
attendancePointNames: [],
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
formVisible: false,
|
||||||
|
formTitle: '新增通知',
|
||||||
|
attendancePoints: [],
|
||||||
|
refreshing: false,
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
await this.loadAttendancePoints()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
async loadAttendancePoints() {
|
||||||
|
try {
|
||||||
|
const attendancePoints = await api.listAccessibleAttendancePoints()
|
||||||
|
this.setData({ attendancePoints: attendancePoints || [] })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
try {
|
||||||
|
const result = await api.listNotices({
|
||||||
|
includeInactive: true,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
rows: result.rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
checkedPointMap(names = []) {
|
||||||
|
const selected = new Set(names || [])
|
||||||
|
return this.data.attendancePoints.map(point => ({
|
||||||
|
...point,
|
||||||
|
checked: selected.has(point.name),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
defaultPointNames() {
|
||||||
|
return this.data.attendancePoints.map(point => point.name)
|
||||||
|
},
|
||||||
|
openCreate() {
|
||||||
|
const pointNames = this.defaultPointNames()
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointNames: pointNames },
|
||||||
|
attendancePoints: this.checkedPointMap(pointNames),
|
||||||
|
formTitle: '新增通知',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
edit(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const notice = this.data.rows[index]
|
||||||
|
this.setData({
|
||||||
|
form: { ...notice },
|
||||||
|
attendancePoints: this.checkedPointMap(notice.attendancePointNames || []),
|
||||||
|
formTitle: '编辑通知',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeForm() {
|
||||||
|
this.setData({
|
||||||
|
formVisible: false,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearForm() {
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`form.${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onActiveChange(e) {
|
||||||
|
this.setData({
|
||||||
|
'form.isActive': e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onPointCheckboxChange(e) {
|
||||||
|
const names = e.detail.value || []
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePointNames': names,
|
||||||
|
attendancePoints: this.checkedPointMap(names),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const form = {
|
||||||
|
...this.data.form,
|
||||||
|
sortOrder: Number(this.data.form.sortOrder || 0),
|
||||||
|
}
|
||||||
|
if (!(form.attendancePointNames || []).length) {
|
||||||
|
wx.showToast({ title: '请选择通知考勤点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '保存中' })
|
||||||
|
try {
|
||||||
|
await api.saveNotice(form)
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
formVisible: false,
|
||||||
|
})
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const notice = this.data.rows[index]
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除通知',
|
||||||
|
content: notice.title,
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteNotice(notice.id)
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/manageNotices/manageNotices.json
Normal file
3
pages/manageNotices/manageNotices.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "通知配置"
|
||||||
|
}
|
||||||
91
pages/manageNotices/manageNotices.wxml
Normal file
91
pages/manageNotices/manageNotices.wxml
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">通知配置</text>
|
||||||
|
<text class="subtitle">首页通知内容维护</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card action-panel">
|
||||||
|
<button class="btn primary full add-btn" bindtap="openCreate">新增通知</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无通知</view>
|
||||||
|
<view wx:for="{{rows}}" wx:key="id" wx:for-index="index" class="card notice-row">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.title}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointsText || '-'}}</text>
|
||||||
|
<text class="subtitle">排序 {{item.sortOrder}} · {{item.updatedAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill {{item.isActive ? 'success' : 'warn'}}">{{item.isActive ? '展示中' : '已停用'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="notice-content">{{item.content}}</text>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
|
||||||
|
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
|
||||||
|
<view class="modal-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{formTitle}}</text>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">标题</text>
|
||||||
|
<input class="input" value="{{form.title}}" data-field="title" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">内容</text>
|
||||||
|
<textarea class="textarea notice-textarea" value="{{form.content}}" data-field="content" bindinput="onInput"></textarea>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">通知考勤点</text>
|
||||||
|
<checkbox-group class="checkbox-grid" bindchange="onPointCheckboxChange">
|
||||||
|
<label
|
||||||
|
wx:for="{{attendancePoints}}"
|
||||||
|
wx:key="name"
|
||||||
|
class="checkbox-chip {{item.checked ? 'checked' : ''}}"
|
||||||
|
>
|
||||||
|
<checkbox value="{{item.name}}" checked="{{item.checked}}" color="#1463ff"></checkbox>
|
||||||
|
<text>{{item.name}}</text>
|
||||||
|
</label>
|
||||||
|
</checkbox-group>
|
||||||
|
</view>
|
||||||
|
<view class="form-grid">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">排序</text>
|
||||||
|
<input class="input number-input" type="number" value="{{form.sortOrder}}" data-field="sortOrder" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item status-form-item">
|
||||||
|
<text class="label">展示状态</text>
|
||||||
|
<view class="switch-row">
|
||||||
|
<text class="value">{{form.isActive ? '展示中' : '停用'}}</text>
|
||||||
|
<switch checked="{{form.isActive}}" bindchange="onActiveChange" color="#1463ff"></switch>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="closeForm">关闭</button>
|
||||||
|
<button class="btn primary full" bindtap="save">保存通知</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
131
pages/manageNotices/manageNotices.wxss
Normal file
131
pages/manageNotices/manageNotices.wxss
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
.action-panel {
|
||||||
|
padding: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-row {
|
||||||
|
border-left: 6rpx solid #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-textarea {
|
||||||
|
min-height: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 78rpx;
|
||||||
|
padding: 0 6rpx 0 20rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 6rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-form-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-height: 72rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 2rpx solid #cbd6e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);
|
||||||
|
color: #162232;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(166, 181, 203, 0.42),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-chip.checked {
|
||||||
|
border-color: #7aa7f5;
|
||||||
|
background: linear-gradient(180deg, #eef5ff 0%, #ddeaff 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
}
|
||||||
342
pages/managePeople/managePeople.js
Normal file
342
pages/managePeople/managePeople.js
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
phone: '',
|
||||||
|
name: '',
|
||||||
|
role: 'worker',
|
||||||
|
attendancePointNames: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRoleOptions = [
|
||||||
|
{ label: '冲压工人', value: 'worker' },
|
||||||
|
{ label: '管理员', value: 'admin' },
|
||||||
|
{ label: '经理', value: 'manager' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const workerRoleOptions = [
|
||||||
|
{ label: '冲压工人', value: 'worker' },
|
||||||
|
]
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
readonly: false,
|
||||||
|
keyword: '',
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
formVisible: false,
|
||||||
|
formTitle: '新增人员',
|
||||||
|
importFileName: '',
|
||||||
|
refreshing: false,
|
||||||
|
currentUser: null,
|
||||||
|
attendancePoints: [],
|
||||||
|
roleOptions: allRoleOptions,
|
||||||
|
roleIndex: 0,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.setData({
|
||||||
|
readonly: options.readonly === '1',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
const currentUser = api.getCurrentUser()
|
||||||
|
const roleOptions = this.roleOptionsForUser(currentUser)
|
||||||
|
this.setData({ currentUser, roleOptions })
|
||||||
|
await this.loadAttendancePoints()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
roleOptionsForUser(user = this.data.currentUser) {
|
||||||
|
return user && user.role === 'admin' ? workerRoleOptions : allRoleOptions
|
||||||
|
},
|
||||||
|
async loadAttendancePoints() {
|
||||||
|
try {
|
||||||
|
const attendancePoints = await api.listAccessibleAttendancePoints()
|
||||||
|
this.setData({ attendancePoints: attendancePoints || [] })
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
try {
|
||||||
|
const result = await api.listPeople({
|
||||||
|
keyword: this.data.keyword,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
rows: result.rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.setData({ keyword: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`form.${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onRoleChange(e) {
|
||||||
|
const roleIndex = Number(e.detail.value)
|
||||||
|
const role = this.data.roleOptions[roleIndex].value
|
||||||
|
const updates = {
|
||||||
|
roleIndex,
|
||||||
|
'form.role': role,
|
||||||
|
}
|
||||||
|
if (role === 'manager') {
|
||||||
|
if (!this.data.form.originalPhone) {
|
||||||
|
updates['form.attendancePointNames'] = []
|
||||||
|
updates.attendancePoints = this.checkedPointMap([])
|
||||||
|
}
|
||||||
|
} else if (!(this.data.form.attendancePointNames || []).length && this.data.attendancePoints[0]) {
|
||||||
|
const defaultPointNames = [this.data.attendancePoints[0].name]
|
||||||
|
updates['form.attendancePointNames'] = defaultPointNames
|
||||||
|
updates.attendancePoints = this.checkedPointMap(defaultPointNames)
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
...updates,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onPointCheckboxChange(e) {
|
||||||
|
const names = e.detail.value || []
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePointNames': names,
|
||||||
|
attendancePoints: this.checkedPointMap(names),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
checkedPointMap(names = []) {
|
||||||
|
const selected = new Set(names || [])
|
||||||
|
return this.data.attendancePoints.map(point => ({
|
||||||
|
...point,
|
||||||
|
checked: selected.has(point.name),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
openCreate() {
|
||||||
|
const defaultPointNames = this.data.attendancePoints[0] ? [this.data.attendancePoints[0].name] : []
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointNames: defaultPointNames },
|
||||||
|
attendancePoints: this.checkedPointMap(defaultPointNames),
|
||||||
|
roleIndex: 0,
|
||||||
|
formTitle: '新增人员',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeForm() {
|
||||||
|
this.setData({
|
||||||
|
formVisible: false,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
roleOptions: this.roleOptionsForUser(),
|
||||||
|
roleIndex: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
try {
|
||||||
|
const form = { ...this.data.form }
|
||||||
|
if (form.role !== 'manager' && !(form.attendancePointNames || []).length) {
|
||||||
|
wx.showToast({ title: '请选择考勤点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await api.savePerson(form)
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
roleOptions: this.roleOptionsForUser(),
|
||||||
|
roleIndex: 0,
|
||||||
|
formVisible: false,
|
||||||
|
})
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
edit(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const person = this.data.rows[index]
|
||||||
|
const roleIndex = Math.max(0, this.data.roleOptions.findIndex(item => item.value === person.role))
|
||||||
|
const pointNames = person.attendancePointNames || []
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
phone: person.phone,
|
||||||
|
originalPhone: person.phone,
|
||||||
|
name: person.name,
|
||||||
|
role: person.role,
|
||||||
|
attendancePointNames: pointNames,
|
||||||
|
},
|
||||||
|
attendancePoints: this.checkedPointMap(pointNames),
|
||||||
|
roleIndex,
|
||||||
|
roleOptions: this.roleOptionsForUser(),
|
||||||
|
formTitle: '编辑人员',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addRole(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const person = this.data.rows[index]
|
||||||
|
if (!person) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existingRoles = new Set(person.roles || [])
|
||||||
|
const roleOptions = this.roleOptionsForUser().filter(item => !existingRoles.has(item.value))
|
||||||
|
if (!roleOptions.length) {
|
||||||
|
wx.showToast({ title: '该人员已拥有全部可添加角色', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const role = roleOptions[0].value
|
||||||
|
const pointNames = person.attendancePointNames || []
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
phone: person.phone,
|
||||||
|
originalPhone: person.phone,
|
||||||
|
name: person.name,
|
||||||
|
role,
|
||||||
|
attendancePointNames: role === 'manager' ? pointNames : pointNames,
|
||||||
|
},
|
||||||
|
attendancePoints: this.checkedPointMap(pointNames),
|
||||||
|
roleOptions,
|
||||||
|
roleIndex: 0,
|
||||||
|
formTitle: '添加角色',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeRole(e) {
|
||||||
|
const phone = e.currentTarget.dataset.phone
|
||||||
|
const role = e.currentTarget.dataset.role
|
||||||
|
const roleName = e.currentTarget.dataset.roleName
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除角色',
|
||||||
|
content: `${phone} · ${roleName}`,
|
||||||
|
success: async res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await api.deletePerson(phone, role)
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removePerson(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const person = this.data.rows[index]
|
||||||
|
if (!person) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentUser = api.getCurrentUser()
|
||||||
|
if (currentUser && currentUser.phone === person.phone) {
|
||||||
|
wx.showToast({ title: '不能删除当前登录账号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除人员',
|
||||||
|
content: `确定删除 ${person.name}(${person.phone})?历史报工记录会保留。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
confirmColor: '#d92d20',
|
||||||
|
success: async res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await api.deletePerson(person.phone)
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearForm() {
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm },
|
||||||
|
attendancePoints: this.checkedPointMap([]),
|
||||||
|
roleOptions: this.roleOptionsForUser(),
|
||||||
|
roleIndex: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
chooseFile() {
|
||||||
|
wx.chooseMessageFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['xlsx', 'xls'],
|
||||||
|
success: async res => {
|
||||||
|
const file = res.tempFiles[0]
|
||||||
|
this.setData({ importFileName: file.name })
|
||||||
|
wx.showLoading({ title: '导入中' })
|
||||||
|
try {
|
||||||
|
const result = await api.importPeople(file.path)
|
||||||
|
wx.showToast({ title: `导入${result.imported}条`, icon: 'success' })
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async exportFile() {
|
||||||
|
wx.showLoading({ title: '导出中' })
|
||||||
|
try {
|
||||||
|
const filePath = await api.exportPeople()
|
||||||
|
wx.openDocument({
|
||||||
|
filePath,
|
||||||
|
fileType: 'xlsx',
|
||||||
|
showMenu: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/managePeople/managePeople.json
Normal file
3
pages/managePeople/managePeople.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "人员清单"
|
||||||
|
}
|
||||||
117
pages/managePeople/managePeople.wxml
Normal file
117
pages/managePeople/managePeople.wxml
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">人员清单</text>
|
||||||
|
<text class="subtitle">电话号唯一,角色决定可用功能</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!readonly}}" class="card">
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="chooseFile">导入人员清单</button>
|
||||||
|
<button class="btn primary full" bindtap="exportFile">导出人员清单</button>
|
||||||
|
</view>
|
||||||
|
<button class="btn primary full add-btn" bindtap="openCreate">新增人员</button>
|
||||||
|
<text wx:if="{{importFileName}}" class="subtitle">已选择:{{importFileName}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<input class="input search" placeholder="搜索姓名、电话、角色" value="{{keyword}}" bindinput="onKeywordInput" />
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无人员</view>
|
||||||
|
<view wx:for="{{rows}}" wx:key="phone" wx:for-index="index" class="card person-card {{item.temporaryExpired ? 'person-expired' : ''}}">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.name}}</text>
|
||||||
|
<text class="subtitle">{{item.phone}}</text>
|
||||||
|
<text wx:if="{{item.role !== 'manager'}}" class="subtitle">考勤点 {{item.attendancePointsText || '-'}}</text>
|
||||||
|
<text wx:if="{{item.temporaryStatusText}}" class="temporary-status {{item.temporaryExpired ? 'expired' : ''}}">
|
||||||
|
{{item.temporaryStatusText}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{readonly}}" class="role-row">
|
||||||
|
<view
|
||||||
|
wx:for="{{item.rolesDisplay}}"
|
||||||
|
wx:key="role"
|
||||||
|
wx:for-item="roleItem"
|
||||||
|
class="role-chip readonly-chip"
|
||||||
|
>
|
||||||
|
{{roleItem.roleName}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="role-row">
|
||||||
|
<button
|
||||||
|
wx:for="{{item.rolesDisplay}}"
|
||||||
|
wx:key="role"
|
||||||
|
wx:for-item="roleItem"
|
||||||
|
class="role-chip"
|
||||||
|
data-phone="{{item.phone}}"
|
||||||
|
data-role="{{roleItem.role}}"
|
||||||
|
data-role-name="{{roleItem.roleName}}"
|
||||||
|
bindtap="removeRole"
|
||||||
|
>
|
||||||
|
{{roleItem.roleName}} ×
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!readonly}}" class="button-row">
|
||||||
|
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
|
||||||
|
<button wx:if="{{currentUser.role === 'manager'}}" class="btn secondary full" data-index="{{index}}" bindtap="addRole">添加角色</button>
|
||||||
|
<button class="btn danger full" data-index="{{index}}" bindtap="removePerson">删除人员</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
|
||||||
|
<view class="modal-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{formTitle}}</text>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">电话号</text>
|
||||||
|
<input class="input" type="number" value="{{form.phone}}" disabled="{{!!form.originalPhone}}" data-field="phone" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">姓名</text>
|
||||||
|
<input class="input" value="{{form.name}}" data-field="name" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">角色</text>
|
||||||
|
<picker mode="selector" range="{{roleOptions}}" range-key="label" value="{{roleIndex}}" bindchange="onRoleChange">
|
||||||
|
<view class="picker">{{roleOptions[roleIndex].label}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{form.role !== 'manager'}}" class="form-item">
|
||||||
|
<text class="label">考勤点</text>
|
||||||
|
<checkbox-group class="checkbox-grid" bindchange="onPointCheckboxChange">
|
||||||
|
<label
|
||||||
|
wx:for="{{attendancePoints}}"
|
||||||
|
wx:key="name"
|
||||||
|
class="checkbox-chip {{item.checked ? 'checked' : ''}}"
|
||||||
|
>
|
||||||
|
<checkbox value="{{item.name}}" checked="{{item.checked}}" color="#1463ff"></checkbox>
|
||||||
|
<text>{{item.name}}</text>
|
||||||
|
</label>
|
||||||
|
</checkbox-group>
|
||||||
|
</view>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="closeForm">关闭</button>
|
||||||
|
<button class="btn primary full" bindtap="save">保存人员</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
157
pages/managePeople/managePeople.wxss
Normal file
157
pages/managePeople/managePeople.wxss
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
.search {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip {
|
||||||
|
min-width: 128rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(144, 178, 235, 0.62),
|
||||||
|
0 10rpx 16rpx rgba(20, 86, 194, 0.09),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-expired {
|
||||||
|
background: linear-gradient(180deg, #f2f4f7 0%, #e4e7ec 100%);
|
||||||
|
border-color: #98a2b3;
|
||||||
|
filter: grayscale(0.8);
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-expired .value,
|
||||||
|
.person-expired .subtitle {
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card .button-row .btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-status {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
padding: 6rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e7f7ef;
|
||||||
|
color: #087443;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temporary-status.expired {
|
||||||
|
background: #eef0f3;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-height: 72rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 2rpx solid #cbd6e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);
|
||||||
|
color: #162232;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(166, 181, 203, 0.42),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-chip.checked {
|
||||||
|
border-color: #7aa7f5;
|
||||||
|
background: linear-gradient(180deg, #eef5ff 0%, #ddeaff 100%);
|
||||||
|
color: #1456c2;
|
||||||
|
}
|
||||||
325
pages/manageProducts/manageProducts.js
Normal file
325
pages/manageProducts/manageProducts.js
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const emptyForm = {
|
||||||
|
attendancePointName: '',
|
||||||
|
projectNo: '',
|
||||||
|
profileNo: '',
|
||||||
|
productName: '',
|
||||||
|
materialCode: '',
|
||||||
|
materialName: '',
|
||||||
|
supplier: '',
|
||||||
|
productNetWeightKg: '',
|
||||||
|
productGrossWeightKg: '',
|
||||||
|
allowedScrapRate: '',
|
||||||
|
wastePriceYuanPerKg: '',
|
||||||
|
process: '',
|
||||||
|
stampingMethod: '',
|
||||||
|
operatorCount: 1,
|
||||||
|
processUnitPriceYuan: '',
|
||||||
|
standardBeat: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
readonly: false,
|
||||||
|
keyword: '',
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
scrollTop: 0,
|
||||||
|
form: { ...emptyForm },
|
||||||
|
formVisible: false,
|
||||||
|
formTitle: '新增产品',
|
||||||
|
attendancePoints: [],
|
||||||
|
attendancePointLabels: [],
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
stampingMethodSuggestions: ['清洗', '连续模'],
|
||||||
|
stampingSuggestionVisible: false,
|
||||||
|
importFileName: '',
|
||||||
|
refreshing: false,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.setData({
|
||||||
|
readonly: options.readonly === '1',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
await this.loadAttendancePoints()
|
||||||
|
await this.load()
|
||||||
|
},
|
||||||
|
async loadAttendancePoints() {
|
||||||
|
try {
|
||||||
|
const points = await api.listAccessibleAttendancePoints()
|
||||||
|
const attendancePoints = points || []
|
||||||
|
this.setData({
|
||||||
|
attendancePoints,
|
||||||
|
attendancePointLabels: attendancePoints.map(item => item.name),
|
||||||
|
})
|
||||||
|
if (!this.data.form.attendancePointName && attendancePoints.length) {
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePointName': attendancePoints[0].name,
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
try {
|
||||||
|
const result = await api.listProducts({
|
||||||
|
keyword: this.data.keyword,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
rows: (result.rows || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
processDisplay: item.processDisplay || api.processDisplayName(item.process),
|
||||||
|
})),
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
scrollTop: 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.setData({ keyword: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
let value = e.detail.value
|
||||||
|
if (field === 'process') {
|
||||||
|
value = this.normalizeProcessInput(value)
|
||||||
|
}
|
||||||
|
if (field === 'stampingMethod') {
|
||||||
|
this.setData({
|
||||||
|
stampingSuggestionVisible: false,
|
||||||
|
[`form.${field}`]: value,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
[`form.${field}`]: value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onStampingFocus() {
|
||||||
|
this.setData({ stampingSuggestionVisible: true })
|
||||||
|
},
|
||||||
|
chooseStampingSuggestion(e) {
|
||||||
|
const value = e.currentTarget.dataset.value
|
||||||
|
this.setData({
|
||||||
|
'form.stampingMethod': value,
|
||||||
|
stampingSuggestionVisible: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hideStampingSuggestion() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setData({ stampingSuggestionVisible: false })
|
||||||
|
}, 150)
|
||||||
|
},
|
||||||
|
normalizeProcessInput(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (text.includes('杂活')) {
|
||||||
|
return '杂活'
|
||||||
|
}
|
||||||
|
return text.replace(/\D/g, '')
|
||||||
|
},
|
||||||
|
validateProcess(form) {
|
||||||
|
const process = String(form.process || '').trim()
|
||||||
|
if (!process) {
|
||||||
|
wx.showToast({ title: '请输入工序数字', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (process === '杂活') {
|
||||||
|
if (String(form.stampingMethod || '').trim() !== '处理杂活') {
|
||||||
|
wx.showToast({ title: '只有处理杂活的工序可以填写杂活', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(process) || Number(process) <= 0) {
|
||||||
|
wx.showToast({ title: '工序只能填写正整数', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (String(form.stampingMethod || '').trim() === '处理杂活') {
|
||||||
|
wx.showToast({ title: '处理杂活的工序必须填写杂活', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
onAttendancePointChange(e) {
|
||||||
|
const attendancePointIndex = Number(e.detail.value)
|
||||||
|
const point = this.data.attendancePoints[attendancePointIndex]
|
||||||
|
this.setData({
|
||||||
|
attendancePointIndex,
|
||||||
|
'form.attendancePointName': point ? point.name : '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
openCreate() {
|
||||||
|
const firstPoint = this.data.attendancePoints[0]
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
formTitle: '新增产品',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeForm() {
|
||||||
|
this.setData({
|
||||||
|
formVisible: false,
|
||||||
|
form: { ...emptyForm, attendancePointName: this.data.attendancePoints[0] ? this.data.attendancePoints[0].name : '' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
try {
|
||||||
|
const form = { ...this.data.form }
|
||||||
|
form.stampingMethod = String(form.stampingMethod || '').trim()
|
||||||
|
if (!form.attendancePointName) {
|
||||||
|
wx.showToast({ title: '请选择考勤点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.validateProcess(form)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['清洗', '处理杂活'].includes(String(form.stampingMethod || '').trim())) {
|
||||||
|
form.standardBeat = 0
|
||||||
|
}
|
||||||
|
await api.saveProduct(form)
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({ form: { ...emptyForm }, formVisible: false })
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
edit(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const product = this.data.rows[index]
|
||||||
|
const attendancePointIndex = Math.max(0, this.data.attendancePoints.findIndex(item => item.name === product.attendancePointName))
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
...product,
|
||||||
|
originalAttendancePointName: product.attendancePointName,
|
||||||
|
originalProjectNo: product.projectNo,
|
||||||
|
originalProductName: product.productName,
|
||||||
|
originalDeviceNo: product.deviceNo || '',
|
||||||
|
originalProcess: product.rawProcess || product.process || '',
|
||||||
|
},
|
||||||
|
attendancePointIndex,
|
||||||
|
formTitle: '编辑产品',
|
||||||
|
formVisible: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
remove(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const product = this.data.rows[index]
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除产品',
|
||||||
|
content: product.productName,
|
||||||
|
success: async res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await api.deleteProduct(product)
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
clearForm() {
|
||||||
|
const firstPoint = this.data.attendancePoints[0]
|
||||||
|
this.setData({
|
||||||
|
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
chooseFile() {
|
||||||
|
wx.chooseMessageFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['xlsx', 'xls'],
|
||||||
|
success: async res => {
|
||||||
|
const file = res.tempFiles[0]
|
||||||
|
this.setData({ importFileName: file.name })
|
||||||
|
wx.showLoading({ title: '导入中' })
|
||||||
|
try {
|
||||||
|
const result = await api.importProducts(file.path)
|
||||||
|
wx.showToast({ title: `导入${result.imported}条`, icon: 'success' })
|
||||||
|
await this.load(1)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '导入失败',
|
||||||
|
content: error.message,
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async exportFile() {
|
||||||
|
wx.showLoading({ title: '导出中' })
|
||||||
|
try {
|
||||||
|
const filePath = await api.exportProducts()
|
||||||
|
wx.openDocument({
|
||||||
|
filePath,
|
||||||
|
fileType: 'xlsx',
|
||||||
|
showMenu: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.setData({ scrollTop: 0 })
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.setData({ scrollTop: 0 })
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ scrollTop: 0 })
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/manageProducts/manageProducts.json
Normal file
3
pages/manageProducts/manageProducts.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "产品清单"
|
||||||
|
}
|
||||||
180
pages/manageProducts/manageProducts.wxml
Normal file
180
pages/manageProducts/manageProducts.wxml
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
scroll-top="{{scrollTop}}"
|
||||||
|
scroll-with-animation
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">产品清单</text>
|
||||||
|
<text class="subtitle">项目号、产品名称、工序共同维护产品信息</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!readonly}}" class="card">
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="chooseFile">导入产品清单</button>
|
||||||
|
<button class="btn primary full" bindtap="exportFile">导出产品清单</button>
|
||||||
|
</view>
|
||||||
|
<button class="btn primary full add-btn" bindtap="openCreate">新增产品</button>
|
||||||
|
<text wx:if="{{importFileName}}" class="subtitle">已选择:{{importFileName}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<input class="input search" placeholder="搜索考勤点、产品、物料、供应商、工序、冲压方式" value="{{keyword}}" bindinput="onKeywordInput" />
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无产品</view>
|
||||||
|
<view wx:for="{{rows}}" wx:key="uniqueKey" wx:for-index="index" class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.productName}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.projectNo}} · {{item.materialCode}}</text>
|
||||||
|
<text class="subtitle">物料名称 {{item.materialName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary">{{item.processDisplay || item.process || '工序'}}</text>
|
||||||
|
<text wx:if="{{item.isCleaning}}" class="cleaning-stamp">清洗</text>
|
||||||
|
<text wx:if="{{item.isMisc}}" class="misc-stamp">杂活</text>
|
||||||
|
<text wx:if="{{item.isMultiPerson}}" class="multi-person-stamp {{item.isCleaning || item.isMisc ? 'lower-product-stamp' : ''}}">多人协作</text>
|
||||||
|
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-stamp {{item.isMultiPerson ? 'lower-product-stamp' : ''}}">连续模</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view wx:if="{{!item.isCleaning && !item.isMisc}}" class="row">
|
||||||
|
<text class="muted">标准节拍 {{item.standardBeat}} 秒</text>
|
||||||
|
<text class="muted">工序单价 {{item.processUnitPriceYuan || 0}} 元/件</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!readonly}}" class="button-row">
|
||||||
|
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
|
||||||
|
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{formVisible}}" class="modal-mask product-modal-mask" catchtap="closeForm">
|
||||||
|
<view class="modal-panel product-modal-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{formTitle}}</text>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
|
||||||
|
</view>
|
||||||
|
<view class="form-grid product-form-grid">
|
||||||
|
<view class="form-item full-span">
|
||||||
|
<text class="label">考勤点</text>
|
||||||
|
<picker
|
||||||
|
wx:if="{{attendancePoints.length}}"
|
||||||
|
range="{{attendancePointLabels}}"
|
||||||
|
value="{{attendancePointIndex}}"
|
||||||
|
bindchange="onAttendancePointChange"
|
||||||
|
>
|
||||||
|
<view class="picker">{{form.attendancePointName || '请选择考勤点'}}</view>
|
||||||
|
</picker>
|
||||||
|
<view wx:else class="readonly">暂无可选考勤点</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">项目号</text>
|
||||||
|
<input class="input" value="{{form.projectNo}}" data-field="projectNo" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">型材号</text>
|
||||||
|
<input class="input" value="{{form.profileNo}}" data-field="profileNo" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item full-span">
|
||||||
|
<text class="label">产品名称</text>
|
||||||
|
<input class="input" value="{{form.productName}}" data-field="productName" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">物料编码</text>
|
||||||
|
<input class="input" value="{{form.materialCode}}" data-field="materialCode" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item full-span">
|
||||||
|
<text class="label">物料名称</text>
|
||||||
|
<input class="input" value="{{form.materialName}}" data-field="materialName" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">供应商(非必填)</text>
|
||||||
|
<input class="input" value="{{form.supplier}}" data-field="supplier" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">工序</text>
|
||||||
|
<view class="process-input-wrap">
|
||||||
|
<input
|
||||||
|
class="input process-input"
|
||||||
|
type="text"
|
||||||
|
value="{{form.process}}"
|
||||||
|
placeholder="请输入数字"
|
||||||
|
data-field="process"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
<text wx:if="{{form.process !== '杂活'}}" class="process-suffix">序</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item stamping-field">
|
||||||
|
<text class="label">冲压方式</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
value="{{form.stampingMethod}}"
|
||||||
|
placeholder="可输入,或选择清洗/连续模"
|
||||||
|
data-field="stampingMethod"
|
||||||
|
bindfocus="onStampingFocus"
|
||||||
|
bindblur="hideStampingSuggestion"
|
||||||
|
bindinput="onInput"
|
||||||
|
/>
|
||||||
|
<view wx:if="{{stampingSuggestionVisible}}" class="stamping-suggestion-panel">
|
||||||
|
<view
|
||||||
|
wx:for="{{stampingMethodSuggestions}}"
|
||||||
|
wx:key="*this"
|
||||||
|
class="stamping-suggestion-option"
|
||||||
|
data-value="{{item}}"
|
||||||
|
catchtap="chooseStampingSuggestion"
|
||||||
|
>{{item}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">操作人数</text>
|
||||||
|
<input class="input number-input" type="number" value="{{form.operatorCount}}" data-field="operatorCount" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">工序单价(元/件)</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.processUnitPriceYuan}}" data-field="processUnitPriceYuan" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{form.stampingMethod !== '清洗' && form.stampingMethod !== '处理杂活'}}" class="form-item full-span">
|
||||||
|
<text class="label">标准节拍</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.standardBeat}}" data-field="standardBeat" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view wx:else class="form-item full-span">
|
||||||
|
<text class="label">{{form.stampingMethod === '处理杂活' ? '处理杂活' : '清洗方式'}}</text>
|
||||||
|
<view class="readonly">标准节拍不需要填写</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">产品净重(kg)(非必填)</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.productNetWeightKg}}" data-field="productNetWeightKg" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">产品毛重(kg)(非必填)</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.productGrossWeightKg}}" data-field="productGrossWeightKg" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">允许报废率(非必填)</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.allowedScrapRate}}" data-field="allowedScrapRate" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">废料单价(元/kg)(非必填)</text>
|
||||||
|
<input class="input number-input" type="digit" value="{{form.wastePriceYuanPerKg}}" data-field="wastePriceYuanPerKg" bindinput="onInput" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="button-row product-modal-actions">
|
||||||
|
<button class="btn secondary full" bindtap="closeForm">关闭</button>
|
||||||
|
<button class="btn primary full" bindtap="save">保存产品</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
268
pages/manageProducts/manageProducts.wxss
Normal file
268
pages/manageProducts/manageProducts.wxss
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-input-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44rpx;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-input-wrap .process-input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-suffix {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 26rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 3rpx solid rgba(18, 163, 117, 0.68);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(18, 126, 89, 0.82);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
background: rgba(230, 255, 246, 0.72);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(18, 126, 89, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 26rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 3rpx solid rgba(217, 119, 6, 0.68);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.86);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
background: rgba(255, 247, 232, 0.78);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(154, 74, 5, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-person-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 26rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 3rpx solid rgba(217, 119, 6, 0.68);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.86);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
background: rgba(255, 247, 232, 0.78);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(154, 74, 5, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous-die-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
right: 26rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 3rpx solid rgba(217, 119, 6, 0.68);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.86);
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
transform: rotate(-16deg);
|
||||||
|
background: rgba(255, 247, 232, 0.78);
|
||||||
|
box-shadow:
|
||||||
|
0 4rpx 0 rgba(154, 74, 5, 0.18),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lower-product-stamp {
|
||||||
|
top: 76rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamping-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamping-suggestion-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 82rpx;
|
||||||
|
z-index: 20;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid #d9e2f2;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12rpx 32rpx rgba(16, 35, 72, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamping-suggestion-option {
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
border-bottom: 1rpx solid #eef2f7;
|
||||||
|
color: #1f2a44;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stamping-suggestion-option:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-mask {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 18rpx 18rpx calc(env(safe-area-inset-bottom) + 18rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-panel {
|
||||||
|
max-height: 94vh;
|
||||||
|
padding: 18rpx 20rpx 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-panel .modal-head {
|
||||||
|
min-height: 54rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-panel .modal-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-panel .modal-close {
|
||||||
|
height: 50rpx;
|
||||||
|
line-height: 50rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8rpx 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form-grid .full-span {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form-grid .form-item {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form-grid .label {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-form-grid .input {
|
||||||
|
min-height: 54rpx;
|
||||||
|
height: 54rpx;
|
||||||
|
line-height: 54rpx;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-actions {
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-modal-actions .btn {
|
||||||
|
height: 58rpx;
|
||||||
|
line-height: 58rpx;
|
||||||
|
border-radius: 11rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
341
pages/reconciliationLedger/reconciliationLedger.js
Normal file
341
pages/reconciliationLedger/reconciliationLedger.js
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const EXPANDED_MONTH_WIDTH = 390
|
||||||
|
const COLLAPSED_MONTH_WIDTH = 84
|
||||||
|
const END_SPACER_WIDTH = 120
|
||||||
|
const ALL_ATTENDANCE_POINTS = '__ALL_ATTENDANCE_POINTS__'
|
||||||
|
|
||||||
|
const monthLabel = month => `${month}月`
|
||||||
|
|
||||||
|
const formatQty = value => {
|
||||||
|
const number = Number(value || 0)
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return '0'
|
||||||
|
}
|
||||||
|
return Number.isInteger(number) ? String(number) : String(Math.round(number * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeQty = value => {
|
||||||
|
const text = String(value || '').replace(/[^\d.]/g, '')
|
||||||
|
const parts = text.split('.')
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
years: [],
|
||||||
|
yearLabels: [],
|
||||||
|
yearIndex: 0,
|
||||||
|
selectedYear: '',
|
||||||
|
currentYear: '',
|
||||||
|
currentMonth: '',
|
||||||
|
months: [],
|
||||||
|
rawRows: [],
|
||||||
|
rows: [],
|
||||||
|
pointOptions: [{ label: '全部考勤点', value: ALL_ATTENDANCE_POINTS }],
|
||||||
|
pointLabels: ['全部考勤点'],
|
||||||
|
pointIndex: 0,
|
||||||
|
selectedPointValue: ALL_ATTENDANCE_POINTS,
|
||||||
|
selectedPointLabel: '全部考勤点',
|
||||||
|
monthTableWidth: 0,
|
||||||
|
monthEndSpacerWidth: 0,
|
||||||
|
totalReported: 0,
|
||||||
|
totalReconciled: 0,
|
||||||
|
totalReturn: 0,
|
||||||
|
monthScrollLeft: 0,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
savingKey: '',
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.loadYears()
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.loadLedger()
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadYears() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const result = await api.listReconciliationYears()
|
||||||
|
const years = result.years && result.years.length ? result.years : [result.currentYear]
|
||||||
|
const selectedYear = result.currentYear || years[years.length - 1]
|
||||||
|
const yearIndex = Math.max(0, years.findIndex(year => Number(year) === Number(selectedYear)))
|
||||||
|
this.setData({
|
||||||
|
years,
|
||||||
|
yearLabels: years.map(year => `${year}年`),
|
||||||
|
currentYear: result.currentYear,
|
||||||
|
selectedYear,
|
||||||
|
yearIndex,
|
||||||
|
})
|
||||||
|
await this.loadLedger()
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载年份失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildMonths(year, currentYear, currentMonth) {
|
||||||
|
const visibleCount = Number(year) === Number(currentYear) ? Number(currentMonth || 1) : 12
|
||||||
|
return Array.from({ length: visibleCount }, (_, index) => {
|
||||||
|
const month = index + 1
|
||||||
|
const expanded = Number(year) === Number(currentYear)
|
||||||
|
? month === Number(currentMonth)
|
||||||
|
: true
|
||||||
|
return {
|
||||||
|
month,
|
||||||
|
label: monthLabel(month),
|
||||||
|
expanded,
|
||||||
|
width: expanded ? EXPANDED_MONTH_WIDTH : COLLAPSED_MONTH_WIDTH,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
buildRows(rawRows, months) {
|
||||||
|
return (rawRows || []).map(row => ({
|
||||||
|
attendancePointName: row.attendancePointName || '',
|
||||||
|
productName: row.productName,
|
||||||
|
uniqueKey: row.uniqueKey || `${row.attendancePointName || ''}||${row.productName}`,
|
||||||
|
months: months.map(monthMeta => {
|
||||||
|
const source = (row.months || []).find(item => Number(item.month) === Number(monthMeta.month)) || {}
|
||||||
|
const reported = Number(source.reportedGoodQty || 0)
|
||||||
|
const reconciled = Number(source.reconciledGoodQty || 0)
|
||||||
|
const returnQty = Number(source.returnQty || 0)
|
||||||
|
return {
|
||||||
|
...monthMeta,
|
||||||
|
reportedGoodQty: reported,
|
||||||
|
reconciledGoodQty: reconciled,
|
||||||
|
returnQty,
|
||||||
|
reportedGoodQtyText: formatQty(reported),
|
||||||
|
reconciledGoodQtyInput: formatQty(reconciled),
|
||||||
|
returnQtyInput: formatQty(returnQty),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
buildPointOptions(rawRows) {
|
||||||
|
const seen = {}
|
||||||
|
const options = [{ label: '全部考勤点', value: ALL_ATTENDANCE_POINTS }]
|
||||||
|
;(rawRows || []).forEach(row => {
|
||||||
|
const value = row.attendancePointName || ''
|
||||||
|
if (seen[value]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
options.push({
|
||||||
|
label: value || '未设置考勤点',
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return options
|
||||||
|
},
|
||||||
|
getPointFilterState(rawRows) {
|
||||||
|
const pointOptions = this.buildPointOptions(rawRows)
|
||||||
|
const currentValue = this.data.selectedPointValue || ALL_ATTENDANCE_POINTS
|
||||||
|
const matchedIndex = pointOptions.findIndex(item => item.value === currentValue)
|
||||||
|
const pointIndex = matchedIndex >= 0 ? matchedIndex : 0
|
||||||
|
return {
|
||||||
|
pointOptions,
|
||||||
|
pointLabels: pointOptions.map(item => item.label),
|
||||||
|
pointIndex,
|
||||||
|
selectedPointValue: pointOptions[pointIndex].value,
|
||||||
|
selectedPointLabel: pointOptions[pointIndex].label,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getFilteredRows(rawRows) {
|
||||||
|
const selectedPointValue = this.data.selectedPointValue || ALL_ATTENDANCE_POINTS
|
||||||
|
if (selectedPointValue === ALL_ATTENDANCE_POINTS) {
|
||||||
|
return rawRows || []
|
||||||
|
}
|
||||||
|
return (rawRows || []).filter(row => (row.attendancePointName || '') === selectedPointValue)
|
||||||
|
},
|
||||||
|
rpxToPx(value) {
|
||||||
|
let windowWidth = 375
|
||||||
|
try {
|
||||||
|
const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
|
||||||
|
windowWidth = Number(info && info.windowWidth) || windowWidth
|
||||||
|
} catch (error) {
|
||||||
|
windowWidth = 375
|
||||||
|
}
|
||||||
|
return Math.round(Number(value || 0) * windowWidth / 750)
|
||||||
|
},
|
||||||
|
getCurrentMonthScrollLeft(months, year, currentYear, currentMonth) {
|
||||||
|
if (Number(year) !== Number(currentYear)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const targetMonth = Number(currentMonth || 1)
|
||||||
|
const offsetRpx = (months || []).reduce((sum, item) => {
|
||||||
|
if (Number(item.month) >= targetMonth) {
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
return sum + Number(item.width || 0)
|
||||||
|
}, 0)
|
||||||
|
return this.rpxToPx(offsetRpx)
|
||||||
|
},
|
||||||
|
alignToCurrentMonth(months, year, currentYear, currentMonth) {
|
||||||
|
const scrollLeft = this.getCurrentMonthScrollLeft(months, year, currentYear, currentMonth)
|
||||||
|
if (!scrollLeft) {
|
||||||
|
this.setData({ monthScrollLeft: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentScrollLeft = Number(this.data.monthScrollLeft || 0)
|
||||||
|
this.setData({ monthScrollLeft: currentScrollLeft === scrollLeft ? scrollLeft - 1 : scrollLeft })
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setData({ monthScrollLeft: scrollLeft })
|
||||||
|
}, 80)
|
||||||
|
},
|
||||||
|
applyMonthView(rawRows, months) {
|
||||||
|
const rows = this.buildRows(this.getFilteredRows(rawRows), months)
|
||||||
|
const monthEndSpacerWidth = Number(this.data.selectedYear) === Number(this.data.currentYear)
|
||||||
|
? END_SPACER_WIDTH
|
||||||
|
: 0
|
||||||
|
const monthTableWidth = months.reduce((sum, item) => sum + Number(item.width || 0), 0) + monthEndSpacerWidth
|
||||||
|
const totals = rows.reduce((result, row) => {
|
||||||
|
row.months.forEach(month => {
|
||||||
|
result.reported += Number(month.reportedGoodQty || 0)
|
||||||
|
result.reconciled += Number(month.reconciledGoodQty || 0)
|
||||||
|
result.returnQty += Number(month.returnQty || 0)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, { reported: 0, reconciled: 0, returnQty: 0 })
|
||||||
|
this.setData({
|
||||||
|
rows,
|
||||||
|
months,
|
||||||
|
monthTableWidth,
|
||||||
|
monthEndSpacerWidth,
|
||||||
|
totalReported: formatQty(totals.reported),
|
||||||
|
totalReconciled: formatQty(totals.reconciled),
|
||||||
|
totalReturn: formatQty(totals.returnQty),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async loadLedger() {
|
||||||
|
if (!this.data.selectedYear) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const result = await api.listReconciliationLedger(this.data.selectedYear)
|
||||||
|
const months = this.buildMonths(result.year, result.currentYear, result.currentMonth)
|
||||||
|
this.setData({
|
||||||
|
selectedYear: result.year,
|
||||||
|
currentYear: result.currentYear,
|
||||||
|
currentMonth: result.currentMonth,
|
||||||
|
rawRows: result.rows || [],
|
||||||
|
...this.getPointFilterState(result.rows || []),
|
||||||
|
})
|
||||||
|
this.applyMonthView(result.rows || [], months)
|
||||||
|
this.alignToCurrentMonth(months, result.year, result.currentYear, result.currentMonth)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载账本失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onYearChange(e) {
|
||||||
|
const yearIndex = Number(e.detail.value)
|
||||||
|
const selectedYear = this.data.years[yearIndex]
|
||||||
|
this.setData({ yearIndex, selectedYear })
|
||||||
|
this.loadLedger()
|
||||||
|
},
|
||||||
|
onPointChange(e) {
|
||||||
|
const pointIndex = Number(e.detail.value)
|
||||||
|
const selected = this.data.pointOptions[pointIndex] || this.data.pointOptions[0]
|
||||||
|
this.setData({
|
||||||
|
pointIndex,
|
||||||
|
selectedPointValue: selected ? selected.value : ALL_ATTENDANCE_POINTS,
|
||||||
|
selectedPointLabel: selected ? selected.label : '全部考勤点',
|
||||||
|
})
|
||||||
|
this.applyMonthView(this.data.rawRows, this.data.months)
|
||||||
|
},
|
||||||
|
toggleMonth(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const months = this.data.months.map((item, itemIndex) => {
|
||||||
|
if (itemIndex !== index) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
const expanded = !item.expanded
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
expanded,
|
||||||
|
width: expanded ? EXPANDED_MONTH_WIDTH : COLLAPSED_MONTH_WIDTH,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.applyMonthView(this.data.rawRows, months)
|
||||||
|
},
|
||||||
|
onQtyInput(e) {
|
||||||
|
const rowIndex = Number(e.currentTarget.dataset.rowIndex)
|
||||||
|
const monthIndex = Number(e.currentTarget.dataset.monthIndex)
|
||||||
|
const field = e.currentTarget.dataset.field || 'reconciledGoodQty'
|
||||||
|
const value = sanitizeQty(e.detail.value)
|
||||||
|
this.setData({
|
||||||
|
[`rows[${rowIndex}].months[${monthIndex}].${field}Input`]: value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateRawCell(attendancePointName, productName, month, field, value) {
|
||||||
|
const rawRows = this.data.rawRows.map(row => {
|
||||||
|
if (row.productName !== productName || row.attendancePointName !== attendancePointName) {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
months: row.months.map(item => (
|
||||||
|
Number(item.month) === Number(month)
|
||||||
|
? { ...item, [field]: value }
|
||||||
|
: item
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.setData({ rawRows })
|
||||||
|
return rawRows
|
||||||
|
},
|
||||||
|
async saveCell(e) {
|
||||||
|
const rowIndex = Number(e.currentTarget.dataset.rowIndex)
|
||||||
|
const monthIndex = Number(e.currentTarget.dataset.monthIndex)
|
||||||
|
const field = e.currentTarget.dataset.field || 'reconciledGoodQty'
|
||||||
|
const inputField = `${field}Input`
|
||||||
|
const row = this.data.rows[rowIndex]
|
||||||
|
const month = row && row.months ? row.months[monthIndex] : null
|
||||||
|
if (!row || !month) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inputText = sanitizeQty(e.detail.value)
|
||||||
|
const value = Number(inputText || 0)
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
wx.showToast({ title: '只能填写数字', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const roundedValue = Math.round(value * 100) / 100
|
||||||
|
const key = `${row.attendancePointName}-${row.productName}-${month.month}-${field}`
|
||||||
|
if (roundedValue === Number(month[field] || 0)) {
|
||||||
|
this.setData({
|
||||||
|
[`rows[${rowIndex}].months[${monthIndex}].${inputField}`]: formatQty(roundedValue),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ savingKey: key })
|
||||||
|
try {
|
||||||
|
await api.saveReconciliationEntry({
|
||||||
|
year: this.data.selectedYear,
|
||||||
|
month: month.month,
|
||||||
|
attendancePointName: row.attendancePointName,
|
||||||
|
productName: row.productName,
|
||||||
|
[field]: roundedValue,
|
||||||
|
})
|
||||||
|
const rawRows = this.updateRawCell(row.attendancePointName, row.productName, month.month, field, roundedValue)
|
||||||
|
this.applyMonthView(rawRows, this.data.months)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ savingKey: '' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/reconciliationLedger/reconciliationLedger.json
Normal file
3
pages/reconciliationLedger/reconciliationLedger.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "对账小账本"
|
||||||
|
}
|
||||||
152
pages/reconciliationLedger/reconciliationLedger.wxml
Normal file
152
pages/reconciliationLedger/reconciliationLedger.wxml
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">对账小账本</text>
|
||||||
|
<text class="subtitle">按产品名称汇总最后一道工序的已审核成品数量,对账数量和退货数量可手工维护</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card toolbar-card">
|
||||||
|
<view class="toolbar-row">
|
||||||
|
<picker mode="selector" range="{{yearLabels}}" value="{{yearIndex}}" bindchange="onYearChange">
|
||||||
|
<view class="picker year-picker">{{selectedYear}}年</view>
|
||||||
|
</picker>
|
||||||
|
<view class="ledger-summary">
|
||||||
|
<view>
|
||||||
|
<text class="label">已报工合计</text>
|
||||||
|
<text class="value">{{totalReported}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">对账合计</text>
|
||||||
|
<text class="value">{{totalReconciled}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">退货合计</text>
|
||||||
|
<text class="value">{{totalReturn}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="hint-row">
|
||||||
|
<text>当前年份默认定位到本月,向右滑动可查看前面月份,已过月份可点击月份标题展开。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rawRows.length && !loading}}" class="list-empty">暂无产品</view>
|
||||||
|
|
||||||
|
<view wx:if="{{rawRows.length}}" class="ledger-shell">
|
||||||
|
<view class="ledger-fixed">
|
||||||
|
<view class="fixed-head">
|
||||||
|
<text class="fixed-head-title">考勤点 / 产品名称</text>
|
||||||
|
<picker mode="selector" range="{{pointLabels}}" value="{{pointIndex}}" bindchange="onPointChange">
|
||||||
|
<view class="point-filter-picker">
|
||||||
|
<text>{{selectedPointLabel || '全部考勤点'}}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
wx:for="{{rows}}"
|
||||||
|
wx:key="uniqueKey"
|
||||||
|
wx:for-item="row"
|
||||||
|
class="fixed-product data-row"
|
||||||
|
>
|
||||||
|
<text class="point-name">{{row.attendancePointName || '-'}}</text>
|
||||||
|
<text class="product-name">{{row.productName}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!rows.length && !loading}}" class="fixed-product empty-product">
|
||||||
|
<text>当前考勤点暂无产品</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="ledger-x"
|
||||||
|
scroll-x
|
||||||
|
enhanced
|
||||||
|
show-scrollbar
|
||||||
|
scroll-left="{{monthScrollLeft}}"
|
||||||
|
>
|
||||||
|
<view class="ledger-table" style="width: {{monthTableWidth}}rpx;">
|
||||||
|
<view class="ledger-row ledger-head">
|
||||||
|
<view
|
||||||
|
wx:for="{{months}}"
|
||||||
|
wx:key="month"
|
||||||
|
wx:for-item="month"
|
||||||
|
wx:for-index="monthIndex"
|
||||||
|
class="month-block {{month.expanded ? 'expanded' : 'collapsed'}}"
|
||||||
|
style="width: {{month.width}}rpx;"
|
||||||
|
>
|
||||||
|
<view class="month-title" data-index="{{monthIndex}}" bindtap="toggleMonth">
|
||||||
|
<text>{{month.label}}</text>
|
||||||
|
<text class="month-toggle">{{month.expanded ? '收起' : '展开'}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{month.expanded}}" class="month-subhead">
|
||||||
|
<text>已报工成品</text>
|
||||||
|
<text>对账成品</text>
|
||||||
|
<text>退货数量</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{monthEndSpacerWidth}}" class="month-end-spacer" style="width: {{monthEndSpacerWidth}}rpx;"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{rows}}"
|
||||||
|
wx:key="uniqueKey"
|
||||||
|
wx:for-item="row"
|
||||||
|
wx:for-index="rowIndex"
|
||||||
|
class="ledger-row data-row"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
wx:for="{{row.months}}"
|
||||||
|
wx:key="month"
|
||||||
|
wx:for-item="month"
|
||||||
|
wx:for-index="monthIndex"
|
||||||
|
class="month-block row-month {{month.expanded ? 'expanded' : 'collapsed'}}"
|
||||||
|
style="width: {{month.width}}rpx;"
|
||||||
|
>
|
||||||
|
<block wx:if="{{month.expanded}}">
|
||||||
|
<view class="qty-cell reported">
|
||||||
|
<text>{{month.reportedGoodQtyText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="qty-cell editable">
|
||||||
|
<input
|
||||||
|
class="ledger-input"
|
||||||
|
type="digit"
|
||||||
|
value="{{month.reconciledGoodQtyInput}}"
|
||||||
|
data-row-index="{{rowIndex}}"
|
||||||
|
data-month-index="{{monthIndex}}"
|
||||||
|
data-field="reconciledGoodQty"
|
||||||
|
bindinput="onQtyInput"
|
||||||
|
bindblur="saveCell"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="qty-cell editable return">
|
||||||
|
<input
|
||||||
|
class="ledger-input"
|
||||||
|
type="digit"
|
||||||
|
value="{{month.returnQtyInput}}"
|
||||||
|
data-row-index="{{rowIndex}}"
|
||||||
|
data-month-index="{{monthIndex}}"
|
||||||
|
data-field="returnQty"
|
||||||
|
bindinput="onQtyInput"
|
||||||
|
bindblur="saveCell"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
<view wx:else class="collapsed-cell" data-index="{{monthIndex}}" bindtap="toggleMonth">
|
||||||
|
<text>{{month.label}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{monthEndSpacerWidth}}" class="month-end-spacer" style="width: {{monthEndSpacerWidth}}rpx;"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length && !loading}}" class="ledger-row data-row ledger-empty-row">
|
||||||
|
<view class="ledger-empty-cell" style="width: {{monthTableWidth}}rpx;">当前考勤点暂无产品</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
345
pages/reconciliationLedger/reconciliationLedger.wxss
Normal file
345
pages/reconciliationLedger/reconciliationLedger.wxss
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
.toolbar-card {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-picker {
|
||||||
|
min-width: 186rpx;
|
||||||
|
min-height: 74rpx;
|
||||||
|
justify-content: center;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary > view {
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
border: 2rpx solid #c5d2e4;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 rgba(166, 181, 203, 0.64),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-row {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-shell {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
border: 3rpx solid #99aac3;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 12rpx 0 rgba(143, 158, 181, 0.72),
|
||||||
|
0 22rpx 34rpx rgba(22, 34, 50, 0.16),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-fixed {
|
||||||
|
width: 284rpx;
|
||||||
|
flex: 0 0 284rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 8;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 12rpx 0 18rpx rgba(22, 34, 50, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-head,
|
||||||
|
.fixed-product {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-head {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
padding: 12rpx 14rpx;
|
||||||
|
border-right: 2rpx solid #b4c2d6;
|
||||||
|
border-bottom: 2rpx solid #c4d0e1;
|
||||||
|
background: linear-gradient(180deg, #cbd8ea 0%, #b7c7dd 100%);
|
||||||
|
color: #162232;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-head-title {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-filter-picker {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
border: 2rpx solid rgba(20, 86, 194, 0.24);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 21rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.92),
|
||||||
|
0 5rpx 0 rgba(126, 146, 175, 0.36);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-filter-picker text {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-product {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
height: 164rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
border-right: 2rpx solid #c4d0e1;
|
||||||
|
border-bottom: 2rpx solid #d7e0ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-x {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-table {
|
||||||
|
min-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-row {
|
||||||
|
display: flex;
|
||||||
|
height: 164rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-head {
|
||||||
|
height: 150rpx;
|
||||||
|
background: linear-gradient(180deg, #dce8f8 0%, #c8d8ed 100%);
|
||||||
|
color: #162232;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-row:nth-child(odd) {
|
||||||
|
background: #f9fbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-row:nth-child(even) {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-fixed .data-row:nth-child(odd) {
|
||||||
|
background: #f9fbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-fixed .data-row:nth-child(even) {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.28;
|
||||||
|
word-break: break-all;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.32;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-block {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-right: 2rpx solid #c4d0e1;
|
||||||
|
border-bottom: 2rpx solid #d7e0ec;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-end-spacer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 2rpx solid #d7e0ec;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-title {
|
||||||
|
height: 74rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
background: linear-gradient(180deg, #edf4ff 0%, #d8e6f8 100%);
|
||||||
|
color: #162232;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-block.collapsed .month-title {
|
||||||
|
height: 150rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-toggle {
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(20, 86, 194, 0.1);
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-subhead {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
height: 76rpx;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 21rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-subhead text:not(:last-child) {
|
||||||
|
border-right: 2rpx solid #c4d0e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-month {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
min-height: 164rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-month.collapsed {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reported {
|
||||||
|
border-right: 2rpx solid #d7e0ec;
|
||||||
|
background: rgba(235, 244, 255, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable {
|
||||||
|
background: rgba(235, 255, 244, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable:not(:last-child) {
|
||||||
|
border-right: 2rpx solid #d7e0ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable.return {
|
||||||
|
background: rgba(255, 248, 230, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-input {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
border: 2rpx solid #8fb7a0;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #087443;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.95),
|
||||||
|
0 5rpx 0 rgba(130, 174, 151, 0.46);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-cell {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(135deg, rgba(148, 163, 184, 0.12) 0, rgba(148, 163, 184, 0.12) 6rpx, transparent 6rpx, transparent 14rpx),
|
||||||
|
#f2f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-product,
|
||||||
|
.ledger-empty-cell {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-product {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-empty-cell {
|
||||||
|
height: 164rpx;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 2rpx solid #d7e0ec;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
272
pages/records/records.js
Normal file
272
pages/records/records.js
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
const util = require('../../utils/util')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
user: null,
|
||||||
|
mode: 'mine',
|
||||||
|
title: '我的报工记录',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
status: '',
|
||||||
|
showStatusFilter: true,
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
refreshing: false,
|
||||||
|
scrollTop: 0,
|
||||||
|
expandedRecordId: '',
|
||||||
|
detailExpandedAll: false,
|
||||||
|
filterVoided: true,
|
||||||
|
statusOptions: [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '待审核', value: 'pending' },
|
||||||
|
{ label: '已通过', value: 'approved' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
const mode = options.mode || 'mine'
|
||||||
|
const defaultDate = util.today()
|
||||||
|
const startDate = mode === 'pending' ? (options.startDate || '') : (options.startDate || defaultDate)
|
||||||
|
const endDate = mode === 'pending' ? (options.endDate || '') : (options.endDate || defaultDate)
|
||||||
|
const titleMap = {
|
||||||
|
audit: '审核记录',
|
||||||
|
pending: '待审核清单',
|
||||||
|
mine: '我的报工记录',
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
mode,
|
||||||
|
title: titleMap[mode] || '我的报工记录',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status: mode === 'pending' ? 'pending' : (options.status || ''),
|
||||||
|
showStatusFilter: mode !== 'pending',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
toggleDetails() {
|
||||||
|
this.setData({
|
||||||
|
detailExpandedAll: !this.data.detailExpandedAll,
|
||||||
|
expandedRecordId: '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onScroll(e) {
|
||||||
|
this.currentScrollTop = e.detail.scrollTop || 0
|
||||||
|
},
|
||||||
|
queryRecordTop(id) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.createSelectorQuery()
|
||||||
|
.select(`#record-${id}`)
|
||||||
|
.boundingClientRect(rect => {
|
||||||
|
resolve(rect ? rect.top : null)
|
||||||
|
})
|
||||||
|
.exec()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
restoreRecordPosition(id, beforeTop) {
|
||||||
|
if (beforeTop === null || beforeTop === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.nextTick(() => {
|
||||||
|
this.queryRecordTop(id).then(afterTop => {
|
||||||
|
if (afterTop === null || afterTop === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
(this.currentScrollTop || 0) + afterTop - beforeTop,
|
||||||
|
)
|
||||||
|
this.currentScrollTop = nextScrollTop
|
||||||
|
this.setData({ scrollTop: nextScrollTop })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleRecordDetail(e) {
|
||||||
|
const id = String(e.currentTarget.dataset.id || '')
|
||||||
|
const switchingRecord = this.data.detailExpandedAll
|
||||||
|
|| (!!this.data.expandedRecordId && this.data.expandedRecordId !== id)
|
||||||
|
this.queryRecordTop(id).then(beforeTop => {
|
||||||
|
this.setData({
|
||||||
|
detailExpandedAll: false,
|
||||||
|
expandedRecordId: this.data.expandedRecordId === id ? '' : id,
|
||||||
|
}, () => {
|
||||||
|
if (switchingRecord) {
|
||||||
|
this.restoreRecordPosition(id, beforeTop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
const user = api.getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
this.setData({ rows: [], user })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
startDate: this.data.startDate,
|
||||||
|
endDate: this.data.endDate,
|
||||||
|
status: this.data.status,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
includeVoided: !this.data.filterVoided,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.data.mode === 'audit') {
|
||||||
|
filters.reviewerPhone = user.phone
|
||||||
|
} else if (this.data.mode === 'pending') {
|
||||||
|
filters.status = 'pending'
|
||||||
|
} else {
|
||||||
|
filters.employeePhone = user.phone
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listReports(filters)
|
||||||
|
this.setData({
|
||||||
|
user,
|
||||||
|
rows: result.rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
expandedRecordId: '',
|
||||||
|
detailExpandedAll: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStartChange(e) {
|
||||||
|
this.setData({ startDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onEndChange(e) {
|
||||||
|
this.setData({ endDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
shiftRangeDate(field, days) {
|
||||||
|
const otherField = field === 'startDate' ? 'endDate' : 'startDate'
|
||||||
|
const current = this.data[field]
|
||||||
|
const other = this.data[otherField]
|
||||||
|
const base = current || other || util.today()
|
||||||
|
const updates = {
|
||||||
|
[field]: util.addDays(base, days),
|
||||||
|
page: 1,
|
||||||
|
}
|
||||||
|
if (!current && !other) {
|
||||||
|
updates[otherField] = base
|
||||||
|
}
|
||||||
|
this.setData(updates)
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
decreaseStartDate() {
|
||||||
|
this.shiftRangeDate('startDate', -1)
|
||||||
|
},
|
||||||
|
increaseEndDate() {
|
||||||
|
this.shiftRangeDate('endDate', 1)
|
||||||
|
},
|
||||||
|
chooseStatus(e) {
|
||||||
|
this.setData({
|
||||||
|
status: e.currentTarget.dataset.value,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
openPendingReview(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/review/review?reportId=${id}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleFilterVoided() {
|
||||||
|
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onFilterVoidedChange(e) {
|
||||||
|
this.setData({ filterVoided: !!e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
async voidReport(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.showModal({
|
||||||
|
title: '确认作废',
|
||||||
|
content: '作废的记录将在30天后被系统彻底删除,彻底删除前可随时撤销作废,是否确定删除',
|
||||||
|
cancelText: '否',
|
||||||
|
confirmText: '是',
|
||||||
|
confirmColor: '#c5221f',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
await api.voidReport(id)
|
||||||
|
wx.showToast({ title: '已作废', icon: 'success' })
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '作废失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async unvoidReport(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
await api.unvoidReport(id)
|
||||||
|
wx.showToast({ title: '已撤销作废', icon: 'success' })
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '撤销失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openEditReview(e) {
|
||||||
|
if (this.data.mode === 'mine') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/review/review?reportId=${id}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/records/records.json
Normal file
3
pages/records/records.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "报工记录"
|
||||||
|
}
|
||||||
172
pages/records/records.wxml
Normal file
172
pages/records/records.wxml
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
scroll-top="{{scrollTop}}"
|
||||||
|
scroll-anchoring="{{true}}"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindscroll="onScroll"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<view class="records-header-row">
|
||||||
|
<view class="records-header-main">
|
||||||
|
<text class="title">{{title}}</text>
|
||||||
|
<text class="subtitle">支持按日期和状态筛选,点击记录查看明细</text>
|
||||||
|
</view>
|
||||||
|
<view class="top-action-group">
|
||||||
|
<view class="void-filter-switch">
|
||||||
|
<text>过滤作废</text>
|
||||||
|
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary detail-toggle" bindtap="toggleDetails">
|
||||||
|
{{detailExpandedAll ? '收起明细' : '展开明细'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="filter-bar date-filter-bar">
|
||||||
|
<view class="date-filter-cell">
|
||||||
|
<button class="date-step-btn" bindtap="decreaseStartDate">-</button>
|
||||||
|
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
|
||||||
|
<view class="picker">{{startDate || '开始日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="date-filter-cell end">
|
||||||
|
<button class="date-step-btn" bindtap="increaseEndDate">+</button>
|
||||||
|
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
|
||||||
|
<view class="picker">{{endDate || '结束日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{showStatusFilter}}" class="status-row">
|
||||||
|
<button
|
||||||
|
wx:for="{{statusOptions}}"
|
||||||
|
wx:key="value"
|
||||||
|
class="status-btn {{status === item.value ? 'active' : ''}}"
|
||||||
|
data-value="{{item.value}}"
|
||||||
|
bindtap="chooseStatus"
|
||||||
|
>
|
||||||
|
{{item.label}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无记录</view>
|
||||||
|
<view
|
||||||
|
wx:for="{{rows}}"
|
||||||
|
wx:key="id"
|
||||||
|
id="record-{{item.id}}"
|
||||||
|
class="card record-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}} {{expandedRecordId == item.id || detailExpandedAll ? 'expanded' : ''}}"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
bindtap="toggleRecordDetail"
|
||||||
|
>
|
||||||
|
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
|
||||||
|
<text wx:elif="{{item.isModified}}" class="modified-watermark">更改</text>
|
||||||
|
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">{{item.autoSubmitReason || '超时-系统自动提交'}}</text>
|
||||||
|
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">清洗</text>
|
||||||
|
<text wx:if="{{item.hasMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">包含杂活</text>
|
||||||
|
<text wx:if="{{item.hasMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
|
||||||
|
<text wx:if="{{item.hasContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning || item.hasMultiPerson ? 'lower-watermark' : ''}} {{item.hasMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.startAtText}} 至 {{item.endAtText}}</text>
|
||||||
|
<text class="subtitle">{{item.metrics.shiftDistributionText || ''}}</text>
|
||||||
|
<view wx:if="{{item.correctionPairs.length}}" class="correction-list">
|
||||||
|
<view wx:for="{{item.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
|
||||||
|
<text>{{pair.label}} </text>
|
||||||
|
<text class="old-value">{{pair.oldValue}}</text>
|
||||||
|
<text> {{pair.newValue}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pill {{item.status === 'approved' ? 'success' : item.status === 'pending' ? 'warn' : ''}}">{{item.statusName}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{mode !== 'mine'}}" class="record-action-row">
|
||||||
|
<button wx:if="{{mode === 'pending' && !item.isVoided && item.canEdit}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="openPendingReview">进入审核</button>
|
||||||
|
<button wx:if="{{mode === 'audit' && !item.isVoided && item.canEdit}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="openEditReview">更改</button>
|
||||||
|
<button wx:if="{{!item.isVoided && item.canVoid}}" class="btn danger record-action-btn" data-id="{{item.id}}" catchtap="voidReport">作废</button>
|
||||||
|
<button wx:if="{{item.isVoided && item.canUnvoid}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="unvoidReport">撤销作废</button>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{(detailExpandedAll || expandedRecordId == item.id) && item.items.length}}" class="record-detail-list">
|
||||||
|
<text class="detail-section-title">报工明细</text>
|
||||||
|
<view
|
||||||
|
wx:for="{{item.items}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-item="detail"
|
||||||
|
class="record-detail-item {{detail.isMultiPerson || detail.isContinuousDie ? 'marked-detail-item' : ''}} {{detail.isMultiPerson && detail.isContinuousDie ? 'stacked-detail-item' : ''}}"
|
||||||
|
>
|
||||||
|
<text wx:if="{{detail.isMultiPerson}}" class="detail-multi-stamp">多人协作</text>
|
||||||
|
<text wx:if="{{detail.isContinuousDie}}" class="detail-continuous-stamp {{detail.isMultiPerson ? 'lower-detail-stamp' : ''}}">连续模</text>
|
||||||
|
<view class="row detail-head">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="detail-product">{{detail.displayName || detail.productName || '-'}}</text>
|
||||||
|
<text class="detail-meta">考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="detail-meta">项目 {{detail.projectNo || '-'}} · 工序 {{detail.process || '-'}}</text>
|
||||||
|
<text class="detail-meta">冲压方式 {{detail.stampingMethod || '-'}}</text>
|
||||||
|
<text class="detail-meta">材料库存批次号 {{detail.rawMaterialBatchNo || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary device-pill">{{detail.deviceNo || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-qty-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">{{detail.isMisc ? '处理杂活' : (detail.isCleaning ? '清洗数量' : '成品数量')}}</text>
|
||||||
|
<text class="value">{{detail.isMisc ? '不计产量' : detail.goodQty}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
|
||||||
|
<text class="label">不良数量</text>
|
||||||
|
<text class="value">{{detail.defectQty}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{detail.workTimeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.isMisc}}">
|
||||||
|
<text class="label">杂活事项</text>
|
||||||
|
<text class="value">{{detail.remark || '待备注'}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
|
||||||
|
<text class="label">实际节拍</text>
|
||||||
|
<text class="value {{detail.beatCompareClass}}">
|
||||||
|
{{detail.actualBeat}} 秒
|
||||||
|
<text wx:if="{{detail.beatReason}}" class="metric-reason">{{detail.beatReason}}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
|
||||||
|
<text class="label">节拍对比</text>
|
||||||
|
<text class="value {{detail.beatCompareClass}}">{{detail.paceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
|
||||||
|
<text class="label">工作量对比</text>
|
||||||
|
<text class="value {{detail.workloadCompareClass}}">{{detail.workloadText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.isContinuousDie}}">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<text class="value">{{detail.changeoverCount || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.correctionPairs.length}}" class="correction-list detail-corrections">
|
||||||
|
<view wx:for="{{detail.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
|
||||||
|
<text>{{pair.label}} </text>
|
||||||
|
<text class="old-value">{{pair.oldValue}}</text>
|
||||||
|
<text> {{pair.newValue}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
284
pages/records/records.wxss
Normal file
284
pages/records/records.wxss
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
.records-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-header-main {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-toggle {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 150rpx;
|
||||||
|
height: 62rpx;
|
||||||
|
line-height: 62rpx;
|
||||||
|
margin: 4rpx 0 0;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-header-row .top-action-group {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240rpx minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-header-row .void-filter-switch {
|
||||||
|
width: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-header-row .detail-toggle {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 68rpx;
|
||||||
|
line-height: 68rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 6rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #f7faff 0%, #e4ebf5 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.06),
|
||||||
|
0 8rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 12rpx 18rpx rgba(22, 34, 50, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 62rpx;
|
||||||
|
line-height: 62rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-btn.active {
|
||||||
|
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 #0d3f93,
|
||||||
|
0 12rpx 18rpx rgba(20, 99, 255, 0.2),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-color: #aebbd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(18, 163, 117, 0.55);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(8, 116, 67, 0.72);
|
||||||
|
background: rgba(226, 255, 243, 0.82);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card.expanded {
|
||||||
|
border-color: #1463ff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 13rpx 0 rgba(20, 86, 194, 0.48),
|
||||||
|
0 24rpx 36rpx rgba(20, 86, 194, 0.16),
|
||||||
|
inset 0 0 0 1rpx rgba(20, 99, 255, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-action-row,
|
||||||
|
.record-action-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-entry-btn,
|
||||||
|
.record-action-btn {
|
||||||
|
width: 172rpx;
|
||||||
|
height: 62rpx;
|
||||||
|
line-height: 62rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-entry-btn,
|
||||||
|
.record-action-btn.secondary {
|
||||||
|
color: #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-list {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 2rpx solid #bfcadd;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1rpx rgba(255, 255, 255, 0.86),
|
||||||
|
0 8rpx 0 rgba(177, 191, 211, 0.58),
|
||||||
|
0 14rpx 22rpx rgba(22, 34, 50, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 2rpx solid #aebbd0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.82),
|
||||||
|
0 8rpx 0 rgba(176, 190, 209, 0.64),
|
||||||
|
0 14rpx 20rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 0 0 1rpx rgba(174, 187, 208, 0.34),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-multi-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 18rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-continuous-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 18rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-continuous-stamp.lower-detail-stamp {
|
||||||
|
top: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item + .record-detail-item {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item.multi-detail-item,
|
||||||
|
.record-detail-item.marked-detail-item {
|
||||||
|
padding-top: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail-item.stacked-detail-item {
|
||||||
|
padding-top: 96rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-pill {
|
||||||
|
max-width: 260rpx;
|
||||||
|
height: auto;
|
||||||
|
min-height: 44rpx;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.3;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
padding-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-product {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-qty-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx 18rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
518
pages/reportForm/reportForm.js
Normal file
518
pages/reportForm/reportForm.js
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const pad = value => {
|
||||||
|
const text = String(value)
|
||||||
|
return text.length > 1 ? text : `0${text}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDateValue = value => {
|
||||||
|
const text = String(value || '')
|
||||||
|
if (text.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(text)) {
|
||||||
|
return new Date(text)
|
||||||
|
}
|
||||||
|
return new Date(text.replace(/-/g, '/').replace('T', ' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = value => {
|
||||||
|
const date = parseDateValue(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimeValue = value => {
|
||||||
|
const date = parseDateValue(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return [0, 0, 0]
|
||||||
|
}
|
||||||
|
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimeText = value => {
|
||||||
|
const parts = formatTimeValue(value)
|
||||||
|
return `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimePartsText = parts => `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}`
|
||||||
|
|
||||||
|
const buildDateTimeValue = (date, timeValue) => (
|
||||||
|
date ? `${date}T${pad(timeValue[0])}:${pad(timeValue[1])}:${pad(timeValue[2])}` : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeOptions = [
|
||||||
|
Array.from({ length: 24 }, (_, index) => pad(index)),
|
||||||
|
Array.from({ length: 60 }, (_, index) => pad(index)),
|
||||||
|
Array.from({ length: 60 }, (_, index) => pad(index)),
|
||||||
|
]
|
||||||
|
|
||||||
|
const productKey = product => `${String(product.projectNo || '').trim()}||${String(product.productName || '').trim()}||${String(product.process || '').trim()}`
|
||||||
|
|
||||||
|
const blockDisplay = block => block.moldDisplayName || block.moldName || block.deviceNo
|
||||||
|
|
||||||
|
const productToItem = (product, startAt = '', availableOptions = null, equipmentOptions = [], deviceNo = '') => {
|
||||||
|
const selectedDeviceNo = deviceNo || ''
|
||||||
|
const deviceIndex = selectedDeviceNo
|
||||||
|
? Math.max(0, equipmentOptions.findIndex(item => item.deviceNo === selectedDeviceNo))
|
||||||
|
: -1
|
||||||
|
return {
|
||||||
|
productIndex: product ? 0 : -1,
|
||||||
|
attendancePointName: product ? product.attendancePointName : '',
|
||||||
|
deviceIndex,
|
||||||
|
deviceNo: selectedDeviceNo,
|
||||||
|
availableOptions: availableOptions || null,
|
||||||
|
startAt,
|
||||||
|
startAtText: startAt ? `${formatDate(startAt)} ${formatTimeText(startAt)}` : '',
|
||||||
|
projectNo: product ? product.projectNo : '',
|
||||||
|
productName: product ? product.productName : '',
|
||||||
|
materialCode: product ? product.materialCode : '',
|
||||||
|
materialName: product ? product.materialName : '',
|
||||||
|
rawMaterialBatchNo: '',
|
||||||
|
rawMaterialBatchOptions: [],
|
||||||
|
rawMaterialBatchIndex: -1,
|
||||||
|
rawMaterialBatchError: false,
|
||||||
|
process: product ? product.process : '',
|
||||||
|
stampingMethod: product ? product.stampingMethod : '',
|
||||||
|
operatorCount: product ? product.operatorCount : 1,
|
||||||
|
isMultiPerson: product ? !!product.isMultiPerson : false,
|
||||||
|
isMisc: product ? !!product.isMisc : false,
|
||||||
|
standardBeat: product ? product.standardBeat : 0,
|
||||||
|
standardWorkload: product ? product.standardWorkload : 0,
|
||||||
|
goodQty: product && product.isMisc ? 0 : '',
|
||||||
|
defectQty: '',
|
||||||
|
remark: '',
|
||||||
|
deviceError: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORTING_LIMIT_MINUTES = 15
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
sessionId: '',
|
||||||
|
draft: null,
|
||||||
|
remainingText: '',
|
||||||
|
remainingSeconds: 0,
|
||||||
|
reminderShown: false,
|
||||||
|
confirmVisible: false,
|
||||||
|
confirmSummary: null,
|
||||||
|
submitting: false,
|
||||||
|
continuing: false,
|
||||||
|
addStartVisible: false,
|
||||||
|
addStartForm: null,
|
||||||
|
timeOptions,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.setData({ sessionId: options.sessionId || '' })
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
this.clearCountdown()
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const draft = await api.buildReportDraft(this.data.sessionId)
|
||||||
|
this.setData({ draft }, () => {
|
||||||
|
this.showReportReminder()
|
||||||
|
this.startCountdown(draft)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.handleExpiredReport(error)) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleExpiredReport(error) {
|
||||||
|
const message = error && error.message ? error.message : ''
|
||||||
|
if (message.indexOf('超过15分钟') === -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '报工已超时',
|
||||||
|
content: '请重新扫码点击重新报工,下班时间会更新为重新报工时间。',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
wx.navigateBack({ delta: 1 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
showReportReminder() {
|
||||||
|
if (this.data.reminderShown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ reminderShown: true })
|
||||||
|
wx.showModal({
|
||||||
|
title: '填报提醒',
|
||||||
|
content: `报工填报时限为${REPORTING_LIMIT_MINUTES}分钟,请尽快完成填报`,
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
parseTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const text = String(value)
|
||||||
|
if (text.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(text)) {
|
||||||
|
return new Date(text).getTime()
|
||||||
|
}
|
||||||
|
return new Date(text.replace(/-/g, '/').replace('T', ' ')).getTime()
|
||||||
|
},
|
||||||
|
formatRemaining(seconds) {
|
||||||
|
const safeSeconds = Math.max(0, Number(seconds || 0))
|
||||||
|
const minutes = Math.floor(safeSeconds / 60)
|
||||||
|
const second = safeSeconds % 60
|
||||||
|
const minuteText = minutes < 10 ? `0${minutes}` : `${minutes}`
|
||||||
|
const secondText = second < 10 ? `0${second}` : `${second}`
|
||||||
|
return `${minuteText}:${secondText}`
|
||||||
|
},
|
||||||
|
clearCountdown() {
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer)
|
||||||
|
this.countdownTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startCountdown(draft) {
|
||||||
|
this.clearCountdown()
|
||||||
|
const endTime = this.parseTime(draft && draft.endAt)
|
||||||
|
if (!endTime) {
|
||||||
|
this.setData({ remainingText: '', remainingSeconds: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deadline = endTime + REPORTING_LIMIT_MINUTES * 60 * 1000
|
||||||
|
const tick = () => {
|
||||||
|
const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
|
||||||
|
this.setData({
|
||||||
|
remainingSeconds,
|
||||||
|
remainingText: this.formatRemaining(remainingSeconds),
|
||||||
|
})
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
this.clearCountdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
this.countdownTimer = setInterval(tick, 1000)
|
||||||
|
},
|
||||||
|
itemOptions(block, item) {
|
||||||
|
return item.availableOptions || block.options || []
|
||||||
|
},
|
||||||
|
onProductChange(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const productIndex = Number(e.detail.value)
|
||||||
|
const block = this.data.draft.deviceBlocks[blockIndex]
|
||||||
|
const currentItem = block.items[itemIndex] || {}
|
||||||
|
const options = this.itemOptions(block, currentItem)
|
||||||
|
const product = options[productIndex]
|
||||||
|
if (!product) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = productToItem(
|
||||||
|
product,
|
||||||
|
currentItem.startAt || block.startAt || this.data.draft.startAt,
|
||||||
|
currentItem.availableOptions || null,
|
||||||
|
block.equipmentOptions || [],
|
||||||
|
currentItem.deviceNo,
|
||||||
|
)
|
||||||
|
item.productIndex = productIndex
|
||||||
|
item.deviceError = !!currentItem.deviceError && !item.deviceNo
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}]`]: item,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeviceChange(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const deviceIndex = Number(e.detail.value)
|
||||||
|
const equipment = this.data.draft.deviceBlocks[blockIndex].equipmentOptions[deviceIndex]
|
||||||
|
if (!equipment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceIndex`]: deviceIndex,
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceNo`]: equipment.deviceNo,
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceError`]: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onRawMaterialBatchChange(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const batchIndex = Number(e.detail.value)
|
||||||
|
const item = this.data.draft.deviceBlocks[blockIndex].items[itemIndex]
|
||||||
|
const selected = (item.rawMaterialBatchOptions || [])[batchIndex]
|
||||||
|
if (!selected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchIndex`]: batchIndex,
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchNo`]: selected,
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchError`]: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onQtyInput(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
const updates = {
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].${field}`]: e.detail.value,
|
||||||
|
}
|
||||||
|
if (field === 'changeoverCount') {
|
||||||
|
updates[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].changeoverCountError`] = false
|
||||||
|
}
|
||||||
|
this.setData(updates)
|
||||||
|
},
|
||||||
|
addItem(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const block = this.data.draft.deviceBlocks[blockIndex]
|
||||||
|
if (block.options.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedKeys = new Set(
|
||||||
|
(block.items || [])
|
||||||
|
.filter(item => item.productName)
|
||||||
|
.map(item => productKey(item)),
|
||||||
|
)
|
||||||
|
const availableOptions = (block.options || []).filter(option => !usedKeys.has(productKey(option)))
|
||||||
|
if (!availableOptions.length) {
|
||||||
|
wx.showToast({ title: '该模具没有可新增的产品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previousItem = block.items[block.items.length - 1] || {}
|
||||||
|
const defaultStartAt = previousItem.startAt || block.startAt || this.data.draft.startAt
|
||||||
|
this.setData({
|
||||||
|
addStartVisible: true,
|
||||||
|
addStartForm: {
|
||||||
|
blockIndex,
|
||||||
|
availableOptions,
|
||||||
|
previousStartAt: defaultStartAt,
|
||||||
|
date: formatDate(defaultStartAt),
|
||||||
|
timeValue: formatTimeValue(defaultStartAt),
|
||||||
|
timeText: formatTimeText(defaultStartAt),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onAddStartDateChange(e) {
|
||||||
|
this.setData({
|
||||||
|
'addStartForm.date': e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onAddStartTimeChange(e) {
|
||||||
|
const timeValue = e.detail.value.map(value => Number(value))
|
||||||
|
this.setData({
|
||||||
|
'addStartForm.timeValue': timeValue,
|
||||||
|
'addStartForm.timeText': formatTimePartsText(timeValue),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancelAddStart() {
|
||||||
|
this.setData({
|
||||||
|
addStartVisible: false,
|
||||||
|
addStartForm: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
confirmAddStart() {
|
||||||
|
const form = this.data.addStartForm
|
||||||
|
if (!form) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const block = this.data.draft.deviceBlocks[form.blockIndex]
|
||||||
|
const startAt = buildDateTimeValue(form.date, form.timeValue)
|
||||||
|
const startTime = this.parseTime(startAt)
|
||||||
|
const blockStartTime = this.parseTime(block.startAt || this.data.draft.startAt)
|
||||||
|
const blockEndTime = this.parseTime(block.endAt || this.data.draft.endAt)
|
||||||
|
const previousStartTime = this.parseTime(form.previousStartAt)
|
||||||
|
if (!startTime || startTime < blockStartTime || startTime > blockEndTime) {
|
||||||
|
wx.showToast({ title: '开始时间需在本设备工作时间内', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (previousStartTime && startTime < previousStartTime) {
|
||||||
|
wx.showToast({ title: '开始时间不能早于上一产品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = productToItem(
|
||||||
|
form.availableOptions && form.availableOptions[0],
|
||||||
|
startAt,
|
||||||
|
form.availableOptions || [],
|
||||||
|
block.equipmentOptions || [],
|
||||||
|
)
|
||||||
|
const items = block.items.concat(item)
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${form.blockIndex}].items`]: items,
|
||||||
|
addStartVisible: false,
|
||||||
|
addStartForm: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeItem(e) {
|
||||||
|
const blockIndex = e.currentTarget.dataset.blockIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const block = this.data.draft.deviceBlocks[blockIndex]
|
||||||
|
if (block.items.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = block.items.filter((_, index) => index !== itemIndex)
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items`]: items,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
validateDraft(draft) {
|
||||||
|
const blocks = draft.deviceBlocks || []
|
||||||
|
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
|
||||||
|
const block = blocks[blockIndex]
|
||||||
|
const items = block.items || []
|
||||||
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
|
||||||
|
const item = items[itemIndex]
|
||||||
|
if (item.isMisc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ((block.options || []).length > 1 && (!item.productName || item.productIndex < 0)) {
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}缺少产品信息`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!item.deviceNo) {
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceError`]: true,
|
||||||
|
})
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}请选择设备号`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!item.startAt) {
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}请选择开始工作时间`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ((item.rawMaterialBatchOptions || []).length && !item.rawMaterialBatchNo) {
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchError`]: true,
|
||||||
|
})
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}请选择材料库存批次号`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (item.isContinuousDie) {
|
||||||
|
const text = String(item.changeoverCount || '').trim()
|
||||||
|
const value = Number(text)
|
||||||
|
if (!text || !Number.isInteger(value) || value < 0) {
|
||||||
|
this.setData({
|
||||||
|
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].changeoverCountError`]: true,
|
||||||
|
})
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}请填写非负整数换料次数`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goodQty = Number(item.goodQty || 0)
|
||||||
|
const defectQty = Number(item.defectQty || 0)
|
||||||
|
if (!Number.isFinite(goodQty) || goodQty < 0 || !Number.isFinite(defectQty) || defectQty < 0) {
|
||||||
|
wx.showToast({
|
||||||
|
title: `${blockDisplay(block)}产出数量不能为负数`,
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
buildConfirmSummary(draft) {
|
||||||
|
const totalGood = draft.deviceBlocks.reduce((sum, block) => (
|
||||||
|
sum + block.items.reduce((inner, item) => inner + (item.isMisc ? 0 : Number(item.goodQty || 0)), 0)
|
||||||
|
), 0)
|
||||||
|
const totalDefect = draft.deviceBlocks.reduce((sum, block) => (
|
||||||
|
sum + block.items.reduce((inner, item) => inner + (item.isMisc ? 0 : Number(item.defectQty || 0)), 0)
|
||||||
|
), 0)
|
||||||
|
const hasMisc = draft.deviceBlocks.some(block => (block.items || []).some(item => item.isMisc))
|
||||||
|
return {
|
||||||
|
deviceText: draft.deviceBlocks.map(block => blockDisplay(block)).join('、'),
|
||||||
|
totalGood,
|
||||||
|
totalDefect,
|
||||||
|
note: hasMisc ? '包含处理杂活,提交后由管理员补充杂活事项' : '提交后进入管理员审核',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
if (this.data.continuing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const draft = this.data.draft
|
||||||
|
if (!this.validateDraft(draft)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
confirmVisible: true,
|
||||||
|
confirmSummary: this.buildConfirmSummary(draft),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancelConfirm() {
|
||||||
|
if (this.data.submitting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ confirmVisible: false })
|
||||||
|
},
|
||||||
|
async confirmSubmit() {
|
||||||
|
if (this.data.submitting || this.data.continuing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
wx.showLoading({ title: '提交中' })
|
||||||
|
try {
|
||||||
|
const report = await api.submitReport(this.data.draft)
|
||||||
|
this.setData({ confirmVisible: false })
|
||||||
|
wx.reLaunch({
|
||||||
|
url: `/pages/reportResult/reportResult?reportId=${report.id}`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.handleExpiredReport(error)) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
continueWork() {
|
||||||
|
if (this.data.submitting || this.data.continuing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '继续上班',
|
||||||
|
content: '确认取消本次下班报工填报,继续保持上班状态?当前填写内容不会保存。',
|
||||||
|
confirmText: '继续上班',
|
||||||
|
cancelText: '返回填写',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ continuing: true })
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
await api.continueWork(this.data.sessionId)
|
||||||
|
this.clearCountdown()
|
||||||
|
wx.showToast({ title: '已继续上班', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.reLaunch({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.setData({ continuing: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/reportForm/reportForm.json
Normal file
3
pages/reportForm/reportForm.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "下班报工"
|
||||||
|
}
|
||||||
269
pages/reportForm/reportForm.wxml
Normal file
269
pages/reportForm/reportForm.wxml
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
<scroll-view class="page safe-bottom" scroll-y type="list">
|
||||||
|
<view class="header">
|
||||||
|
<view class="report-title-row">
|
||||||
|
<view class="report-title-main">
|
||||||
|
<text class="title">下班报工</text>
|
||||||
|
<text class="subtitle">按模具和设备填写产量,提交后进入审核</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{remainingText}}" class="countdown-pill">
|
||||||
|
<text class="countdown-label">剩余</text>
|
||||||
|
<text class="countdown-value">{{remainingText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{draft}}">
|
||||||
|
<view class="card">
|
||||||
|
<view class="row">
|
||||||
|
<view>
|
||||||
|
<text class="label">上班打卡时间</text>
|
||||||
|
<text class="value">{{draft.startAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="text-right">
|
||||||
|
<text class="label">下班打卡时间</text>
|
||||||
|
<text class="value">{{draft.endAtText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted">考勤点</text>
|
||||||
|
<text class="value">{{draft.attendancePointName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="muted">报工时长</text>
|
||||||
|
<text class="value">{{draft.durationMinutes}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{draft.deviceBlocks}}"
|
||||||
|
wx:key="blockKey"
|
||||||
|
wx:for-index="blockIndex"
|
||||||
|
wx:for-item="block"
|
||||||
|
class="card device-card"
|
||||||
|
>
|
||||||
|
<view class="row device-head">
|
||||||
|
<view>
|
||||||
|
<text class="label">模具名称</text>
|
||||||
|
<text class="value">{{block.moldDisplayName || block.moldName || block.deviceNo}}</text>
|
||||||
|
<text wx:if="{{block.attendancePointName}}" class="subtitle">考勤点 {{block.attendancePointName}}</text>
|
||||||
|
<text class="subtitle">累计工时 {{block.durationMinutes || 0}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
wx:if="{{block.canAddItem}}"
|
||||||
|
class="btn mini secondary add-product-btn"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
bindtap="addItem"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!block.options.length}}" class="list-empty">该模具暂无产品</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{block.items}}"
|
||||||
|
wx:key="index"
|
||||||
|
wx:for-index="itemIndex"
|
||||||
|
wx:for-item="reportItem"
|
||||||
|
class="item-box {{reportItem.isMultiPerson || reportItem.isContinuousDie ? 'multi-item-box' : ''}} {{reportItem.isMultiPerson && reportItem.isContinuousDie ? 'stacked-item-box' : ''}}"
|
||||||
|
>
|
||||||
|
<text wx:if="{{reportItem.isMultiPerson}}" class="multi-item-stamp">多人协作</text>
|
||||||
|
<text wx:if="{{reportItem.isContinuousDie}}" class="continuous-item-stamp {{reportItem.isMultiPerson ? 'lower-item-stamp' : ''}}">连续模</text>
|
||||||
|
<view class="row">
|
||||||
|
<text class="pill primary">报工 {{itemIndex + 1}}</text>
|
||||||
|
<button
|
||||||
|
wx:if="{{block.items.length > 1}}"
|
||||||
|
class="btn mini secondary"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
bindtap="removeItem"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">开始工作时间</text>
|
||||||
|
<view class="readonly">{{reportItem.startAtText || '-'}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{reportItem.isMisc}}" class="misc-auto-box">
|
||||||
|
<text class="label">处理杂活</text>
|
||||||
|
<text class="value">系统自动生成该报工卡片,只记录本段工时,杂活事项由管理员审核时备注。</text>
|
||||||
|
<text class="subtitle">累计工时 {{block.durationMinutes || 0}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!reportItem.isMisc}}" class="form-item">
|
||||||
|
<text class="label">设备号</text>
|
||||||
|
<picker
|
||||||
|
wx:if="{{block.equipmentOptions.length}}"
|
||||||
|
mode="selector"
|
||||||
|
range="{{block.equipmentOptions}}"
|
||||||
|
range-key="displayName"
|
||||||
|
value="{{reportItem.deviceIndex}}"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
bindchange="onDeviceChange"
|
||||||
|
>
|
||||||
|
<view class="picker {{reportItem.deviceError ? 'field-error' : ''}}">{{reportItem.deviceNo || '请选择设备号'}}</view>
|
||||||
|
</picker>
|
||||||
|
<view wx:else class="readonly {{reportItem.deviceError ? 'field-error' : ''}}">暂无可选设备</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!reportItem.isMisc}}" class="form-item">
|
||||||
|
<text class="label">材料库存批次号</text>
|
||||||
|
<picker
|
||||||
|
wx:if="{{reportItem.rawMaterialBatchOptions.length}}"
|
||||||
|
mode="selector"
|
||||||
|
range="{{reportItem.rawMaterialBatchOptions}}"
|
||||||
|
value="{{reportItem.rawMaterialBatchIndex}}"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
bindchange="onRawMaterialBatchChange"
|
||||||
|
>
|
||||||
|
<view class="picker {{reportItem.rawMaterialBatchError ? 'field-error' : ''}}">{{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}}</view>
|
||||||
|
</picker>
|
||||||
|
<view wx:else class="readonly">暂无可选材料库存批次号</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{reportItem.isContinuousDie}}" class="form-item">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<input
|
||||||
|
class="input number-input {{reportItem.changeoverCountError ? 'field-error' : ''}}"
|
||||||
|
type="number"
|
||||||
|
value="{{reportItem.changeoverCount}}"
|
||||||
|
placeholder="请输入换料次数"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="changeoverCount"
|
||||||
|
bindinput="onQtyInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!reportItem.isMisc}}" class="readonly-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">产品名称</text>
|
||||||
|
<text class="value">{{reportItem.productName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">考勤点</text>
|
||||||
|
<text class="value">{{reportItem.attendancePointName || block.attendancePointName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">项目号</text>
|
||||||
|
<text class="value">{{reportItem.projectNo || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">物料编码</text>
|
||||||
|
<text class="value">{{reportItem.materialCode || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">工序</text>
|
||||||
|
<text class="value">{{reportItem.process || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">冲压方式</text>
|
||||||
|
<text class="value">{{reportItem.stampingMethod || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">标准节拍</text>
|
||||||
|
<text class="value">{{reportItem.standardBeat || 0}} 秒</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!reportItem.isMisc}}" class="qty-grid">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">成品数量</text>
|
||||||
|
<input
|
||||||
|
class="input number-input"
|
||||||
|
type="number"
|
||||||
|
value="{{reportItem.goodQty}}"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="goodQty"
|
||||||
|
bindinput="onQtyInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">不良数量</text>
|
||||||
|
<input
|
||||||
|
class="input number-input"
|
||||||
|
type="number"
|
||||||
|
value="{{reportItem.defectQty}}"
|
||||||
|
data-block-index="{{blockIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="defectQty"
|
||||||
|
bindinput="onQtyInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn primary full submit-btn" disabled="{{continuing}}" bindtap="submit">提交报工</button>
|
||||||
|
<button
|
||||||
|
class="btn secondary full continue-work-btn"
|
||||||
|
disabled="{{submitting || continuing}}"
|
||||||
|
bindtap="continueWork"
|
||||||
|
>
|
||||||
|
{{continuing ? '处理中' : '继续上班'}}
|
||||||
|
</button>
|
||||||
|
</block>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{confirmVisible}}" class="confirm-mask" catchtap="cancelConfirm">
|
||||||
|
<view class="confirm-panel" catchtap="noop">
|
||||||
|
<text class="confirm-title">确认提交报工</text>
|
||||||
|
<view class="confirm-lines">
|
||||||
|
<view class="confirm-line">
|
||||||
|
<text class="confirm-label">模具</text>
|
||||||
|
<text class="confirm-value">{{confirmSummary.deviceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-line">
|
||||||
|
<text class="confirm-label">成品数量</text>
|
||||||
|
<text class="confirm-value">{{confirmSummary.totalGood}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-line">
|
||||||
|
<text class="confirm-label">不良数量</text>
|
||||||
|
<text class="confirm-value">{{confirmSummary.totalDefect}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-line">
|
||||||
|
<text class="confirm-label">审核说明</text>
|
||||||
|
<text class="confirm-value">{{confirmSummary.note}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-actions">
|
||||||
|
<button class="btn secondary confirm-btn" bindtap="cancelConfirm">取消</button>
|
||||||
|
<button class="btn primary confirm-btn" disabled="{{submitting}}" bindtap="confirmSubmit">{{submitting ? '提交中' : '确定'}}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{addStartVisible}}" class="confirm-mask" catchtap="cancelAddStart">
|
||||||
|
<view class="confirm-panel add-start-panel" catchtap="noop">
|
||||||
|
<text class="confirm-title">新增产品开始时间</text>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">开始日期</text>
|
||||||
|
<picker mode="date" value="{{addStartForm.date}}" bindchange="onAddStartDateChange">
|
||||||
|
<view class="picker">{{addStartForm.date}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">开始时间</text>
|
||||||
|
<picker
|
||||||
|
mode="multiSelector"
|
||||||
|
range="{{timeOptions}}"
|
||||||
|
value="{{addStartForm.timeValue}}"
|
||||||
|
bindchange="onAddStartTimeChange"
|
||||||
|
>
|
||||||
|
<view class="picker">{{addStartForm.timeText}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="confirm-actions">
|
||||||
|
<button class="btn secondary confirm-btn" bindtap="cancelAddStart">取消</button>
|
||||||
|
<button class="btn primary confirm-btn" bindtap="confirmAddStart">确定新增</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
290
pages/reportForm/reportForm.wxss
Normal file
290
pages/reportForm/reportForm.wxss
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
.report-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-title-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-pill {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 132rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border: 1rpx solid #ffd9a8;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff7e8;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-label {
|
||||||
|
display: block;
|
||||||
|
color: #a95605;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #7a3d00;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
padding-bottom: 8rpx;
|
||||||
|
border-left: 6rpx solid #1463ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-head {
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.mini.add-product-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 78rpx;
|
||||||
|
min-width: 78rpx;
|
||||||
|
height: 78rpx;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 68rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-box {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 22rpx 18rpx 18rpx;
|
||||||
|
border: 1rpx solid #dce4ef;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 10rpx 0 rgba(205, 216, 229, 0.5),
|
||||||
|
0 16rpx 24rpx rgba(22, 34, 50, 0.08),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-box.multi-item-box {
|
||||||
|
padding-top: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-box.stacked-item-box {
|
||||||
|
padding-top: 104rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-item-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 18rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous-item-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 18rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lower-item-stamp {
|
||||||
|
top: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly {
|
||||||
|
min-height: 78rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid #ccd8e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 6rpx 0 rgba(205, 216, 229, 0.45),
|
||||||
|
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker.field-error,
|
||||||
|
.readonly.field-error {
|
||||||
|
border-color: #e5484d;
|
||||||
|
background: linear-gradient(180deg, #fff7f7 0%, #fff1f1 100%);
|
||||||
|
color: #b42318;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(180, 35, 24, 0.08),
|
||||||
|
0 0 0 4rpx rgba(229, 72, 77, 0.12),
|
||||||
|
0 6rpx 0 rgba(255, 197, 197, 0.65),
|
||||||
|
0 10rpx 18rpx rgba(180, 35, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx 20rpx;
|
||||||
|
margin: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-auto-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border: 2rpx solid #f3b35b;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #fffaf1 0%, #fff3df 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(220, 139, 34, 0.32),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
height: 92rpx;
|
||||||
|
line-height: 92rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-work-btn {
|
||||||
|
margin: 28rpx auto 16rpx;
|
||||||
|
width: 430rpx;
|
||||||
|
height: 78rpx;
|
||||||
|
line-height: 78rpx;
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-start-panel .form-item {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 44rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.52);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-panel {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 44rpx 34rpx 40rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.64),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.28),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 22rpx;
|
||||||
|
border: 1rpx solid #dce4ef;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
|
||||||
|
0 8rpx 0 rgba(205, 216, 229, 0.48),
|
||||||
|
0 12rpx 18rpx rgba(22, 34, 50, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150rpx minmax(0, 1fr);
|
||||||
|
gap: 18rpx;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
color: #667085;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-value {
|
||||||
|
min-width: 0;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
padding: 0 2rpx 12rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
110
pages/reportResult/reportResult.js
Normal file
110
pages/reportResult/reportResult.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
report: null,
|
||||||
|
},
|
||||||
|
async onLoad(options) {
|
||||||
|
try {
|
||||||
|
const report = await api.getReport(options.reportId)
|
||||||
|
this.setData({
|
||||||
|
report: {
|
||||||
|
...report,
|
||||||
|
itemResults: this.buildItemResults(report),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
round1(value) {
|
||||||
|
return Math.round(Number(value || 0) * 10) / 10
|
||||||
|
},
|
||||||
|
round2(value) {
|
||||||
|
return Math.round(Number(value || 0) * 100) / 100
|
||||||
|
},
|
||||||
|
buildCompareText(rate, fastLabel, slowLabel) {
|
||||||
|
return `${rate > 0 ? slowLabel : fastLabel}${Math.abs(this.round1(rate))}%`
|
||||||
|
},
|
||||||
|
buildWorkloadText(rate) {
|
||||||
|
return `${rate >= 0 ? '工作量大于标准工作量' : '工作量小于标准工作量'}${Math.abs(this.round1(rate))}%`
|
||||||
|
},
|
||||||
|
buildItemResults(report) {
|
||||||
|
return (report.items || []).map((item, index) => {
|
||||||
|
if (item.isMisc) {
|
||||||
|
return {
|
||||||
|
key: `${item.id || index}`,
|
||||||
|
title: '处理杂活',
|
||||||
|
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
|
||||||
|
goodQty: 0,
|
||||||
|
defectQty: 0,
|
||||||
|
workTimeText: item.workTimeText || '-',
|
||||||
|
resultText: '处理杂活工时已提交管理员审核,杂活事项由管理员备注留痕',
|
||||||
|
goodResult: true,
|
||||||
|
isMisc: true,
|
||||||
|
isMultiPerson: !!item.isMultiPerson,
|
||||||
|
isContinuousDie: !!item.isContinuousDie,
|
||||||
|
changeoverCount: item.changeoverCount || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.isCleaning) {
|
||||||
|
return {
|
||||||
|
key: `${item.id || index}`,
|
||||||
|
title: `清洗 · ${item.displayName || item.productName || '-'}`,
|
||||||
|
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 项目 ${item.projectNo || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
|
||||||
|
goodQty: this.round2(item.goodQty || 0),
|
||||||
|
defectQty: 0,
|
||||||
|
resultText: `清洗数量 ${this.round2(item.goodQty || 0)},已提交管理员审核`,
|
||||||
|
goodResult: true,
|
||||||
|
isCleaning: true,
|
||||||
|
isMultiPerson: !!item.isMultiPerson,
|
||||||
|
isContinuousDie: !!item.isContinuousDie,
|
||||||
|
changeoverCount: item.changeoverCount || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goodQty = Number(item.goodQty || 0)
|
||||||
|
const defectQty = Number(item.defectQty || 0)
|
||||||
|
const outputQty = goodQty + defectQty
|
||||||
|
const actualBeat = outputQty > 0 ? Number(item.allocatedMinutes || 0) * 60 / outputQty : 0
|
||||||
|
const standardBeat = Number(item.standardBeat || 0)
|
||||||
|
const standardWorkload = api.calculateStandardWorkload(item.allocatedMinutes, standardBeat) || Number(item.standardWorkload || 0)
|
||||||
|
const paceRate = standardBeat > 0 ? (actualBeat - standardBeat) / standardBeat * 100 : 0
|
||||||
|
const workloadRate = standardWorkload > 0 ? (goodQty - standardWorkload) / standardWorkload * 100 : 0
|
||||||
|
const paceText = this.buildCompareText(paceRate, '节拍快于标准节拍', '节拍慢于标准节拍')
|
||||||
|
const workloadText = this.buildWorkloadText(workloadRate)
|
||||||
|
const overStandardAbnormal = paceRate < -30 || workloadRate > 30
|
||||||
|
const underStandardAbnormal = paceRate > 0 || workloadRate < 0
|
||||||
|
const abnormal = overStandardAbnormal || underStandardAbnormal
|
||||||
|
const goodResult = !abnormal
|
||||||
|
const suffix = overStandardAbnormal
|
||||||
|
? '数据明显超出标准,请自我核对一下是不是报工时填错。'
|
||||||
|
: (goodResult ? '今天真的很棒呢!' : '后续需要加油喽!')
|
||||||
|
return {
|
||||||
|
key: `${item.id || index}`,
|
||||||
|
title: `${item.deviceNo || '-'} · ${item.displayName || item.productName || '-'}`,
|
||||||
|
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 项目 ${item.projectNo || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
|
||||||
|
actualBeat: this.round2(actualBeat),
|
||||||
|
standardBeat: this.round2(standardBeat),
|
||||||
|
goodQty: this.round2(goodQty),
|
||||||
|
defectQty: this.round2(defectQty),
|
||||||
|
standardWorkload: this.round1(standardWorkload),
|
||||||
|
resultText: `今日${paceText},今日${workloadText},${suffix}`,
|
||||||
|
goodResult,
|
||||||
|
overStandardAbnormal: abnormal,
|
||||||
|
isMultiPerson: !!item.isMultiPerson,
|
||||||
|
isContinuousDie: !!item.isContinuousDie,
|
||||||
|
changeoverCount: item.changeoverCount || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
goRecords() {
|
||||||
|
wx.redirectTo({
|
||||||
|
url: '/pages/records/records',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
goHome() {
|
||||||
|
wx.reLaunch({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
4
pages/reportResult/reportResult.json
Normal file
4
pages/reportResult/reportResult.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"disableSwipeBack": true
|
||||||
|
}
|
||||||
74
pages/reportResult/reportResult.wxml
Normal file
74
pages/reportResult/reportResult.wxml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<view class="page safe-bottom result-page">
|
||||||
|
<view class="result-nav">
|
||||||
|
<text class="result-nav-title">报工结果</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">报工成功</text>
|
||||||
|
<text class="subtitle">已提交管理员审核</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{report}}">
|
||||||
|
<view class="card">
|
||||||
|
<text wx:if="{{report.hasMisc}}" class="misc-watermark">包含杂活</text>
|
||||||
|
<text wx:if="{{report.hasMultiPerson}}" class="multi-person-watermark {{report.hasMisc ? 'lower-watermark' : ''}}">多人协作</text>
|
||||||
|
<text wx:if="{{report.hasContinuousDie}}" class="continuous-die-watermark {{report.hasMisc || report.hasMultiPerson ? 'lower-watermark' : ''}} {{report.hasMisc && report.hasMultiPerson ? 'third-watermark' : ''}}">连续模</text>
|
||||||
|
<text class="label">报工日期</text>
|
||||||
|
<text class="value">{{report.reportDate}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{report.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{report.metrics.shiftDistributionText}}</text>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view
|
||||||
|
wx:for="{{report.itemResults}}"
|
||||||
|
wx:key="key"
|
||||||
|
wx:for-item="result"
|
||||||
|
class="result-item {{result.goodResult ? 'good' : (result.overStandardAbnormal ? 'danger' : 'warn')}} {{result.isMultiPerson || result.isContinuousDie ? 'marked-result' : ''}} {{result.isMultiPerson && result.isContinuousDie ? 'stacked-result' : ''}}"
|
||||||
|
>
|
||||||
|
<text wx:if="{{result.isMultiPerson}}" class="result-multi-stamp">多人协作</text>
|
||||||
|
<text wx:if="{{result.isContinuousDie}}" class="result-continuous-stamp {{result.isMultiPerson ? 'lower-result-stamp' : ''}}">连续模</text>
|
||||||
|
<text class="result-title">{{result.title}}</text>
|
||||||
|
<text class="result-subtitle">{{result.subtitle}}</text>
|
||||||
|
<text class="result-text">{{result.resultText}}</text>
|
||||||
|
<view wx:if="{{result.isMisc}}" class="result-metrics">
|
||||||
|
<view>
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{result.workTimeText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{result.isCleaning}}" class="result-metrics">
|
||||||
|
<view>
|
||||||
|
<text class="label">清洗数量</text>
|
||||||
|
<text class="value">{{result.goodQty}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="result-metrics">
|
||||||
|
<view>
|
||||||
|
<text class="label">实际节拍</text>
|
||||||
|
<text class="value">{{result.actualBeat}} 秒/件</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">标准节拍</text>
|
||||||
|
<text class="value">{{result.standardBeat}} 秒/件</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">成品数量</text>
|
||||||
|
<text class="value">{{result.goodQty}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">标准工作量</text>
|
||||||
|
<text class="value">{{result.standardWorkload}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{result.isContinuousDie}}">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<text class="value">{{result.changeoverCount || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="goRecords">查看记录</button>
|
||||||
|
<button class="btn primary full" bindtap="goHome">返回首页</button>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
125
pages/reportResult/reportResult.wxss
Normal file
125
pages/reportResult/reportResult.wxss
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
.result-page {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-nav {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 176rpx;
|
||||||
|
margin: 0 -24rpx 22rpx;
|
||||||
|
padding-top: 88rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(213, 222, 235, 0.9);
|
||||||
|
background: rgba(247, 248, 250, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-nav-title {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.marked-result {
|
||||||
|
padding-top: 62rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.stacked-result {
|
||||||
|
padding-top: 104rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .result-multi-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 16rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-continuous-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 16rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
padding: 6rpx 12rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-continuous-stamp.lower-result-stamp {
|
||||||
|
top: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.good {
|
||||||
|
border: 1rpx solid #bfe8d4;
|
||||||
|
background: #eefaf4;
|
||||||
|
color: #087443;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.warn {
|
||||||
|
border: 1rpx solid #f7d7a8;
|
||||||
|
background: #fff7e8;
|
||||||
|
color: #9a4a05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.danger {
|
||||||
|
border: 1rpx solid #f1b4b2;
|
||||||
|
background: #fff1f0;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx 18rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding-top: 18rpx;
|
||||||
|
border-top: 1rpx solid rgba(102, 112, 133, 0.16);
|
||||||
|
}
|
||||||
871
pages/review/review.js
Normal file
871
pages/review/review.js
Normal file
@ -0,0 +1,871 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const pad = value => {
|
||||||
|
const text = String(value)
|
||||||
|
return text.length > 1 ? text : `0${text}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePart = value => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const timePart = value => {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTimeValue = (date, time) => (date && time ? `${date}T${time}:00` : '')
|
||||||
|
|
||||||
|
const productChoiceKey = item => `${item.attendancePointName || ''}||${item.projectNo}||${item.productName}`
|
||||||
|
|
||||||
|
const optionKey = item => `${item.attendancePointName || ''}||${item.projectNo}||${item.productName}||${item.process || ''}||${api.normalizeDeviceNo(item.deviceNo)}`
|
||||||
|
|
||||||
|
const segmentKey = item => `${api.normalizeDeviceNo(item.moldName || item.productName || item.deviceNo)}||${String(item.process || '').trim()}`
|
||||||
|
|
||||||
|
const round2 = value => Math.round(Number(value || 0) * 100) / 100
|
||||||
|
|
||||||
|
const withCompareClasses = item => {
|
||||||
|
const standardWorkload = item.isCleaning
|
||||||
|
? 0
|
||||||
|
: (item.isMisc ? 0 : (api.calculateStandardWorkload(item.allocatedMinutes, item.standardBeat) || Number(item.standardWorkload || 0)))
|
||||||
|
const beatComparison = (item.isCleaning || item.isMisc) ? api.classifyBeat(0, 0) : api.classifyBeat(item.actualBeat, item.standardBeat)
|
||||||
|
const workloadComparison = (item.isCleaning || item.isMisc) ? api.classifyWorkload(0, 0) : api.classifyWorkload(item.goodQty, standardWorkload)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
standardWorkload,
|
||||||
|
beatCompareClass: beatComparison.className,
|
||||||
|
beatReason: beatComparison.reason,
|
||||||
|
workloadCompareClass: workloadComparison.className,
|
||||||
|
workloadReason: workloadComparison.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const correctionOrCurrent = (corrections, key, currentValue) => (
|
||||||
|
corrections && corrections[key] !== undefined ? corrections[key] : currentValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemActualBeat = item => {
|
||||||
|
const outputQty = Number(item.goodQty || 0) + Number(item.defectQty || 0)
|
||||||
|
return outputQty > 0 ? round2(Number(item.allocatedMinutes || 0) * 60 / outputQty) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
user: null,
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
focusReportId: '',
|
||||||
|
refreshing: false,
|
||||||
|
scrollTop: 0,
|
||||||
|
expandedReportId: '',
|
||||||
|
detailExpandedAll: false,
|
||||||
|
filterVoided: true,
|
||||||
|
productModalVisible: false,
|
||||||
|
productModalRows: [],
|
||||||
|
productModalKeyword: '',
|
||||||
|
productModalPage: 1,
|
||||||
|
productModalPageInput: '1',
|
||||||
|
productModalTotalPages: 1,
|
||||||
|
productModalContext: null,
|
||||||
|
productModalLoading: false,
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
this.setData({ focusReportId: options.reportId || '' })
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
onScroll(e) {
|
||||||
|
this.currentScrollTop = e.detail.scrollTop || 0
|
||||||
|
},
|
||||||
|
toggleDetails() {
|
||||||
|
this.setData({
|
||||||
|
detailExpandedAll: !this.data.detailExpandedAll,
|
||||||
|
expandedReportId: '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
queryReportTop(id) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.createSelectorQuery()
|
||||||
|
.select(`#review-report-${id}`)
|
||||||
|
.boundingClientRect(rect => {
|
||||||
|
resolve(rect ? rect.top : null)
|
||||||
|
})
|
||||||
|
.exec()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
restoreReportPosition(id, beforeTop) {
|
||||||
|
if (beforeTop === null || beforeTop === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.nextTick(() => {
|
||||||
|
this.queryReportTop(id).then(afterTop => {
|
||||||
|
if (afterTop === null || afterTop === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
(this.currentScrollTop || 0) + afterTop - beforeTop,
|
||||||
|
)
|
||||||
|
this.currentScrollTop = nextScrollTop
|
||||||
|
this.setData({ scrollTop: nextScrollTop })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleReportDetail(e) {
|
||||||
|
const id = String(e.currentTarget.dataset.id || '')
|
||||||
|
const switchingReport = this.data.detailExpandedAll
|
||||||
|
|| (!!this.data.expandedReportId && this.data.expandedReportId !== id)
|
||||||
|
this.queryReportTop(id).then(beforeTop => {
|
||||||
|
this.setData({
|
||||||
|
detailExpandedAll: false,
|
||||||
|
expandedReportId: this.data.expandedReportId === id ? '' : id,
|
||||||
|
}, () => {
|
||||||
|
if (switchingReport) {
|
||||||
|
this.restoreReportPosition(id, beforeTop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
const user = api.getCurrentUser()
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (this.data.focusReportId) {
|
||||||
|
const report = await api.getReport(this.data.focusReportId)
|
||||||
|
result = {
|
||||||
|
rows: (report.status === 'pending' || report.canEdit || report.isVoided) ? [report] : [],
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await api.listReports({
|
||||||
|
status: 'pending',
|
||||||
|
page,
|
||||||
|
pageSize: 6,
|
||||||
|
includeVoided: !this.data.filterVoided,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const rows = await this.buildReviewRows(result.rows || [])
|
||||||
|
const defaultExpandedId = this.data.focusReportId && rows.length ? String(rows[0].id) : ''
|
||||||
|
this.setData({
|
||||||
|
user,
|
||||||
|
rows,
|
||||||
|
page: result.page,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
expandedReportId: defaultExpandedId,
|
||||||
|
detailExpandedAll: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({ user, rows: [] })
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async buildReviewRows(rows) {
|
||||||
|
const hydrated = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const editItems = []
|
||||||
|
const editDeviceSegments = (row.deviceSegments || []).map((segment, index) => this.makeEditDeviceSegment(segment, index))
|
||||||
|
for (const detail of row.items || []) {
|
||||||
|
const editItem = await this.buildReviewItem(detail)
|
||||||
|
const itemKey = segmentKey(editItem)
|
||||||
|
editItem.deviceSegments = editDeviceSegments.filter(segment => segmentKey(segment) === itemKey)
|
||||||
|
editItems.push(editItem)
|
||||||
|
}
|
||||||
|
hydrated.push({
|
||||||
|
...row,
|
||||||
|
timeRangeText: row.isCleaning ? '清洗报工' : `${row.startAtText} 至 ${row.endAtText}`,
|
||||||
|
editStartDate: datePart(row.startAt),
|
||||||
|
editStartTime: timePart(row.startAt),
|
||||||
|
editEndDate: datePart(row.endAt),
|
||||||
|
editEndTime: timePart(row.endAt),
|
||||||
|
editReviewRemark: row.reviewRemark || '',
|
||||||
|
editItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return hydrated
|
||||||
|
},
|
||||||
|
async buildReviewItem(detail) {
|
||||||
|
const [products, equipmentOptions] = await Promise.all([
|
||||||
|
this.loadProductOptions(detail.productName, detail.attendancePointName),
|
||||||
|
detail.isMisc ? Promise.resolve([]) : this.loadEquipmentOptions(detail.attendancePointName, !!detail.isCleaning),
|
||||||
|
])
|
||||||
|
return this.makeEditItem(detail, products, equipmentOptions)
|
||||||
|
},
|
||||||
|
async loadProductOptions(productName, attendancePointName = '', keyword = '') {
|
||||||
|
try {
|
||||||
|
const result = await api.listProducts({
|
||||||
|
productName: keyword ? '' : productName,
|
||||||
|
keyword,
|
||||||
|
attendancePointName,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
})
|
||||||
|
return result.rows || []
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadEquipmentOptions(attendancePointName = '', isCleaning = false) {
|
||||||
|
try {
|
||||||
|
const result = await api.listEquipment({
|
||||||
|
attendancePointName,
|
||||||
|
deviceType: isCleaning ? '清洗设备' : '冲压设备',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
})
|
||||||
|
return result.rows || []
|
||||||
|
} catch (error) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeEditItem(detail, products, equipmentOptions = []) {
|
||||||
|
const corrections = detail.corrections || {}
|
||||||
|
const current = {
|
||||||
|
attendancePointName: detail.attendancePointName || '',
|
||||||
|
projectNo: detail.projectNo,
|
||||||
|
productName: detail.productName,
|
||||||
|
process: detail.process || '',
|
||||||
|
stampingMethod: detail.stampingMethod || '',
|
||||||
|
deviceNo: detail.deviceNo,
|
||||||
|
materialCode: detail.materialCode || '',
|
||||||
|
materialName: detail.materialName || '',
|
||||||
|
rawMaterialBatchNo: detail.rawMaterialBatchNo || '',
|
||||||
|
standardBeat: detail.standardBeat || 0,
|
||||||
|
standardWorkload: detail.standardWorkload || 0,
|
||||||
|
isCleaning: !!detail.isCleaning,
|
||||||
|
isContinuousDie: !!detail.isContinuousDie,
|
||||||
|
isMisc: !!detail.isMisc,
|
||||||
|
isMultiPerson: !!detail.isMultiPerson,
|
||||||
|
operatorCount: detail.operatorCount || 1,
|
||||||
|
changeoverCount: detail.changeoverCount || 0,
|
||||||
|
remark: detail.remark || '',
|
||||||
|
}
|
||||||
|
const originalProduct = {
|
||||||
|
attendancePointName: current.attendancePointName,
|
||||||
|
projectNo: correctionOrCurrent(corrections, 'project_no', current.projectNo),
|
||||||
|
productName: correctionOrCurrent(corrections, 'product_name', current.productName),
|
||||||
|
process: correctionOrCurrent(corrections, 'process_name', current.process) || '',
|
||||||
|
materialCode: current.materialCode,
|
||||||
|
materialName: current.materialName,
|
||||||
|
stampingMethod: correctionOrCurrent(corrections, 'stamping_method', current.stampingMethod) || '',
|
||||||
|
operatorCount: current.operatorCount,
|
||||||
|
standardBeat: current.standardBeat,
|
||||||
|
isCleaning: current.isCleaning,
|
||||||
|
isContinuousDie: current.isContinuousDie,
|
||||||
|
isMisc: current.isMisc,
|
||||||
|
isMultiPerson: current.isMultiPerson,
|
||||||
|
}
|
||||||
|
const optionMap = {}
|
||||||
|
;(products || []).forEach(product => {
|
||||||
|
optionMap[optionKey(product)] = product
|
||||||
|
})
|
||||||
|
optionMap[optionKey(current)] = current
|
||||||
|
const options = Object.keys(optionMap).map(key => optionMap[key])
|
||||||
|
const productMap = {}
|
||||||
|
options.forEach(product => {
|
||||||
|
productMap[productChoiceKey(product)] = {
|
||||||
|
attendancePointName: product.attendancePointName || current.attendancePointName,
|
||||||
|
projectNo: product.projectNo,
|
||||||
|
productName: product.productName,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const productChoices = Object.keys(productMap).map(key => productMap[key])
|
||||||
|
const productLabels = productChoices.map(item => `${item.projectNo} · ${item.productName}`)
|
||||||
|
let productIndex = productChoices.findIndex(item => item.projectNo === current.projectNo && item.productName === current.productName)
|
||||||
|
if (productIndex < 0) {
|
||||||
|
productIndex = 0
|
||||||
|
}
|
||||||
|
const selectedProduct = productChoices[productIndex] || current
|
||||||
|
const processOptions = options.filter(item => (
|
||||||
|
item.attendancePointName === selectedProduct.attendancePointName
|
||||||
|
&& item.projectNo === selectedProduct.projectNo
|
||||||
|
&& item.productName === selectedProduct.productName
|
||||||
|
))
|
||||||
|
const processLabels = processOptions.map(item => item.process || '未填写工序')
|
||||||
|
let processIndex = processOptions.findIndex(item => (item.process || '') === current.process)
|
||||||
|
if (processIndex < 0) {
|
||||||
|
processIndex = 0
|
||||||
|
}
|
||||||
|
const selectedOption = processOptions[processIndex] || current
|
||||||
|
const selectedDeviceNo = api.normalizeDeviceNo(detail.deviceNo)
|
||||||
|
const deviceIndex = selectedDeviceNo
|
||||||
|
? equipmentOptions.findIndex(item => api.normalizeDeviceNo(item.deviceNo) === selectedDeviceNo)
|
||||||
|
: -1
|
||||||
|
return withCompareClasses({
|
||||||
|
id: detail.id,
|
||||||
|
attendancePointName: current.attendancePointName,
|
||||||
|
startAt: detail.startAt || '',
|
||||||
|
isCleaning: !!detail.isCleaning,
|
||||||
|
isContinuousDie: !!selectedOption.isContinuousDie,
|
||||||
|
isMisc: !!detail.isMisc,
|
||||||
|
isMultiPerson: !!selectedOption.isMultiPerson,
|
||||||
|
operatorCount: selectedOption.operatorCount || detail.operatorCount || 1,
|
||||||
|
changeoverCount: detail.changeoverCount || 0,
|
||||||
|
changeoverCountError: false,
|
||||||
|
remark: detail.remark || '',
|
||||||
|
deviceError: false,
|
||||||
|
deviceNo: detail.deviceNo,
|
||||||
|
deviceIndex,
|
||||||
|
equipmentOptions,
|
||||||
|
projectNo: selectedOption.projectNo,
|
||||||
|
productName: selectedOption.productName,
|
||||||
|
process: selectedOption.process || '',
|
||||||
|
stampingMethod: selectedOption.stampingMethod || current.stampingMethod || '',
|
||||||
|
rawMaterialBatchNo: detail.rawMaterialBatchNo || '',
|
||||||
|
productSearchKeyword: '',
|
||||||
|
productOptions: options,
|
||||||
|
productChoices,
|
||||||
|
productLabels,
|
||||||
|
productIndex,
|
||||||
|
processOptions,
|
||||||
|
processLabels,
|
||||||
|
processIndex,
|
||||||
|
originalProduct,
|
||||||
|
goodQty: detail.goodQty,
|
||||||
|
defectQty: detail.defectQty,
|
||||||
|
allocatedMinutes: detail.allocatedMinutes || 0,
|
||||||
|
workTimeText: detail.workTimeText || '0秒',
|
||||||
|
actualBeat: detail.actualBeat || 0,
|
||||||
|
standardBeat: selectedOption.standardBeat || detail.standardBeat || 0,
|
||||||
|
standardWorkload: selectedOption.standardWorkload || detail.standardWorkload || 0,
|
||||||
|
deviceSegments: detail.deviceSegments || [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
productToEditItem(item, product) {
|
||||||
|
const selectedDeviceNo = api.normalizeDeviceNo(item.deviceNo)
|
||||||
|
const deviceIndex = selectedDeviceNo
|
||||||
|
? (item.equipmentOptions || []).findIndex(equipment => api.normalizeDeviceNo(equipment.deviceNo) === selectedDeviceNo)
|
||||||
|
: -1
|
||||||
|
const nextItem = {
|
||||||
|
...item,
|
||||||
|
attendancePointName: item.attendancePointName,
|
||||||
|
projectNo: product.projectNo || item.projectNo,
|
||||||
|
productName: product.productName || item.productName,
|
||||||
|
materialCode: product.materialCode || '',
|
||||||
|
materialName: product.materialName || '',
|
||||||
|
process: product.process || '',
|
||||||
|
stampingMethod: product.stampingMethod || '',
|
||||||
|
standardBeat: Number(product.standardBeat || 0),
|
||||||
|
standardWorkload: api.calculateStandardWorkload(item.allocatedMinutes, product.standardBeat),
|
||||||
|
operatorCount: product.operatorCount || 1,
|
||||||
|
isCleaning: !!product.isCleaning,
|
||||||
|
isContinuousDie: !!product.isContinuousDie,
|
||||||
|
isMisc: !!product.isMisc,
|
||||||
|
isMultiPerson: !!product.isMultiPerson,
|
||||||
|
changeoverCount: product.isContinuousDie ? (item.changeoverCount || 0) : 0,
|
||||||
|
changeoverCountError: false,
|
||||||
|
actualBeat: itemActualBeat(item),
|
||||||
|
deviceIndex,
|
||||||
|
productOptions: [product],
|
||||||
|
productChoices: [product],
|
||||||
|
productLabels: [`${product.projectNo || ''} · ${product.productName || ''}`],
|
||||||
|
productIndex: 0,
|
||||||
|
processOptions: [product],
|
||||||
|
processLabels: [product.process || '未填写工序'],
|
||||||
|
processIndex: 0,
|
||||||
|
}
|
||||||
|
return withCompareClasses(nextItem)
|
||||||
|
},
|
||||||
|
replaceEditProduct(reportIndex, itemIndex, product) {
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
if (!item || !product) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.replaceEditItem(reportIndex, itemIndex, this.productToEditItem(item, product))
|
||||||
|
},
|
||||||
|
async openProductModal(e) {
|
||||||
|
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
|
||||||
|
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
|
||||||
|
const row = this.data.rows[reportIndex]
|
||||||
|
const item = row && row.editItems ? row.editItems[itemIndex] : null
|
||||||
|
if (!item) {
|
||||||
|
wx.showToast({ title: '报工明细不存在', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
productModalVisible: true,
|
||||||
|
productModalContext: { reportIndex, itemIndex },
|
||||||
|
productModalRows: [],
|
||||||
|
productModalKeyword: '',
|
||||||
|
productModalPage: 1,
|
||||||
|
productModalPageInput: '1',
|
||||||
|
productModalTotalPages: 1,
|
||||||
|
})
|
||||||
|
await this.loadProductModal(1)
|
||||||
|
},
|
||||||
|
closeProductModal() {
|
||||||
|
this.setData({
|
||||||
|
productModalVisible: false,
|
||||||
|
productModalRows: [],
|
||||||
|
productModalKeyword: '',
|
||||||
|
productModalContext: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeviceChange(e) {
|
||||||
|
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
|
||||||
|
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
|
||||||
|
const deviceIndex = Number(e.detail.value)
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
const equipment = (item.equipmentOptions || [])[deviceIndex]
|
||||||
|
if (!equipment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].deviceIndex`]: deviceIndex,
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].deviceNo`]: equipment.deviceNo,
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].deviceError`]: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async loadProductModal(page = this.data.productModalPage) {
|
||||||
|
const context = this.data.productModalContext
|
||||||
|
if (!context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = this.data.rows[context.reportIndex].editItems[context.itemIndex]
|
||||||
|
this.setData({ productModalLoading: true })
|
||||||
|
try {
|
||||||
|
const result = await api.listProducts({
|
||||||
|
keyword: this.data.productModalKeyword,
|
||||||
|
attendancePointName: item.attendancePointName,
|
||||||
|
page,
|
||||||
|
pageSize: 8,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
productModalRows: result.rows || [],
|
||||||
|
productModalPage: result.page,
|
||||||
|
productModalPageInput: String(result.page || 1),
|
||||||
|
productModalTotalPages: result.totalPages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载产品失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ productModalLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProductModalKeywordInput(e) {
|
||||||
|
this.setData({ productModalKeyword: e.detail.value })
|
||||||
|
this.loadProductModal(1)
|
||||||
|
},
|
||||||
|
replaceProductFromModal(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const context = this.data.productModalContext
|
||||||
|
const product = this.data.productModalRows[index]
|
||||||
|
if (!context || !product) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const item = this.data.rows[context.reportIndex].editItems[context.itemIndex]
|
||||||
|
if (product.isMisc) {
|
||||||
|
wx.showToast({ title: '处理杂活不能替换到普通报工', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!!item.isCleaning !== !!product.isCleaning) {
|
||||||
|
wx.showToast({ title: item.isCleaning ? '清洗报工只能替换清洗工序' : '普通报工不能替换为清洗工序', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.replaceEditProduct(context.reportIndex, context.itemIndex, product)
|
||||||
|
this.closeProductModal()
|
||||||
|
},
|
||||||
|
productModalPrevPage() {
|
||||||
|
if (this.data.productModalPage > 1) {
|
||||||
|
this.loadProductModal(this.data.productModalPage - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
productModalNextPage() {
|
||||||
|
if (this.data.productModalPage < this.data.productModalTotalPages) {
|
||||||
|
this.loadProductModal(this.data.productModalPage + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProductModalPageInput(e) {
|
||||||
|
this.setData({ productModalPageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpProductModalPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.productModalPageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.productModalPage, this.data.productModalTotalPages))
|
||||||
|
if (target === this.data.productModalPage) {
|
||||||
|
this.setData({ productModalPageInput: String(this.data.productModalPage || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loadProductModal(target)
|
||||||
|
},
|
||||||
|
async restoreProduct(e) {
|
||||||
|
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
|
||||||
|
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
if (!item || !item.originalProduct) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const original = item.originalProduct
|
||||||
|
wx.showLoading({ title: '恢复中' })
|
||||||
|
try {
|
||||||
|
const products = await this.loadProductOptions(original.productName, item.attendancePointName)
|
||||||
|
const product = (products || []).find(option => (
|
||||||
|
option.projectNo === original.projectNo
|
||||||
|
&& option.productName === original.productName
|
||||||
|
&& (option.process || '') === (original.process || '')
|
||||||
|
)) || original
|
||||||
|
this.replaceEditProduct(reportIndex, itemIndex, {
|
||||||
|
...product,
|
||||||
|
projectNo: original.projectNo,
|
||||||
|
productName: original.productName,
|
||||||
|
process: original.process || '',
|
||||||
|
stampingMethod: product.stampingMethod || original.stampingMethod || '',
|
||||||
|
materialCode: product.materialCode || original.materialCode || '',
|
||||||
|
materialName: product.materialName || original.materialName || '',
|
||||||
|
operatorCount: product.operatorCount || original.operatorCount || 1,
|
||||||
|
standardBeat: product.standardBeat === undefined ? original.standardBeat : product.standardBeat,
|
||||||
|
isCleaning: product.isCleaning === undefined ? original.isCleaning : product.isCleaning,
|
||||||
|
isContinuousDie: product.isContinuousDie === undefined ? original.isContinuousDie : product.isContinuousDie,
|
||||||
|
isMisc: product.isMisc === undefined ? original.isMisc : product.isMisc,
|
||||||
|
isMultiPerson: product.isMultiPerson === undefined ? original.isMultiPerson : product.isMultiPerson,
|
||||||
|
changeoverCount: (product.isContinuousDie === undefined ? original.isContinuousDie : product.isContinuousDie) ? (item.changeoverCount || 0) : 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '恢复失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeEditDeviceSegment(segment, index) {
|
||||||
|
const editScanDate = datePart(segment.scannedAt)
|
||||||
|
const editScanTime = timePart(segment.scannedAt)
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
label: index === 0 ? '开始模具' : '换模具',
|
||||||
|
editScanDate,
|
||||||
|
editScanTime,
|
||||||
|
originalScanDate: editScanDate,
|
||||||
|
originalScanTime: editScanTime,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
replaceEditItem(reportIndex, itemIndex, item) {
|
||||||
|
this.setData({
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}]`]: item,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDeviceSegmentFieldChange(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const segmentIndex = e.currentTarget.dataset.segmentIndex
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].deviceSegments[${segmentIndex}].${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onReportFieldChange(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`rows[${reportIndex}].${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onItemInput(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
const value = e.detail.value
|
||||||
|
const updates = {
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].${field}`]: value,
|
||||||
|
}
|
||||||
|
if (field === 'goodQty' || field === 'defectQty') {
|
||||||
|
const item = {
|
||||||
|
...this.data.rows[reportIndex].editItems[itemIndex],
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
const outputQty = Number(item.goodQty || 0) + Number(item.defectQty || 0)
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].actualBeat`] = outputQty > 0
|
||||||
|
? round2(Number(item.allocatedMinutes || 0) * 60 / outputQty)
|
||||||
|
: 0
|
||||||
|
const nextItem = {
|
||||||
|
...item,
|
||||||
|
actualBeat: updates[`rows[${reportIndex}].editItems[${itemIndex}].actualBeat`],
|
||||||
|
}
|
||||||
|
const compared = withCompareClasses(nextItem)
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].standardWorkload`] = compared.standardWorkload
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].beatCompareClass`] = compared.beatCompareClass
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].beatReason`] = compared.beatReason
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].workloadCompareClass`] = compared.workloadCompareClass
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].workloadReason`] = compared.workloadReason
|
||||||
|
}
|
||||||
|
if (field === 'changeoverCount') {
|
||||||
|
updates[`rows[${reportIndex}].editItems[${itemIndex}].changeoverCountError`] = false
|
||||||
|
}
|
||||||
|
this.setData(updates)
|
||||||
|
},
|
||||||
|
async reloadItemOptions(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
wx.showLoading({ title: '匹配中' })
|
||||||
|
try {
|
||||||
|
const keyword = String(item.productSearchKeyword || '').trim()
|
||||||
|
const products = await this.loadProductOptions(item.productName, item.attendancePointName, keyword)
|
||||||
|
this.replaceEditItem(reportIndex, itemIndex, this.makeEditItem(item, products))
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProductSearchInput(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
this.setData({
|
||||||
|
[`rows[${reportIndex}].editItems[${itemIndex}].productSearchKeyword`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onProductChange(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
const productIndex = Number(e.detail.value)
|
||||||
|
const selectedProduct = item.productChoices[productIndex]
|
||||||
|
const processOptions = item.productOptions.filter(option => (
|
||||||
|
option.attendancePointName === selectedProduct.attendancePointName
|
||||||
|
&& option.projectNo === selectedProduct.projectNo
|
||||||
|
&& option.productName === selectedProduct.productName
|
||||||
|
))
|
||||||
|
const nextOption = processOptions[0] || selectedProduct
|
||||||
|
this.replaceEditItem(reportIndex, itemIndex, withCompareClasses({
|
||||||
|
...item,
|
||||||
|
productIndex,
|
||||||
|
attendancePointName: item.attendancePointName,
|
||||||
|
projectNo: selectedProduct.projectNo,
|
||||||
|
productName: selectedProduct.productName,
|
||||||
|
processOptions,
|
||||||
|
processLabels: processOptions.map(option => option.process || '未填写工序'),
|
||||||
|
processIndex: 0,
|
||||||
|
process: nextOption.process || '',
|
||||||
|
stampingMethod: nextOption.stampingMethod || '',
|
||||||
|
deviceNo: item.deviceNo,
|
||||||
|
standardBeat: nextOption.standardBeat || 0,
|
||||||
|
standardWorkload: nextOption.standardWorkload || 0,
|
||||||
|
operatorCount: nextOption.operatorCount || 1,
|
||||||
|
isMultiPerson: !!nextOption.isMultiPerson,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onProcessChange(e) {
|
||||||
|
const reportIndex = e.currentTarget.dataset.reportIndex
|
||||||
|
const itemIndex = e.currentTarget.dataset.itemIndex
|
||||||
|
const item = this.data.rows[reportIndex].editItems[itemIndex]
|
||||||
|
const processIndex = Number(e.detail.value)
|
||||||
|
const selected = item.processOptions[processIndex]
|
||||||
|
if (!selected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.replaceEditItem(reportIndex, itemIndex, withCompareClasses({
|
||||||
|
...item,
|
||||||
|
processIndex,
|
||||||
|
attendancePointName: item.attendancePointName,
|
||||||
|
deviceNo: item.deviceNo,
|
||||||
|
projectNo: selected.projectNo,
|
||||||
|
productName: selected.productName,
|
||||||
|
process: selected.process || '',
|
||||||
|
stampingMethod: selected.stampingMethod || '',
|
||||||
|
standardBeat: selected.standardBeat || 0,
|
||||||
|
standardWorkload: selected.standardWorkload || 0,
|
||||||
|
operatorCount: selected.operatorCount || 1,
|
||||||
|
isMultiPerson: !!selected.isMultiPerson,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async approve(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const user = this.data.user
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
wx.showToast({ title: '无审核权限', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const row = this.data.rows.find(item => String(item.id) === String(id))
|
||||||
|
if (!row) {
|
||||||
|
wx.showToast({ title: '报工记录不存在', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (row.isVoided) {
|
||||||
|
wx.showToast({ title: '作废记录不能编辑', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!row.canEdit) {
|
||||||
|
wx.showToast({ title: '已超过可编辑期限', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invalidItem = (row.editItems || []).find(item => (
|
||||||
|
Number(item.goodQty || 0) < 0
|
||||||
|
|| Number(item.defectQty || 0) < 0
|
||||||
|
|| !Number.isFinite(Number(item.goodQty || 0))
|
||||||
|
|| !Number.isFinite(Number(item.defectQty || 0))
|
||||||
|
))
|
||||||
|
if (invalidItem) {
|
||||||
|
wx.showToast({ title: '产出数量不能为负数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invalidChangeoverItemIndex = (row.editItems || []).findIndex(item => {
|
||||||
|
if (!item.isContinuousDie) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const value = Number(item.changeoverCount)
|
||||||
|
return !Number.isInteger(value) || value < 0
|
||||||
|
})
|
||||||
|
if (invalidChangeoverItemIndex >= 0) {
|
||||||
|
this.setData({
|
||||||
|
[`rows[${this.data.rows.findIndex(item => String(item.id) === String(id))}].editItems[${invalidChangeoverItemIndex}].changeoverCountError`]: true,
|
||||||
|
})
|
||||||
|
wx.showToast({ title: '请填写非负整数换料次数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const missingDeviceItemIndex = (row.editItems || []).findIndex(item => !item.isMisc && !api.normalizeDeviceNo(item.deviceNo))
|
||||||
|
if (missingDeviceItemIndex >= 0) {
|
||||||
|
this.setData({
|
||||||
|
[`rows[${this.data.rows.findIndex(item => String(item.id) === String(id))}].editItems[${missingDeviceItemIndex}].deviceError`]: true,
|
||||||
|
})
|
||||||
|
wx.showToast({ title: '请选择设备号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.submitApprove(id, row, user)
|
||||||
|
},
|
||||||
|
async submitApprove(id, row, user) {
|
||||||
|
const deviceCorrections = (row.editItems || []).flatMap(item => item.deviceSegments || [])
|
||||||
|
.filter(segment => (
|
||||||
|
segment.editScanDate !== segment.originalScanDate
|
||||||
|
|| segment.editScanTime !== segment.originalScanTime
|
||||||
|
))
|
||||||
|
.map(segment => ({
|
||||||
|
id: segment.id,
|
||||||
|
scannedAt: dateTimeValue(segment.editScanDate, segment.editScanTime),
|
||||||
|
}))
|
||||||
|
const invalidDeviceCorrection = deviceCorrections.find(item => !item.scannedAt)
|
||||||
|
if (invalidDeviceCorrection) {
|
||||||
|
wx.showToast({ title: '换模具时间不能为空', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const patch = {
|
||||||
|
startAt: row.isCleaning ? null : dateTimeValue(row.editStartDate, row.editStartTime),
|
||||||
|
endAt: row.isCleaning ? null : dateTimeValue(row.editEndDate, row.editEndTime),
|
||||||
|
deviceCorrections: row.isCleaning ? [] : deviceCorrections,
|
||||||
|
reviewRemark: row.editReviewRemark || '',
|
||||||
|
itemCorrections: (row.editItems || []).map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
device_no: api.normalizeDeviceNo(item.deviceNo),
|
||||||
|
project_no: item.projectNo,
|
||||||
|
product_name: item.productName,
|
||||||
|
raw_material_batch_no: item.rawMaterialBatchNo || null,
|
||||||
|
process_name: item.process || '',
|
||||||
|
changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0,
|
||||||
|
good_qty: Number(item.goodQty || 0),
|
||||||
|
defect_qty: Number(item.defectQty || 0),
|
||||||
|
scrap_qty: 0,
|
||||||
|
remark: item.remark || null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.approveReport(id, user, patch)
|
||||||
|
wx.showToast({ title: row.status === 'approved' ? '已保存更改' : '已审核通过', icon: 'success' })
|
||||||
|
if (this.data.focusReportId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
wx.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
fail: () => {
|
||||||
|
wx.redirectTo({
|
||||||
|
url: '/pages/records/records?mode=pending&status=pending',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
toggleFilterVoided() {
|
||||||
|
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onFilterVoidedChange(e) {
|
||||||
|
this.setData({ filterVoided: !!e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
async voidReport(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.showModal({
|
||||||
|
title: '确认作废',
|
||||||
|
content: '作废的记录将在30天后被系统彻底删除,彻底删除前可随时撤销作废,是否确定删除',
|
||||||
|
cancelText: '否',
|
||||||
|
confirmText: '是',
|
||||||
|
confirmColor: '#c5221f',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
await api.voidReport(id)
|
||||||
|
wx.showToast({ title: '已作废', icon: 'success' })
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '作废失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async unvoidReport(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.showLoading({ title: '处理中' })
|
||||||
|
try {
|
||||||
|
await api.unvoidReport(id)
|
||||||
|
wx.showToast({ title: '已撤销作废', icon: 'success' })
|
||||||
|
await this.load(this.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '撤销失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/review/review.json
Normal file
3
pages/review/review.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "报工审核"
|
||||||
|
}
|
||||||
338
pages/review/review.wxml
Normal file
338
pages/review/review.wxml
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
scroll-top="{{scrollTop}}"
|
||||||
|
scroll-anchoring="{{true}}"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindscroll="onScroll"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<view class="review-header-row">
|
||||||
|
<view class="review-header-main">
|
||||||
|
<text class="title">报工审核</text>
|
||||||
|
<text class="subtitle">复核并校正员工上报数据后通过</text>
|
||||||
|
</view>
|
||||||
|
<view class="top-action-group">
|
||||||
|
<view class="void-filter-switch">
|
||||||
|
<text>过滤作废</text>
|
||||||
|
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary detail-toggle" bindtap="toggleDetails">
|
||||||
|
{{detailExpandedAll ? '收起明细' : '展开明细'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!rows.length}}" class="list-empty">暂无待审核报工</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{rows}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-index="reportIndex"
|
||||||
|
id="review-report-{{item.id}}"
|
||||||
|
class="card review-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}} {{detailExpandedAll || expandedReportId == item.id ? 'expanded' : ''}}"
|
||||||
|
data-id="{{item.id}}"
|
||||||
|
bindtap="toggleReportDetail"
|
||||||
|
>
|
||||||
|
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
|
||||||
|
<text wx:elif="{{item.isModified}}" class="modified-watermark">更改</text>
|
||||||
|
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">{{item.autoSubmitReason || '超时-系统自动提交'}}</text>
|
||||||
|
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">清洗</text>
|
||||||
|
<text wx:if="{{item.hasMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">包含杂活</text>
|
||||||
|
<text wx:if="{{item.hasMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
|
||||||
|
<text wx:if="{{item.hasContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning || item.hasMultiPerson ? 'lower-watermark' : ''}} {{item.hasMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.timeRangeText}}</text>
|
||||||
|
<text class="subtitle">{{item.metrics.shiftDistributionText || ''}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill {{item.status === 'approved' ? 'success' : 'warn'}}">{{item.statusName}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{detailExpandedAll || expandedReportId == item.id}}" catchtap="noop">
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view wx:if="{{!item.isCleaning && !item.isVoided && item.canEdit}}" class="review-edit-panel">
|
||||||
|
<text class="panel-title">上下班时间校正</text>
|
||||||
|
<view class="time-grid">
|
||||||
|
<picker mode="date" value="{{item.editStartDate}}" data-report-index="{{reportIndex}}" data-field="editStartDate" bindchange="onReportFieldChange">
|
||||||
|
<view class="picker compact-picker">{{item.editStartDate || '上班日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="time" value="{{item.editStartTime}}" data-report-index="{{reportIndex}}" data-field="editStartTime" bindchange="onReportFieldChange">
|
||||||
|
<view class="picker compact-picker">{{item.editStartTime || '上班时间'}}</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="date" value="{{item.editEndDate}}" data-report-index="{{reportIndex}}" data-field="editEndDate" bindchange="onReportFieldChange">
|
||||||
|
<view class="picker compact-picker">{{item.editEndDate || '下班日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="time" value="{{item.editEndTime}}" data-report-index="{{reportIndex}}" data-field="editEndTime" bindchange="onReportFieldChange">
|
||||||
|
<view class="picker compact-picker">{{item.editEndTime || '下班时间'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="item-list">
|
||||||
|
<view wx:for="{{item.editItems}}" wx:key="id" wx:for-item="detail" wx:for-index="itemIndex" class="detail-line {{detail.isMultiPerson || detail.isContinuousDie ? 'marked-detail-line' : ''}} {{detail.isMultiPerson && detail.isContinuousDie ? 'stacked-detail-line' : ''}}">
|
||||||
|
<text wx:if="{{detail.isMultiPerson}}" class="detail-multi-stamp">多人协作</text>
|
||||||
|
<text wx:if="{{detail.isContinuousDie}}" class="detail-continuous-stamp {{detail.isMultiPerson ? 'lower-detail-stamp' : ''}}">连续模</text>
|
||||||
|
<text class="detail-title">{{detail.deviceNo}} · {{detail.productName}} / {{detail.process || '-'}} / {{detail.stampingMethod || '-'}}</text>
|
||||||
|
<text class="muted">考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}}</text>
|
||||||
|
<text wx:if="{{detail.isMisc}}" class="muted">处理杂活 · {{detail.workTimeText}}</text>
|
||||||
|
<text wx:elif="{{detail.isCleaning}}" class="muted">工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 清洗数量 {{detail.goodQty}}</text>
|
||||||
|
<text wx:else class="muted">工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 成品 {{detail.goodQty}} · 不良 {{detail.defectQty}}</text>
|
||||||
|
<text wx:if="{{!detail.isCleaning && !detail.isMisc}}" class="muted">材料库存批次号 {{detail.rawMaterialBatchNo || '-'}}</text>
|
||||||
|
<view wx:if="{{!detail.isCleaning && !detail.isMisc && detail.deviceSegments.length && !item.isVoided && item.canEdit}}" class="device-time-inline">
|
||||||
|
<text class="panel-title device-time-title">换模具时间校正</text>
|
||||||
|
<view
|
||||||
|
wx:for="{{detail.deviceSegments}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-item="segment"
|
||||||
|
wx:for-index="segmentIndex"
|
||||||
|
class="device-time-row"
|
||||||
|
>
|
||||||
|
<view class="device-time-main">
|
||||||
|
<text class="value">{{segment.label}} · {{segment.displayName}}</text>
|
||||||
|
<text class="subtitle">原记录 {{segment.scannedAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="device-time-pickers">
|
||||||
|
<picker mode="date" value="{{segment.editScanDate}}" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" data-segment-index="{{segmentIndex}}" data-field="editScanDate" bindchange="onDeviceSegmentFieldChange">
|
||||||
|
<view class="picker compact-picker">{{segment.editScanDate || '日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="time" value="{{segment.editScanTime}}" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" data-segment-index="{{segmentIndex}}" data-field="editScanTime" bindchange="onDeviceSegmentFieldChange">
|
||||||
|
<view class="picker compact-picker">{{segment.editScanTime || '时间'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.isMisc}}" class="detail-metric-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{detail.workTimeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">杂活事项</text>
|
||||||
|
<text class="value">{{detail.remark || '待备注'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{detail.isCleaning}}" class="detail-metric-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">清洗数量</text>
|
||||||
|
<text class="value">{{detail.goodQty}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{detail.workTimeText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:else class="detail-metric-grid">
|
||||||
|
<view>
|
||||||
|
<text class="label">工时</text>
|
||||||
|
<text class="value">{{detail.workTimeText}}</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">成品 / 标准工作量</text>
|
||||||
|
<text class="value {{detail.workloadCompareClass}}">
|
||||||
|
{{detail.goodQty}} / {{detail.standardWorkload}}
|
||||||
|
<text wx:if="{{detail.workloadReason}}" class="metric-reason">{{detail.workloadReason}}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">实际节拍</text>
|
||||||
|
<text class="value {{detail.beatCompareClass}}">
|
||||||
|
{{detail.actualBeat}} 秒
|
||||||
|
<text wx:if="{{detail.beatReason}}" class="metric-reason">{{detail.beatReason}}</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="label">标准节拍</text>
|
||||||
|
<text class="value">{{detail.standardBeat}} 秒</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.isContinuousDie}}">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<text class="value">{{detail.changeoverCount || 0}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{detail.isMisc && !item.isVoided && item.canEdit}}" class="review-edit-panel misc-remark-panel">
|
||||||
|
<text class="panel-title">杂活事项备注</text>
|
||||||
|
<textarea
|
||||||
|
class="input misc-remark-input"
|
||||||
|
value="{{detail.remark}}"
|
||||||
|
placeholder="请输入处理杂活事项,便于经理监督留痕"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="remark"
|
||||||
|
bindinput="onItemInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!detail.isMisc && !item.isVoided && item.canEdit}}" class="form-grid review-form-grid">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">设备号</text>
|
||||||
|
<picker
|
||||||
|
wx:if="{{detail.equipmentOptions.length}}"
|
||||||
|
mode="selector"
|
||||||
|
range="{{detail.equipmentOptions}}"
|
||||||
|
range-key="deviceNo"
|
||||||
|
value="{{detail.deviceIndex}}"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
bindchange="onDeviceChange"
|
||||||
|
>
|
||||||
|
<view class="device-select-control {{detail.deviceError ? 'field-error' : ''}}">
|
||||||
|
<text class="device-select-text">{{detail.deviceNo || (detail.isCleaning ? '请选择清洗设备号' : '请选择冲压设备号')}}</text>
|
||||||
|
<text class="device-select-action">选择</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<view wx:else class="readonly {{detail.deviceError ? 'field-error' : ''}}">暂无可选设备</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item full-span product-correction-row">
|
||||||
|
<text class="label">产品/工序更正</text>
|
||||||
|
<view class="product-correction-actions">
|
||||||
|
<button class="btn primary full" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" bindtap="openProductModal">更正产品</button>
|
||||||
|
<button class="btn secondary full" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" bindtap="restoreProduct">恢复</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item full-span">
|
||||||
|
<text class="label">产品名称</text>
|
||||||
|
<view class="readonly product-readonly">{{detail.projectNo}} · {{detail.productName}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">工序</text>
|
||||||
|
<view class="readonly product-readonly">{{detail.process || '未填写工序'}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">冲压方式</text>
|
||||||
|
<view class="readonly product-readonly">{{detail.stampingMethod || '-'}}</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{detail.isContinuousDie}}" class="form-item">
|
||||||
|
<text class="label">换料次数</text>
|
||||||
|
<input
|
||||||
|
class="input number-input {{detail.changeoverCountError ? 'field-error' : ''}}"
|
||||||
|
type="number"
|
||||||
|
value="{{detail.changeoverCount}}"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="changeoverCount"
|
||||||
|
bindinput="onItemInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">{{detail.isCleaning ? '清洗数量' : '成品数量'}}</text>
|
||||||
|
<input
|
||||||
|
class="input number-input"
|
||||||
|
type="digit"
|
||||||
|
value="{{detail.goodQty}}"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="goodQty"
|
||||||
|
bindinput="onItemInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!detail.isCleaning}}" class="form-item">
|
||||||
|
<text class="label">不良数量</text>
|
||||||
|
<input
|
||||||
|
class="input number-input"
|
||||||
|
type="digit"
|
||||||
|
value="{{detail.defectQty}}"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-item-index="{{itemIndex}}"
|
||||||
|
data-field="defectQty"
|
||||||
|
bindinput="onItemInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{!item.isVoided && item.canEdit}}" class="review-edit-panel review-remark-panel">
|
||||||
|
<text class="panel-title">审核备注</text>
|
||||||
|
<textarea
|
||||||
|
class="input review-remark-input"
|
||||||
|
value="{{item.editReviewRemark}}"
|
||||||
|
placeholder="请输入本条报工的审核备注"
|
||||||
|
maxlength="500"
|
||||||
|
data-report-index="{{reportIndex}}"
|
||||||
|
data-field="editReviewRemark"
|
||||||
|
bindinput="onReportFieldChange"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view wx:elif="{{item.reviewRemark}}" class="review-edit-panel review-remark-panel">
|
||||||
|
<text class="panel-title">审核备注</text>
|
||||||
|
<text class="readonly-text">{{item.reviewRemark}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{(!item.isVoided && (item.canEdit || item.canVoid)) || (item.isVoided && item.canUnvoid)}}" class="button-row review-action-row">
|
||||||
|
<button wx:if="{{!item.isVoided && item.canEdit}}" class="btn primary review-action-btn" data-id="{{item.id}}" bindtap="approve">{{item.status === 'approved' ? '保存更改' : '校正并审核通过'}}</button>
|
||||||
|
<button wx:if="{{!item.isVoided && item.canVoid}}" class="btn danger review-action-btn" data-id="{{item.id}}" catchtap="voidReport">作废</button>
|
||||||
|
<button wx:if="{{item.isVoided && item.canUnvoid}}" class="btn secondary review-action-btn" data-id="{{item.id}}" catchtap="unvoidReport">撤销作废</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row" wx:if="{{rows.length && !focusReportId}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{productModalVisible}}" class="modal-mask product-select-mask" catchtap="closeProductModal">
|
||||||
|
<view class="modal-panel product-select-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<view>
|
||||||
|
<text class="modal-title">产品清单</text>
|
||||||
|
<text class="modal-subtitle">选择正确的产品、工序和冲压方式后替换当前报工明细</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="closeProductModal">关闭</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="input product-select-search"
|
||||||
|
value="{{productModalKeyword}}"
|
||||||
|
placeholder="搜索产品、物料、供应商、工序、冲压方式"
|
||||||
|
bindinput="onProductModalKeywordInput"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view wx:if="{{!productModalRows.length && !productModalLoading}}" class="list-empty">暂无可替换产品</view>
|
||||||
|
<view wx:for="{{productModalRows}}" wx:key="uniqueKey" wx:for-index="productIndex" class="card product-select-card">
|
||||||
|
<view class="row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.productName}}</text>
|
||||||
|
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle">{{item.projectNo}} · {{item.materialCode || '-'}}</text>
|
||||||
|
<text class="subtitle">工序 {{item.processDisplay || item.process || '-'}} · 冲压方式 {{item.stampingMethod || '-'}}</text>
|
||||||
|
<text class="subtitle">物料名称 {{item.materialName || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary">{{item.processDisplay || item.process || '工序'}}</text>
|
||||||
|
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark product-select-stamp">清洗</text>
|
||||||
|
<text wx:if="{{item.isMisc}}" class="misc-watermark product-select-stamp">杂活</text>
|
||||||
|
<text wx:if="{{item.isMultiPerson}}" class="multi-person-watermark product-select-stamp">多人协作</text>
|
||||||
|
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-watermark product-select-stamp">连续模</text>
|
||||||
|
</view>
|
||||||
|
<view class="divider"></view>
|
||||||
|
<view wx:if="{{!item.isCleaning && !item.isMisc}}" class="row">
|
||||||
|
<text class="muted">标准节拍 {{item.standardBeat}} 秒</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn primary full product-replace-btn" data-index="{{productIndex}}" bindtap="replaceProductFromModal">替换</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row product-select-pager" wx:if="{{productModalRows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="productModalPrevPage" disabled="{{productModalPage <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{productModalPageInput}}" bindinput="onProductModalPageInput" bindconfirm="jumpProductModalPage" />
|
||||||
|
<text class="page-total-label">/ {{productModalTotalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpProductModalPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="productModalNextPage" disabled="{{productModalPage >= productModalTotalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
487
pages/review/review.wxss
Normal file
487
pages/review/review.wxss
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
.review-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header-main {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-toggle {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 150rpx;
|
||||||
|
height: 62rpx;
|
||||||
|
line-height: 62rpx;
|
||||||
|
margin: 4rpx 0 0;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header-row .top-action-group {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240rpx minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header-row .void-filter-switch {
|
||||||
|
width: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-header-row .detail-toggle {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 68rpx;
|
||||||
|
line-height: 68rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-color: #aebbd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cleaning-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(18, 163, 117, 0.55);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(8, 116, 67, 0.72);
|
||||||
|
background: rgba(226, 255, 243, 0.82);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-watermark {
|
||||||
|
position: absolute;
|
||||||
|
right: 28rpx;
|
||||||
|
top: 18rpx;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.55);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.82);
|
||||||
|
background: rgba(255, 247, 232, 0.88);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card.expanded {
|
||||||
|
border-color: #1463ff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 13rpx 0 rgba(20, 86, 194, 0.48),
|
||||||
|
0 24rpx 36rpx rgba(20, 86, 194, 0.16),
|
||||||
|
inset 0 0 0 1rpx rgba(20, 99, 255, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx 18rpx;
|
||||||
|
margin: 12rpx 0 8rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border: 2rpx solid #c5cfde;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1rpx rgba(255, 255, 255, 0.86),
|
||||||
|
0 8rpx 0 rgba(177, 191, 211, 0.52),
|
||||||
|
0 14rpx 22rpx rgba(22, 34, 50, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-danger {
|
||||||
|
color: #c5221f;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-success {
|
||||||
|
color: #087443;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-edit-panel {
|
||||||
|
margin: 20rpx 0;
|
||||||
|
padding: 20rpx;
|
||||||
|
border: 1rpx solid #dfe7f2;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(20, 99, 255, 0.06), transparent 46%),
|
||||||
|
#f8fbff;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(207, 218, 233, 0.58),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-grid,
|
||||||
|
.review-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-form-grid .full-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-picker {
|
||||||
|
min-height: 76rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-inline {
|
||||||
|
margin: 6rpx 0 8rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border: 2rpx solid #c5cfde;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #f6faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-top: 1rpx solid rgba(174, 188, 208, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-pickers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-time-title {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list {
|
||||||
|
margin: 18rpx 0;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 2rpx solid #c4d0e2;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #eef4fb;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1rpx rgba(255, 255, 255, 0.72),
|
||||||
|
0 8rpx 0 rgba(174, 188, 208, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
padding: 24rpx 22rpx 24rpx 28rpx;
|
||||||
|
border: 3rpx solid #9fb0c8;
|
||||||
|
border-left: 9rpx solid #1463ff;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.86),
|
||||||
|
0 12rpx 0 rgba(133, 152, 179, 0.55),
|
||||||
|
0 20rpx 28rpx rgba(22, 34, 50, 0.13),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line:nth-child(even) {
|
||||||
|
border-left-color: #12a375;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #f5fffb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line.multi-detail-line,
|
||||||
|
.detail-line.marked-detail-line {
|
||||||
|
padding-top: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-line.stacked-detail-line {
|
||||||
|
padding-top: 112rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
color: #162232;
|
||||||
|
padding-right: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-multi-stamp {
|
||||||
|
position: absolute;
|
||||||
|
left: 24rpx;
|
||||||
|
top: 22rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-continuous-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 24rpx;
|
||||||
|
top: 22rpx;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 7rpx 14rpx;
|
||||||
|
border: 2rpx solid rgba(217, 119, 6, 0.62);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
color: rgba(154, 74, 5, 0.88);
|
||||||
|
background: rgba(255, 247, 232, 0.92);
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-continuous-stamp.lower-detail-stamp {
|
||||||
|
top: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-correction-row {
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-correction-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 22rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 10rpx;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-correction-actions .btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 76rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 27rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-correction-actions .btn.full {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-readonly {
|
||||||
|
min-height: 76rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 2rpx solid #dbe4f0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #162232;
|
||||||
|
background: linear-gradient(180deg, #f9fbff 0%, #eef4fb 100%);
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 76rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select-control {
|
||||||
|
min-height: 76rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 2rpx solid #dbe4f0;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #162232;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select-control.field-error {
|
||||||
|
border-color: #c5221f;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select-text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #1463ff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-action-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-action-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-remark-panel,
|
||||||
|
.misc-remark-panel {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-remark-input,
|
||||||
|
.misc-remark-input {
|
||||||
|
min-height: 150rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding-top: 18rpx;
|
||||||
|
padding-bottom: 18rpx;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-text {
|
||||||
|
display: block;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24rpx 22rpx calc(env(safe-area-inset-bottom) + 24rpx);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24rpx;
|
||||||
|
border: 2rpx solid #c7d2e2;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #6b778c;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-search {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-card .row-left {
|
||||||
|
padding-right: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-stamp {
|
||||||
|
right: 22rpx;
|
||||||
|
top: 72rpx;
|
||||||
|
font-size: 25rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-replace-btn {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-select-pager {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
193
pages/smartMonitor/smartMonitor.js
Normal file
193
pages/smartMonitor/smartMonitor.js
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
locks: [],
|
||||||
|
unreadRows: [],
|
||||||
|
readRows: [],
|
||||||
|
unreadPage: 1,
|
||||||
|
readPage: 1,
|
||||||
|
unreadPageInput: '1',
|
||||||
|
readPageInput: '1',
|
||||||
|
unreadTotal: 0,
|
||||||
|
readTotal: 0,
|
||||||
|
unreadTotalPages: 1,
|
||||||
|
readTotalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
refreshing: false,
|
||||||
|
detailVisible: false,
|
||||||
|
detailType: '',
|
||||||
|
selectedLock: null,
|
||||||
|
selectedFeedback: null,
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load().catch(error => {
|
||||||
|
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load()
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadLocks(),
|
||||||
|
this.loadFeedbacks('unread', this.data.unreadPage),
|
||||||
|
this.loadFeedbacks('read', this.data.readPage),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadLocks() {
|
||||||
|
const locks = await api.listMonitorLocks()
|
||||||
|
this.setData({ locks })
|
||||||
|
},
|
||||||
|
async loadFeedbacks(box, page = 1) {
|
||||||
|
const result = await api.listMoldLockFeedbacks({ box, page, pageSize: 10 })
|
||||||
|
if (box === 'read') {
|
||||||
|
this.setData({
|
||||||
|
readRows: result.rows || [],
|
||||||
|
readPage: result.page || 1,
|
||||||
|
readPageInput: String(result.page || 1),
|
||||||
|
readTotal: result.total || 0,
|
||||||
|
readTotalPages: result.totalPages || 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
unreadRows: result.rows || [],
|
||||||
|
unreadPage: result.page || 1,
|
||||||
|
unreadPageInput: String(result.page || 1),
|
||||||
|
unreadTotal: result.total || 0,
|
||||||
|
unreadTotalPages: result.totalPages || 1,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async changeFeedbackPage(e) {
|
||||||
|
const box = e.currentTarget.dataset.box
|
||||||
|
const direction = Number(e.currentTarget.dataset.direction || 0)
|
||||||
|
const pageKey = box === 'read' ? 'readPage' : 'unreadPage'
|
||||||
|
const totalPagesKey = box === 'read' ? 'readTotalPages' : 'unreadTotalPages'
|
||||||
|
const nextPage = Math.max(1, Math.min(this.data[totalPagesKey], this.data[pageKey] + direction))
|
||||||
|
await this.loadFeedbacks(box, nextPage)
|
||||||
|
},
|
||||||
|
onFeedbackPageInput(e) {
|
||||||
|
const box = e.currentTarget.dataset.box
|
||||||
|
const inputKey = box === 'read' ? 'readPageInput' : 'unreadPageInput'
|
||||||
|
this.setData({ [inputKey]: e.detail.value })
|
||||||
|
},
|
||||||
|
async jumpFeedbackPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const box = e.currentTarget.dataset.box
|
||||||
|
const pageKey = box === 'read' ? 'readPage' : 'unreadPage'
|
||||||
|
const inputKey = box === 'read' ? 'readPageInput' : 'unreadPageInput'
|
||||||
|
const totalPagesKey = box === 'read' ? 'readTotalPages' : 'unreadTotalPages'
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data[inputKey]
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data[pageKey], this.data[totalPagesKey]))
|
||||||
|
if (target === this.data[pageKey]) {
|
||||||
|
this.setData({ [inputKey]: String(this.data[pageKey] || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.loadFeedbacks(box, target)
|
||||||
|
},
|
||||||
|
openLock(e) {
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const selectedLock = this.data.locks[index]
|
||||||
|
if (!selectedLock) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
detailVisible: true,
|
||||||
|
detailType: 'lock',
|
||||||
|
selectedLock,
|
||||||
|
selectedFeedback: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async openFeedback(e) {
|
||||||
|
const box = e.currentTarget.dataset.box
|
||||||
|
const index = Number(e.currentTarget.dataset.index)
|
||||||
|
const rows = box === 'read' ? this.data.readRows : this.data.unreadRows
|
||||||
|
const feedback = rows[index]
|
||||||
|
if (!feedback) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '加载中' })
|
||||||
|
try {
|
||||||
|
const selectedFeedback = box === 'unread'
|
||||||
|
? await api.readMoldLockFeedback(feedback.id)
|
||||||
|
: feedback
|
||||||
|
this.setData({
|
||||||
|
detailVisible: true,
|
||||||
|
detailType: 'feedback',
|
||||||
|
selectedLock: null,
|
||||||
|
selectedFeedback,
|
||||||
|
})
|
||||||
|
if (box === 'unread') {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadFeedbacks('unread', this.data.unreadPage),
|
||||||
|
this.loadFeedbacks('read', 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeDetail() {
|
||||||
|
this.setData({
|
||||||
|
detailVisible: false,
|
||||||
|
detailType: '',
|
||||||
|
selectedLock: null,
|
||||||
|
selectedFeedback: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
async releaseFromLock() {
|
||||||
|
const lock = this.data.selectedLock
|
||||||
|
if (!lock) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '解除中' })
|
||||||
|
try {
|
||||||
|
await api.releaseMoldLock(lock.id)
|
||||||
|
wx.showToast({ title: '已解除占用', icon: 'success' })
|
||||||
|
this.closeDetail()
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '解除失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async releaseFromFeedback() {
|
||||||
|
const feedback = this.data.selectedFeedback
|
||||||
|
if (!feedback || !feedback.canRelease) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showLoading({ title: '解除中' })
|
||||||
|
try {
|
||||||
|
const selectedFeedback = await api.releaseMoldLockByFeedback(feedback.id)
|
||||||
|
wx.showToast({ title: '已解除占用', icon: 'success' })
|
||||||
|
this.setData({ selectedFeedback })
|
||||||
|
await Promise.all([
|
||||||
|
this.loadLocks(),
|
||||||
|
this.loadFeedbacks('unread', this.data.unreadPage),
|
||||||
|
this.loadFeedbacks('read', this.data.readPage),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '解除失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
4
pages/smartMonitor/smartMonitor.json
Normal file
4
pages/smartMonitor/smartMonitor.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "智能监控",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
165
pages/smartMonitor/smartMonitor.wxml
Normal file
165
pages/smartMonitor/smartMonitor.wxml
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">智能监控</text>
|
||||||
|
<text class="subtitle">查看当前模具占用和员工反馈</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-title">反馈信箱</view>
|
||||||
|
<view class="mail-grid">
|
||||||
|
<view class="mail-card">
|
||||||
|
<view class="mail-head">
|
||||||
|
<view class="mail-title">
|
||||||
|
<text class="mail-icon unread">未</text>
|
||||||
|
<text class="value">未读信箱</text>
|
||||||
|
</view>
|
||||||
|
<text class="mail-badge">{{unreadTotal}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!unreadRows.length}}" class="mail-empty">暂无未读反馈</view>
|
||||||
|
<view
|
||||||
|
wx:for="{{unreadRows}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-index="index"
|
||||||
|
class="feedback-row unread-row"
|
||||||
|
data-box="unread"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="openFeedback"
|
||||||
|
>
|
||||||
|
<text class="feedback-title">{{item.moldDisplayName}}</text>
|
||||||
|
<text class="feedback-sub">{{item.reporterName || item.reporterPhone}} 反馈 {{item.occupiedName || item.occupiedPhone}}</text>
|
||||||
|
<text class="feedback-time">{{item.feedbackAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pager">
|
||||||
|
<button class="btn mini secondary" data-box="unread" data-direction="-1" bindtap="changeFeedbackPage" disabled="{{unreadPage <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control mini">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{unreadPageInput}}" data-box="unread" bindinput="onFeedbackPageInput" bindconfirm="jumpFeedbackPage" />
|
||||||
|
<text class="page-total-label">/ {{unreadTotalPages}}</text>
|
||||||
|
<text class="page-jump-action" data-box="unread" bindtap="jumpFeedbackPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary" data-box="unread" data-direction="1" bindtap="changeFeedbackPage" disabled="{{unreadPage >= unreadTotalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mail-card">
|
||||||
|
<view class="mail-head">
|
||||||
|
<view class="mail-title">
|
||||||
|
<text class="mail-icon read">读</text>
|
||||||
|
<text class="value">已读信箱</text>
|
||||||
|
</view>
|
||||||
|
<text class="mail-badge muted-badge">{{readTotal}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!readRows.length}}" class="mail-empty">暂无已读反馈</view>
|
||||||
|
<view
|
||||||
|
wx:for="{{readRows}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-index="index"
|
||||||
|
class="feedback-row"
|
||||||
|
data-box="read"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="openFeedback"
|
||||||
|
>
|
||||||
|
<view class="feedback-title-line">
|
||||||
|
<text class="feedback-title">{{item.moldDisplayName}}</text>
|
||||||
|
<text class="status-text {{item.isHandled ? 'handled' : 'pending'}}">{{item.statusName}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="feedback-sub">{{item.reporterName || item.reporterPhone}} 反馈 {{item.occupiedName || item.occupiedPhone}}</text>
|
||||||
|
<text class="feedback-time">{{item.readAtText || item.feedbackAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pager">
|
||||||
|
<button class="btn mini secondary" data-box="read" data-direction="-1" bindtap="changeFeedbackPage" disabled="{{readPage <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control mini">
|
||||||
|
<input class="page-jump-input" type="number" confirm-type="done" value="{{readPageInput}}" data-box="read" bindinput="onFeedbackPageInput" bindconfirm="jumpFeedbackPage" />
|
||||||
|
<text class="page-total-label">/ {{readTotalPages}}</text>
|
||||||
|
<text class="page-jump-action" data-box="read" bindtap="jumpFeedbackPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn mini secondary" data-box="read" data-direction="1" bindtap="changeFeedbackPage" disabled="{{readPage >= readTotalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-title">生产监控</view>
|
||||||
|
<view class="card monitor-card">
|
||||||
|
<view class="monitor-head">
|
||||||
|
<view>
|
||||||
|
<text class="value">当前占用模具</text>
|
||||||
|
<text class="subtitle">只显示已经被员工打卡上班占用的模具</text>
|
||||||
|
</view>
|
||||||
|
<text class="monitor-mark">监控</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{!locks.length}}" class="list-empty inline-empty">暂无占用中的模具</view>
|
||||||
|
<view wx:else class="mold-grid">
|
||||||
|
<view
|
||||||
|
wx:for="{{locks}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-index="index"
|
||||||
|
class="mold-tile"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="openLock"
|
||||||
|
>
|
||||||
|
<view class="mold-icon">模</view>
|
||||||
|
<text class="mold-name">{{item.moldDisplayName}}</text>
|
||||||
|
<text class="mold-worker">{{item.occupiedName || item.occupiedPhone}}</text>
|
||||||
|
<text class="mold-duration">{{item.occupiedDurationText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view wx:if="{{detailVisible}}" class="modal-mask" catchtap="closeDetail">
|
||||||
|
<view class="modal-panel" catchtap="noop">
|
||||||
|
<view class="modal-head">
|
||||||
|
<text class="modal-title">{{detailType === 'lock' ? '占用详情' : '反馈详情'}}</text>
|
||||||
|
<button class="btn mini secondary modal-close" bindtap="closeDetail">关闭</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{detailType === 'lock' && selectedLock}}">
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">模具名</text>
|
||||||
|
<text class="value">{{selectedLock.moldDisplayName}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">占用人员</text>
|
||||||
|
<text class="value">{{selectedLock.occupiedName || selectedLock.occupiedPhone}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">本次占用开始时间</text>
|
||||||
|
<text class="value">{{selectedLock.occupiedStartAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">已占用时长</text>
|
||||||
|
<text class="value">{{selectedLock.occupiedDurationText}}</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn danger full detail-action" bindtap="releaseFromLock">强制解除占用</button>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block wx:if="{{detailType === 'feedback' && selectedFeedback}}">
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">反馈人员姓名</text>
|
||||||
|
<text class="value">{{selectedFeedback.reporterName || selectedFeedback.reporterPhone}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">占用人员姓名</text>
|
||||||
|
<text class="value">{{selectedFeedback.occupiedName || selectedFeedback.occupiedPhone}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">模具名</text>
|
||||||
|
<text class="value">{{selectedFeedback.moldDisplayName}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">反馈时间</text>
|
||||||
|
<text class="value">{{selectedFeedback.feedbackAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="label">处理状态</text>
|
||||||
|
<text class="status-text detail-status {{selectedFeedback.isHandled ? 'handled' : 'pending'}}">{{selectedFeedback.statusName}}</text>
|
||||||
|
</view>
|
||||||
|
<button wx:if="{{selectedFeedback.canRelease}}" class="btn danger full detail-action" bindtap="releaseFromFeedback">一键解除占用</button>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
310
pages/smartMonitor/smartMonitor.wxss
Normal file
310
pages/smartMonitor/smartMonitor.wxss
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
.mail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22rpx;
|
||||||
|
border: 3rpx solid #aebbd0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2rpx rgba(255, 255, 255, 0.9),
|
||||||
|
0 11rpx 0 rgba(147, 163, 185, 0.68),
|
||||||
|
0 20rpx 32rpx rgba(22, 34, 50, 0.15),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-head,
|
||||||
|
.mail-title,
|
||||||
|
.feedback-title-line,
|
||||||
|
.monitor-head,
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-head,
|
||||||
|
.monitor-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-title {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
flex: 0 0 46rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow:
|
||||||
|
0 6rpx 0 rgba(15, 23, 42, 0.16),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-icon.unread {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #b91c1c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-icon.read {
|
||||||
|
background: linear-gradient(180deg, #64748b 0%, #334155 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 42rpx;
|
||||||
|
height: 42rpx;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-badge {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-empty {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 22rpx 12rpx;
|
||||||
|
border: 2rpx dashed #b8c4d4;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-row {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 2rpx solid #c7d2e3;
|
||||||
|
border-left: 7rpx solid #64748b;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #f8fbff;
|
||||||
|
box-shadow: 0 7rpx 0 rgba(148, 163, 184, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-row {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-title {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-title-line {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-sub,
|
||||||
|
.feedback-time,
|
||||||
|
.mold-worker,
|
||||||
|
.mold-duration {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.pending {
|
||||||
|
color: #d92d20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.handled {
|
||||||
|
color: #087443;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .btn {
|
||||||
|
min-width: 82rpx;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .page-jump-control.mini {
|
||||||
|
flex-basis: 152rpx;
|
||||||
|
gap: 4rpx;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .page-jump-control.mini .page-jump-input {
|
||||||
|
flex-basis: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .page-jump-control.mini .page-total-label {
|
||||||
|
min-width: 50rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .page-jump-control.mini .page-jump-action {
|
||||||
|
width: 28rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-card {
|
||||||
|
margin-bottom: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-mark {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 10rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #e9f1ff;
|
||||||
|
color: #1456c2;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-empty {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150rpx, 1fr));
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-tile {
|
||||||
|
min-height: 210rpx;
|
||||||
|
padding: 18rpx 14rpx;
|
||||||
|
border: 2rpx solid #bcc8d9;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #eef5ff 100%);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(148, 163, 184, 0.54),
|
||||||
|
0 16rpx 26rpx rgba(22, 34, 50, 0.12),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
margin: 0 auto 14rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #1d4ed8 0%, #153e9f 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow:
|
||||||
|
0 7rpx 0 rgba(21, 62, 159, 0.42),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mold-name {
|
||||||
|
display: block;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30rpx;
|
||||||
|
border: 1rpx solid #e4e9f1;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 16rpx 0 rgba(170, 184, 204, 0.62),
|
||||||
|
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding-bottom: 18rpx;
|
||||||
|
border-bottom: 1rpx solid #e4e9f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-status {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-action {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
253
pages/usageStats/usageStats.js
Normal file
253
pages/usageStats/usageStats.js
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
let usageStatsLoadSeq = 0
|
||||||
|
let keywordSearchTimer = null
|
||||||
|
|
||||||
|
const pad2 = value => String(value).padStart(2, '0')
|
||||||
|
|
||||||
|
const formatDate = date => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
||||||
|
|
||||||
|
const defaultDateRange = () => {
|
||||||
|
const today = new Date()
|
||||||
|
return {
|
||||||
|
startDate: `${today.getFullYear()}-${pad2(today.getMonth() + 1)}-01`,
|
||||||
|
endDate: formatDate(today),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricLabel = metricKind => (metricKind === 'quantity' ? '数量' : '时长')
|
||||||
|
|
||||||
|
const valueLabel = row => {
|
||||||
|
if (row.metricKind === 'quantity') {
|
||||||
|
return `${Number(row.value || 0)} 件`
|
||||||
|
}
|
||||||
|
return api.formatDurationAllUnits(row.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDocumentFile = filePath => new Promise((resolve, reject) => {
|
||||||
|
wx.openDocument({
|
||||||
|
filePath,
|
||||||
|
fileType: 'xlsx',
|
||||||
|
showMenu: true,
|
||||||
|
success: resolve,
|
||||||
|
fail: () => reject(new Error('打开文件失败')),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
...defaultDateRange(),
|
||||||
|
category: 'device',
|
||||||
|
sortBy: 'value',
|
||||||
|
attendancePoints: [],
|
||||||
|
attendancePointLabels: ['全部考勤点'],
|
||||||
|
attendancePointIndex: 0,
|
||||||
|
attendancePointName: '',
|
||||||
|
keyword: '',
|
||||||
|
rows: [],
|
||||||
|
page: 1,
|
||||||
|
pageInput: '1',
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
exporting: false,
|
||||||
|
refreshing: false,
|
||||||
|
scrollTop: 0,
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
if (keywordSearchTimer) {
|
||||||
|
clearTimeout(keywordSearchTimer)
|
||||||
|
keywordSearchTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
await this.loadAttendancePoints()
|
||||||
|
await this.load(1)
|
||||||
|
},
|
||||||
|
async loadAttendancePoints() {
|
||||||
|
try {
|
||||||
|
const points = await api.listAccessibleAttendancePoints()
|
||||||
|
const attendancePoints = points || []
|
||||||
|
this.setData({
|
||||||
|
attendancePoints,
|
||||||
|
attendancePointLabels: ['全部考勤点'].concat(attendancePoints.map(item => item.name)),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async load(page = this.data.page) {
|
||||||
|
const loadSeq = usageStatsLoadSeq + 1
|
||||||
|
usageStatsLoadSeq = loadSeq
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const result = await api.listUsageStats({
|
||||||
|
category: this.data.category,
|
||||||
|
sortBy: this.data.sortBy,
|
||||||
|
startDate: this.data.startDate,
|
||||||
|
endDate: this.data.endDate,
|
||||||
|
attendancePointName: this.data.attendancePointName,
|
||||||
|
keyword: this.data.keyword,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
if (loadSeq !== usageStatsLoadSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rows = result.rows || []
|
||||||
|
const maxValue = Math.max(1, ...rows.map(item => Number(item.value || 0)))
|
||||||
|
this.setData({
|
||||||
|
rows: rows.map(item => ({
|
||||||
|
...item,
|
||||||
|
metricLabel: metricLabel(item.metricKind),
|
||||||
|
valueText: valueLabel(item),
|
||||||
|
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxValue * 100)),
|
||||||
|
uniqueKey: `${item.category || ''}-${item.id || ''}-${item.metricKind || ''}-${item.name || ''}`,
|
||||||
|
})),
|
||||||
|
page: result.page || 1,
|
||||||
|
pageInput: String(result.page || 1),
|
||||||
|
totalPages: result.totalPages || 1,
|
||||||
|
scrollTop: 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (loadSeq === usageStatsLoadSeq) {
|
||||||
|
wx.showToast({ title: error.message || '加载统计失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadSeq === usageStatsLoadSeq) {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load(1)
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chooseCategory(e) {
|
||||||
|
const category = e.currentTarget.dataset.category
|
||||||
|
if (category === this.data.category) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ category, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
chooseSort(e) {
|
||||||
|
const sortBy = e.currentTarget.dataset.sort
|
||||||
|
if (sortBy === this.data.sortBy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ sortBy, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onStartChange(e) {
|
||||||
|
this.setData({ startDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onEndChange(e) {
|
||||||
|
this.setData({ endDate: e.detail.value, page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onAttendancePointChange(e) {
|
||||||
|
const attendancePointIndex = Number(e.detail.value)
|
||||||
|
const point = attendancePointIndex > 0 ? this.data.attendancePoints[attendancePointIndex - 1] : null
|
||||||
|
this.setData({
|
||||||
|
attendancePointIndex,
|
||||||
|
attendancePointName: point ? point.name : '',
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
onKeywordInput(e) {
|
||||||
|
this.setData({ keyword: e.detail.value, page: 1 })
|
||||||
|
if (keywordSearchTimer) {
|
||||||
|
clearTimeout(keywordSearchTimer)
|
||||||
|
}
|
||||||
|
keywordSearchTimer = setTimeout(() => {
|
||||||
|
keywordSearchTimer = null
|
||||||
|
this.load(1)
|
||||||
|
}, 350)
|
||||||
|
},
|
||||||
|
searchKeyword() {
|
||||||
|
if (keywordSearchTimer) {
|
||||||
|
clearTimeout(keywordSearchTimer)
|
||||||
|
keywordSearchTimer = null
|
||||||
|
}
|
||||||
|
this.setData({ page: 1 })
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
openDetail(e) {
|
||||||
|
const row = this.data.rows[Number(e.currentTarget.dataset.index)]
|
||||||
|
if (!row) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const attendancePointName = this.data.attendancePointName || row.attendancePointName || ''
|
||||||
|
const params = [
|
||||||
|
['category', row.category || this.data.category],
|
||||||
|
['id', row.id || ''],
|
||||||
|
['metricKind', row.metricKind || ''],
|
||||||
|
['startDate', this.data.startDate],
|
||||||
|
['endDate', this.data.endDate],
|
||||||
|
['attendancePointName', attendancePointName],
|
||||||
|
['name', row.name || ''],
|
||||||
|
['productName', row.productName || ''],
|
||||||
|
['processName', row.processName || ''],
|
||||||
|
['stampingMethod', row.stampingMethod || ''],
|
||||||
|
].map(pair => `${pair[0]}=${encodeURIComponent(pair[1])}`).join('&')
|
||||||
|
wx.navigateTo({ url: `/pages/usageStatsDetail/usageStatsDetail?${params}` })
|
||||||
|
},
|
||||||
|
async exportFile() {
|
||||||
|
if (this.data.exporting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ exporting: true })
|
||||||
|
wx.showLoading({ title: '导出中' })
|
||||||
|
let filePath = ''
|
||||||
|
try {
|
||||||
|
filePath = await api.exportUsageStats({
|
||||||
|
startDate: this.data.startDate,
|
||||||
|
endDate: this.data.endDate,
|
||||||
|
attendancePointName: this.data.attendancePointName,
|
||||||
|
keyword: this.data.keyword,
|
||||||
|
})
|
||||||
|
await openDocumentFile(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || (filePath ? '打开文件失败' : '导出失败'), icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.setData({ exporting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageInput(e) {
|
||||||
|
this.setData({ pageInput: e.detail.value })
|
||||||
|
},
|
||||||
|
jumpToPage(e) {
|
||||||
|
wx.hideKeyboard()
|
||||||
|
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
|
||||||
|
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
|
||||||
|
if (target === this.data.page) {
|
||||||
|
this.setData({ pageInput: String(this.data.page || 1) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.load(target)
|
||||||
|
},
|
||||||
|
prevPage() {
|
||||||
|
if (this.data.page > 1) {
|
||||||
|
this.load(this.data.page - 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nextPage() {
|
||||||
|
if (this.data.page < this.data.totalPages) {
|
||||||
|
this.load(this.data.page + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/usageStats/usageStats.json
Normal file
3
pages/usageStats/usageStats.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "设备模具使用统计"
|
||||||
|
}
|
||||||
105
pages/usageStats/usageStats.wxml
Normal file
105
pages/usageStats/usageStats.wxml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
scroll-top="{{scrollTop}}"
|
||||||
|
scroll-with-animation
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">设备模具使用统计</text>
|
||||||
|
<text class="subtitle">默认统计审核通过且未作废数据</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card filter-card">
|
||||||
|
<view class="date-filter-row">
|
||||||
|
<view class="date-filter-item">
|
||||||
|
<text class="label">开始日期</text>
|
||||||
|
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
|
||||||
|
<view class="picker">{{startDate || '开始日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="date-filter-item">
|
||||||
|
<text class="label">结束日期</text>
|
||||||
|
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
|
||||||
|
<view class="picker">{{endDate || '结束日期'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="filter-section">
|
||||||
|
<text class="label">考勤点</text>
|
||||||
|
<picker range="{{attendancePointLabels}}" value="{{attendancePointIndex}}" bindchange="onAttendancePointChange">
|
||||||
|
<view class="picker ellipsis">{{attendancePointLabels[attendancePointIndex] || '全部考勤点'}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="segmented-row">
|
||||||
|
<button class="segment-btn {{category === 'device' ? 'active' : ''}}" data-category="device" bindtap="chooseCategory">设备</button>
|
||||||
|
<button class="segment-btn {{category === 'mold' ? 'active' : ''}}" data-category="mold" bindtap="chooseCategory">模具</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="segmented-row">
|
||||||
|
<button class="segment-btn {{sortBy === 'value' ? 'active' : ''}}" data-sort="value" bindtap="chooseSort">按时长/数量</button>
|
||||||
|
<button class="segment-btn {{sortBy === 'count' ? 'active' : ''}}" data-sort="count" bindtap="chooseSort">按报工次数</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="btn primary full export-btn" loading="{{exporting}}" bindtap="exportFile">导出统计</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="input search"
|
||||||
|
value="{{keyword}}"
|
||||||
|
placeholder="搜索考勤点、设备、模具、产品、工序、冲压方式"
|
||||||
|
confirm-type="search"
|
||||||
|
bindinput="onKeywordInput"
|
||||||
|
bindconfirm="searchKeyword"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view wx:if="{{loading && !rows.length}}" class="list-empty">加载中</view>
|
||||||
|
<view wx:elif="{{!rows.length}}" class="list-empty">暂无统计数据</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
wx:for="{{rows}}"
|
||||||
|
wx:key="uniqueKey"
|
||||||
|
wx:for-index="index"
|
||||||
|
class="card usage-card"
|
||||||
|
data-index="{{index}}"
|
||||||
|
bindtap="openDetail"
|
||||||
|
>
|
||||||
|
<view class="row usage-head">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value usage-name">{{item.name || '-'}}</text>
|
||||||
|
<text class="subtitle ellipsis">考勤点 {{item.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle ellipsis">{{item.objectType || '-'}} · 报工 {{item.reportCount || 0}} 次</text>
|
||||||
|
</view>
|
||||||
|
<text class="pill primary">{{item.metricLabel}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="usage-bar-track">
|
||||||
|
<view class="usage-bar-fill {{item.metricKind === 'quantity' ? 'quantity' : 'duration'}}" style="width: {{item.barPercent}}%;"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row usage-value-row">
|
||||||
|
<text class="label">{{item.metricKind === 'quantity' ? '统计数量' : '使用时长'}}</text>
|
||||||
|
<text class="value usage-value">{{item.valueText}}</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.productName || item.processName || item.stampingMethod}}" class="usage-tags">
|
||||||
|
<text wx:if="{{item.productName}}" class="usage-tag">{{item.productName}}</text>
|
||||||
|
<text wx:if="{{item.processName}}" class="usage-tag">{{item.processName}}</text>
|
||||||
|
<text wx:if="{{item.stampingMethod}}" class="usage-tag">{{item.stampingMethod}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="button-row pager-row usage-pager" wx:if="{{rows.length}}">
|
||||||
|
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
|
||||||
|
<view class="page-jump-control usage-page-jump">
|
||||||
|
<input class="page-jump-input usage-page-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
|
||||||
|
<text class="page-total-label usage-total-label">/ {{totalPages}}</text>
|
||||||
|
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
150
pages/usageStats/usageStats.wxss
Normal file
150
pages/usageStats/usageStats.wxss
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
.filter-card {
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-item {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 72rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border: 1rpx solid #cbd7e6;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: #f7faff;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-btn.active {
|
||||||
|
border-color: #1463ff;
|
||||||
|
background: #eaf2ff;
|
||||||
|
color: #1456c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-card {
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-card:active {
|
||||||
|
transform: translateY(3rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-track {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 18rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e8eef6;
|
||||||
|
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(90deg, #1463ff 0%, #5b8def 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill.quantity {
|
||||||
|
background: linear-gradient(90deg, #087443 0%, #4ab37c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-value-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-value {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 7rpx 12rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-pager {
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-page-jump {
|
||||||
|
flex-basis: 236rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-page-input {
|
||||||
|
flex-basis: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-total-label {
|
||||||
|
min-width: 86rpx;
|
||||||
|
}
|
||||||
101
pages/usageStatsDetail/usageStatsDetail.js
Normal file
101
pages/usageStatsDetail/usageStatsDetail.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const decodeOption = value => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(String(value))
|
||||||
|
} catch (error) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueText = (value, metricKind) => {
|
||||||
|
const numericValue = Number(value || 0)
|
||||||
|
if (metricKind === 'quantity') {
|
||||||
|
return `${numericValue} 件`
|
||||||
|
}
|
||||||
|
return api.formatDurationAllUnits(numericValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricTitle = metricKind => (metricKind === 'quantity' ? '清洗数量' : '总使用时长')
|
||||||
|
|
||||||
|
const normalizeFilters = options => ({
|
||||||
|
category: decodeOption(options.category),
|
||||||
|
id: decodeOption(options.id),
|
||||||
|
metricKind: decodeOption(options.metricKind),
|
||||||
|
startDate: decodeOption(options.startDate),
|
||||||
|
endDate: decodeOption(options.endDate),
|
||||||
|
attendancePointName: decodeOption(options.attendancePointName),
|
||||||
|
name: decodeOption(options.name),
|
||||||
|
productName: decodeOption(options.productName),
|
||||||
|
processName: decodeOption(options.processName),
|
||||||
|
stampingMethod: decodeOption(options.stampingMethod),
|
||||||
|
})
|
||||||
|
|
||||||
|
const decorateDetail = detail => {
|
||||||
|
const metricKind = detail.metricKind || ''
|
||||||
|
const dailyRows = detail.dailyRows || []
|
||||||
|
const maxDailyValue = Math.max(1, ...dailyRows.map(item => Number(item.value || 0)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
metricTitle: metricTitle(metricKind),
|
||||||
|
valueText: valueText(detail.value, metricKind),
|
||||||
|
dailyRows: dailyRows.map(item => ({
|
||||||
|
...item,
|
||||||
|
valueText: valueText(item.value, metricKind),
|
||||||
|
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxDailyValue * 100)),
|
||||||
|
})),
|
||||||
|
reportRows: (detail.reportRows || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
valueText: valueText(item.value, metricKind),
|
||||||
|
employeeText: item.employeeName || item.employeePhone || '-',
|
||||||
|
})),
|
||||||
|
tags: Array.isArray(detail.tags) ? detail.tags : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
filters: {},
|
||||||
|
detail: null,
|
||||||
|
loading: false,
|
||||||
|
loadError: false,
|
||||||
|
errorText: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const filters = normalizeFilters(options || {})
|
||||||
|
this.setData({ filters })
|
||||||
|
this.load(filters)
|
||||||
|
},
|
||||||
|
|
||||||
|
async load(filters = this.data.filters) {
|
||||||
|
const loadSeq = (this.loadSeq || 0) + 1
|
||||||
|
this.loadSeq = loadSeq
|
||||||
|
this.setData({ loading: true, loadError: false, errorText: '' })
|
||||||
|
try {
|
||||||
|
const detail = await api.getUsageStatsDetail(filters)
|
||||||
|
if (loadSeq !== this.loadSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ detail: decorateDetail(detail || {}), loadError: false, errorText: '' })
|
||||||
|
} catch (error) {
|
||||||
|
if (loadSeq === this.loadSeq) {
|
||||||
|
const errorText = error.message || '加载详情失败'
|
||||||
|
this.setData({ detail: null, loadError: true, errorText })
|
||||||
|
wx.showToast({ title: errorText, icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadSeq === this.loadSeq) {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/usageStatsDetail/usageStatsDetail.json
Normal file
3
pages/usageStatsDetail/usageStatsDetail.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "统计详情"
|
||||||
|
}
|
||||||
70
pages/usageStatsDetail/usageStatsDetail.wxml
Normal file
70
pages/usageStatsDetail/usageStatsDetail.wxml
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<scroll-view class="page safe-bottom" scroll-y type="list">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title ellipsis">{{detail.name || filters.name || '-'}}</text>
|
||||||
|
<text class="subtitle ellipsis">考勤点 {{detail.attendancePointName || filters.attendancePointName || '-'}}</text>
|
||||||
|
<text class="subtitle ellipsis">{{detail.objectType || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loading && !detail && !loadError}}" class="list-empty">加载中</view>
|
||||||
|
|
||||||
|
<view wx:elif="{{loadError}}" class="card error-card">
|
||||||
|
<text class="value">加载失败</text>
|
||||||
|
<text class="subtitle error-text">{{errorText || '加载详情失败'}}</text>
|
||||||
|
<button class="btn secondary reload-btn" loading="{{loading}}" bindtap="reload">重新加载</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:elif="{{detail}}">
|
||||||
|
<view class="card metric-card">
|
||||||
|
<view class="metric-grid">
|
||||||
|
<view class="metric-item">
|
||||||
|
<text class="label">{{detail.metricTitle}}</text>
|
||||||
|
<text class="value metric-value">{{detail.valueText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="metric-item">
|
||||||
|
<text class="label">报工次数</text>
|
||||||
|
<text class="value metric-value">{{detail.reportCount || 0}} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{detail.productName || detail.processName || detail.stampingMethod || detail.tags.length}}" class="tag-list">
|
||||||
|
<text wx:if="{{detail.productName}}" class="usage-tag">{{detail.productName}}</text>
|
||||||
|
<text wx:if="{{detail.processName}}" class="usage-tag">{{detail.processName}}</text>
|
||||||
|
<text wx:if="{{detail.stampingMethod}}" class="usage-tag">{{detail.stampingMethod}}</text>
|
||||||
|
<text wx:for="{{detail.tags}}" wx:key="*this" class="usage-tag">{{item}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="section-title">每日趋势</text>
|
||||||
|
<view wx:if="{{!detail.dailyRows.length}}" class="list-empty">暂无每日趋势</view>
|
||||||
|
<view wx:for="{{detail.dailyRows}}" wx:key="reportDate" class="card trend-card">
|
||||||
|
<view class="row trend-row">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value">{{item.reportDate || '-'}}</text>
|
||||||
|
<text class="subtitle">报工 {{item.reportCount || 0}} 次</text>
|
||||||
|
</view>
|
||||||
|
<text class="value trend-value">{{item.valueText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="usage-bar-track">
|
||||||
|
<view class="usage-bar-fill {{detail.metricKind === 'quantity' ? 'quantity' : ''}}" style="width: {{item.barPercent}}%;"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="section-title">关联报工</text>
|
||||||
|
<view wx:if="{{!detail.reportRows.length}}" class="list-empty">暂无关联报工</view>
|
||||||
|
<view wx:for="{{detail.reportRows}}" wx:key="reportId" class="card report-card">
|
||||||
|
<view class="row report-head">
|
||||||
|
<view class="row-left">
|
||||||
|
<text class="value ellipsis">{{item.employeeText}}</text>
|
||||||
|
<text class="subtitle">{{item.reportDate || '-'}}</text>
|
||||||
|
</view>
|
||||||
|
<text class="value report-value">{{item.valueText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="report-meta">
|
||||||
|
<text class="subtitle ellipsis">{{item.displayName || '-'}}</text>
|
||||||
|
<text class="subtitle report-count">报工 {{item.reportCount || 0}} 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view wx:else class="list-empty">暂无统计详情</view>
|
||||||
|
</scroll-view>
|
||||||
125
pages/usageStatsDetail/usageStatsDetail.wxss
Normal file
125
pages/usageStatsDetail/usageStatsDetail.wxss
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f5f8fc;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 7rpx 12rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-card,
|
||||||
|
.report-card {
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-row,
|
||||||
|
.report-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-value,
|
||||||
|
.report-value {
|
||||||
|
flex: 1 1 260rpx;
|
||||||
|
min-width: 240rpx;
|
||||||
|
max-width: 380rpx;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-track {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 18rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e8eef6;
|
||||||
|
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: linear-gradient(90deg, #1463ff 0%, #5b8def 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-bar-fill.quantity {
|
||||||
|
background: linear-gradient(90deg, #087443 0%, #4ab37c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta .subtitle {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
479
pages/workSchedule/workSchedule.js
Normal file
479
pages/workSchedule/workSchedule.js
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
const api = require('../../utils/api')
|
||||||
|
|
||||||
|
const DEFAULT_POINT_NAME = '宁波嘉恒智能科技有限公司'
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
dayStart: '08:00',
|
||||||
|
dayEnd: '17:20',
|
||||||
|
lunchStart: '11:40',
|
||||||
|
lunchEnd: '12:40',
|
||||||
|
dinnerStart: '17:20',
|
||||||
|
dinnerEnd: '18:00',
|
||||||
|
overtimeStart: '18:00',
|
||||||
|
overtimeEnd: '20:00',
|
||||||
|
nightStart: '20:00',
|
||||||
|
nightEnd: '06:00',
|
||||||
|
attendanceLatitude: '',
|
||||||
|
attendanceLongitude: '',
|
||||||
|
attendanceRadiusMeters: 500,
|
||||||
|
autoSubmitHours: 15,
|
||||||
|
attendancePoints: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleFields = [
|
||||||
|
'dayStart',
|
||||||
|
'dayEnd',
|
||||||
|
'lunchStart',
|
||||||
|
'lunchEnd',
|
||||||
|
'dinnerStart',
|
||||||
|
'dinnerEnd',
|
||||||
|
'overtimeStart',
|
||||||
|
'overtimeEnd',
|
||||||
|
'nightStart',
|
||||||
|
'nightEnd',
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultScheduleValues = () => scheduleFields.reduce((result, field) => ({
|
||||||
|
...result,
|
||||||
|
[field]: defaultForm[field],
|
||||||
|
}), {})
|
||||||
|
|
||||||
|
const emptyPoint = () => ({
|
||||||
|
localKey: `new_${Date.now()}`,
|
||||||
|
name: '',
|
||||||
|
originalName: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
radiusMeters: 500,
|
||||||
|
...defaultScheduleValues(),
|
||||||
|
remark: '',
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
form: { ...defaultForm },
|
||||||
|
updatedAtText: '',
|
||||||
|
refreshing: false,
|
||||||
|
saving: false,
|
||||||
|
locating: false,
|
||||||
|
locationAccuracyText: '',
|
||||||
|
expandedPointKey: '',
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const schedule = await api.getWorkSchedule()
|
||||||
|
this.setData({
|
||||||
|
form: this.buildForm(schedule),
|
||||||
|
updatedAtText: schedule.updatedAtText || '',
|
||||||
|
expandedPointKey: '',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onPullDownRefresh() {
|
||||||
|
this.setData({ refreshing: true })
|
||||||
|
try {
|
||||||
|
await this.load()
|
||||||
|
} finally {
|
||||||
|
this.setData({ refreshing: false })
|
||||||
|
if (wx.stopPullDownRefresh) {
|
||||||
|
wx.stopPullDownRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTimeChange(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
const pointIndex = e.currentTarget.dataset.index
|
||||||
|
if (pointIndex !== undefined && pointIndex !== '') {
|
||||||
|
this.setData({
|
||||||
|
[`form.attendancePoints[${pointIndex}].${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ [`form.${field}`]: e.detail.value })
|
||||||
|
},
|
||||||
|
buildForm(schedule = {}) {
|
||||||
|
return {
|
||||||
|
dayStart: schedule.dayStart || defaultForm.dayStart,
|
||||||
|
dayEnd: schedule.dayEnd || defaultForm.dayEnd,
|
||||||
|
lunchStart: schedule.lunchStart || defaultForm.lunchStart,
|
||||||
|
lunchEnd: schedule.lunchEnd || defaultForm.lunchEnd,
|
||||||
|
dinnerStart: schedule.dinnerStart || defaultForm.dinnerStart,
|
||||||
|
dinnerEnd: schedule.dinnerEnd || defaultForm.dinnerEnd,
|
||||||
|
overtimeStart: schedule.overtimeStart || defaultForm.overtimeStart,
|
||||||
|
overtimeEnd: schedule.overtimeEnd || defaultForm.overtimeEnd,
|
||||||
|
nightStart: schedule.nightStart || defaultForm.nightStart,
|
||||||
|
nightEnd: schedule.nightEnd || defaultForm.nightEnd,
|
||||||
|
attendanceLatitude: schedule.attendanceLatitude === '' || schedule.attendanceLatitude === undefined || schedule.attendanceLatitude === null
|
||||||
|
? ''
|
||||||
|
: this.formatCoordinate(schedule.attendanceLatitude),
|
||||||
|
attendanceLongitude: schedule.attendanceLongitude === '' || schedule.attendanceLongitude === undefined || schedule.attendanceLongitude === null
|
||||||
|
? ''
|
||||||
|
: this.formatCoordinate(schedule.attendanceLongitude),
|
||||||
|
attendanceRadiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
|
||||||
|
autoSubmitHours: schedule.autoSubmitHours || defaultForm.autoSubmitHours,
|
||||||
|
attendancePoints: this.buildAttendancePoints(schedule),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildPointSchedule(point = {}, schedule = {}) {
|
||||||
|
return scheduleFields.reduce((result, field) => ({
|
||||||
|
...result,
|
||||||
|
[field]: point[field] || schedule[field] || defaultForm[field],
|
||||||
|
}), {})
|
||||||
|
},
|
||||||
|
buildAttendancePoints(schedule = {}) {
|
||||||
|
const source = (schedule.attendancePoints || []).length
|
||||||
|
? schedule.attendancePoints
|
||||||
|
: [{
|
||||||
|
name: DEFAULT_POINT_NAME,
|
||||||
|
latitude: schedule.attendanceLatitude,
|
||||||
|
longitude: schedule.attendanceLongitude,
|
||||||
|
radiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
|
||||||
|
remark: '',
|
||||||
|
isActive: true,
|
||||||
|
}]
|
||||||
|
return source.map((point, index) => ({
|
||||||
|
localKey: point.name || `point_${index}`,
|
||||||
|
name: point.name || '',
|
||||||
|
originalName: point.originalName || point.name || '',
|
||||||
|
latitude: point.latitude === '' || point.latitude === undefined || point.latitude === null ? '' : this.formatCoordinate(point.latitude),
|
||||||
|
longitude: point.longitude === '' || point.longitude === undefined || point.longitude === null ? '' : this.formatCoordinate(point.longitude),
|
||||||
|
radiusMeters: point.radiusMeters || defaultForm.attendanceRadiusMeters,
|
||||||
|
...this.buildPointSchedule(point, schedule),
|
||||||
|
remark: point.remark || '',
|
||||||
|
isActive: point.isActive !== false,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onRadiusInput(e) {
|
||||||
|
this.setData({
|
||||||
|
'form.attendanceRadiusMeters': e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onAutoSubmitHoursInput(e) {
|
||||||
|
this.setData({
|
||||||
|
'form.autoSubmitHours': e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onCoordinateInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`form.${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onPointInput(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const field = e.currentTarget.dataset.field
|
||||||
|
this.setData({
|
||||||
|
[`form.attendancePoints[${index}].${field}`]: e.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addAttendancePoint() {
|
||||||
|
const point = emptyPoint()
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePoints': this.data.form.attendancePoints.concat(point),
|
||||||
|
expandedPointKey: point.localKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleAttendancePoint(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
this.setData({
|
||||||
|
expandedPointKey: this.data.expandedPointKey === key ? '' : key,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
formatCoordinate(value) {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number.toFixed(6) : ''
|
||||||
|
},
|
||||||
|
formatAccuracy(value) {
|
||||||
|
const number = Number(value)
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return number < 10 ? `${number.toFixed(1)}米` : `${Math.round(number)}米`
|
||||||
|
},
|
||||||
|
sleep(ms) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
requestHighAccuracyLocation() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
isHighAccuracy: true,
|
||||||
|
highAccuracyExpireTime: 8000,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
normalizeLocation(res) {
|
||||||
|
const latitude = Number(res && res.latitude)
|
||||||
|
const longitude = Number(res && res.longitude)
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy: Number(res && res.accuracy),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pickBetterLocation(current, next) {
|
||||||
|
if (!next) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
const currentAccuracy = Number(current.accuracy)
|
||||||
|
const nextAccuracy = Number(next.accuracy)
|
||||||
|
if (!Number.isFinite(currentAccuracy)) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextAccuracy)) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
return nextAccuracy < currentAccuracy ? next : current
|
||||||
|
},
|
||||||
|
async getBestCurrentLocation() {
|
||||||
|
let best = null
|
||||||
|
let lastError = null
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
try {
|
||||||
|
const sample = this.normalizeLocation(await this.requestHighAccuracyLocation())
|
||||||
|
best = this.pickBetterLocation(best, sample)
|
||||||
|
if (sample && Number.isFinite(sample.accuracy) && sample.accuracy <= 30) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
if (index < 2) {
|
||||||
|
await this.sleep(700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
throw lastError || new Error('获取位置失败')
|
||||||
|
},
|
||||||
|
async useCurrentLocation(e) {
|
||||||
|
const pointIndex = e && e.currentTarget ? e.currentTarget.dataset.index : undefined
|
||||||
|
this.setData({ locating: true })
|
||||||
|
try {
|
||||||
|
const location = await this.getBestCurrentLocation()
|
||||||
|
const accuracyText = this.formatAccuracy(location.accuracy)
|
||||||
|
const updates = {
|
||||||
|
locationAccuracyText: accuracyText ? `定位精度约 ${accuracyText}` : '',
|
||||||
|
}
|
||||||
|
if (pointIndex !== undefined && pointIndex !== '') {
|
||||||
|
updates[`form.attendancePoints[${pointIndex}].latitude`] = this.formatCoordinate(location.latitude)
|
||||||
|
updates[`form.attendancePoints[${pointIndex}].longitude`] = this.formatCoordinate(location.longitude)
|
||||||
|
} else {
|
||||||
|
updates['form.attendanceLatitude'] = this.formatCoordinate(location.latitude)
|
||||||
|
updates['form.attendanceLongitude'] = this.formatCoordinate(location.longitude)
|
||||||
|
}
|
||||||
|
this.setData(updates)
|
||||||
|
if (Number.isFinite(location.accuracy) && location.accuracy > 100) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '定位精度偏低',
|
||||||
|
content: '当前只拿到模糊定位,请在系统和微信里开启精确位置,或手动修正经纬度后保存。',
|
||||||
|
showCancel: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: '已获取当前位置', icon: 'success' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error && error.errMsg ? error.errMsg : ''
|
||||||
|
wx.showToast({
|
||||||
|
title: message.indexOf('auth deny') >= 0 ? '请允许获取位置' : '获取位置失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.setData({ locating: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetDefault() {
|
||||||
|
this.setData({
|
||||||
|
form: {
|
||||||
|
...this.data.form,
|
||||||
|
...defaultScheduleValues(),
|
||||||
|
attendanceLatitude: this.data.form.attendanceLatitude,
|
||||||
|
attendanceLongitude: this.data.form.attendanceLongitude,
|
||||||
|
attendanceRadiusMeters: this.data.form.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
|
||||||
|
autoSubmitHours: this.data.form.autoSubmitHours || defaultForm.autoSubmitHours,
|
||||||
|
attendancePoints: (this.data.form.attendancePoints || []).map(point => ({
|
||||||
|
...point,
|
||||||
|
...defaultScheduleValues(),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
validateSchedule(point, name) {
|
||||||
|
for (let index = 0; index < scheduleFields.length; index += 1) {
|
||||||
|
const field = scheduleFields[index]
|
||||||
|
const value = String(point[field] || '').trim()
|
||||||
|
if (!/^\d{1,2}:\d{2}$/.test(value)) {
|
||||||
|
wx.showToast({ title: `${name} 作息时间格式错误`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parts = value.split(':')
|
||||||
|
const hour = Number(parts[0])
|
||||||
|
const minute = Number(parts[1])
|
||||||
|
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
|
wx.showToast({ title: `${name} 作息时间范围错误`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
validateAttendancePoints(points) {
|
||||||
|
const names = new Set()
|
||||||
|
for (let index = 0; index < points.length; index += 1) {
|
||||||
|
const point = points[index]
|
||||||
|
const name = String(point.name || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
wx.showToast({ title: `第${index + 1}个考勤点缺少名称`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (names.has(name)) {
|
||||||
|
wx.showToast({ title: `考勤点名称重复:${name}`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
names.add(name)
|
||||||
|
const latitudeText = String(point.latitude || '').trim()
|
||||||
|
const longitudeText = String(point.longitude || '').trim()
|
||||||
|
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
|
||||||
|
wx.showToast({ title: `${name} 经纬度需要同时填写`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (latitudeText || longitudeText) {
|
||||||
|
const latitude = Number(latitudeText)
|
||||||
|
const longitude = Number(longitudeText)
|
||||||
|
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||||
|
wx.showToast({ title: `${name} 纬度范围为-90到90`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||||||
|
wx.showToast({ title: `${name} 经度范围为-180到180`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const radius = Number(point.radiusMeters)
|
||||||
|
if (!Number.isFinite(radius) || radius <= 0) {
|
||||||
|
wx.showToast({ title: `${name} 请输入有效考勤半径`, icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!this.validateSchedule(point, name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async removePoint(e) {
|
||||||
|
const index = e.currentTarget.dataset.index
|
||||||
|
const point = this.data.form.attendancePoints[index]
|
||||||
|
if (!point) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!point.originalName) {
|
||||||
|
const nextPoints = this.data.form.attendancePoints.filter((_, itemIndex) => itemIndex !== Number(index))
|
||||||
|
this.setData({
|
||||||
|
'form.attendancePoints': nextPoints,
|
||||||
|
expandedPointKey: this.data.expandedPointKey === point.localKey ? '' : this.data.expandedPointKey,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '停用考勤点',
|
||||||
|
content: `确认停用 ${point.name}?已有历史业务数据会保留。`,
|
||||||
|
confirmText: '停用',
|
||||||
|
confirmColor: '#d92d20',
|
||||||
|
success: async res => {
|
||||||
|
if (!res.confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteAttendancePoint(point.originalName)
|
||||||
|
wx.showToast({ title: '已停用', icon: 'success' })
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '停用失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const radius = Number(this.data.form.attendanceRadiusMeters)
|
||||||
|
if (!Number.isFinite(radius) || radius <= 0) {
|
||||||
|
wx.showToast({ title: '请输入有效考勤半径', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const latitudeText = String(this.data.form.attendanceLatitude || '').trim()
|
||||||
|
const longitudeText = String(this.data.form.attendanceLongitude || '').trim()
|
||||||
|
const autoSubmitHours = Number(this.data.form.autoSubmitHours)
|
||||||
|
if (!Number.isFinite(autoSubmitHours) || autoSubmitHours < 1 || autoSubmitHours > 168) {
|
||||||
|
wx.showToast({ title: '系统自动提交时长需在1到168小时之间', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
|
||||||
|
wx.showToast({ title: '经纬度需要同时填写', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (latitudeText || longitudeText) {
|
||||||
|
const latitude = Number(latitudeText)
|
||||||
|
const longitude = Number(longitudeText)
|
||||||
|
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||||
|
wx.showToast({ title: '纬度范围为-90到90', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||||||
|
wx.showToast({ title: '经度范围为-180到180', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setData({ saving: true })
|
||||||
|
wx.showLoading({ title: '保存中' })
|
||||||
|
try {
|
||||||
|
const points = this.data.form.attendancePoints || []
|
||||||
|
if (!points.length) {
|
||||||
|
wx.showToast({ title: '请至少维护一个考勤点', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.validateAttendancePoints(points)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const primaryPoint = points[0] || {}
|
||||||
|
const schedulePayload = {
|
||||||
|
...this.data.form,
|
||||||
|
...this.buildPointSchedule(primaryPoint, this.data.form),
|
||||||
|
attendanceLatitude: primaryPoint.latitude || this.data.form.attendanceLatitude,
|
||||||
|
attendanceLongitude: primaryPoint.longitude || this.data.form.attendanceLongitude,
|
||||||
|
attendanceRadiusMeters: primaryPoint.radiusMeters || this.data.form.attendanceRadiusMeters,
|
||||||
|
attendancePoints: points.map(point => ({
|
||||||
|
...point,
|
||||||
|
name: String(point.name || '').trim(),
|
||||||
|
originalName: point.originalName || point.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const schedule = await api.saveWorkScheduleSettings(schedulePayload)
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({
|
||||||
|
form: this.buildForm(schedule),
|
||||||
|
updatedAtText: schedule.updatedAtText || '',
|
||||||
|
})
|
||||||
|
await this.load()
|
||||||
|
} catch (error) {
|
||||||
|
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ saving: false })
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
3
pages/workSchedule/workSchedule.json
Normal file
3
pages/workSchedule/workSchedule.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "考勤设置"
|
||||||
|
}
|
||||||
199
pages/workSchedule/workSchedule.wxml
Normal file
199
pages/workSchedule/workSchedule.wxml
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<scroll-view
|
||||||
|
class="page safe-bottom"
|
||||||
|
scroll-y
|
||||||
|
type="list"
|
||||||
|
refresher-enabled
|
||||||
|
refresher-triggered="{{refreshing}}"
|
||||||
|
bindrefresherrefresh="onPullDownRefresh"
|
||||||
|
>
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">考勤设置</text>
|
||||||
|
<text class="subtitle">按考勤点维护作息时间、就餐时间和扫码考勤范围</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card auto-submit-card">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">系统自动提交时长(小时)</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="digit"
|
||||||
|
value="{{form.autoSubmitHours}}"
|
||||||
|
placeholder="默认15小时"
|
||||||
|
bindinput="onAutoSubmitHoursInput"
|
||||||
|
/>
|
||||||
|
<text class="subtitle">超过该时长未下班报工,系统将自动提交待审核报工。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-title section-title-action">
|
||||||
|
<text>考勤范围</text>
|
||||||
|
<button class="btn mini primary add-point-btn" bindtap="addAttendancePoint">+</button>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
wx:for="{{form.attendancePoints}}"
|
||||||
|
wx:key="localKey"
|
||||||
|
wx:for-index="pointIndex"
|
||||||
|
wx:for-item="point"
|
||||||
|
class="card location-card {{expandedPointKey === point.localKey ? 'expanded' : 'collapsed'}}"
|
||||||
|
>
|
||||||
|
<view class="location-head" data-key="{{point.localKey}}" bindtap="toggleAttendancePoint">
|
||||||
|
<view class="location-text">
|
||||||
|
<text class="schedule-name">{{point.name || '新考勤点'}}</text>
|
||||||
|
<block wx:if="{{expandedPointKey === point.localKey}}">
|
||||||
|
<text class="subtitle">一个考勤点对应一个分公司,可独立配置作息和扫码范围</text>
|
||||||
|
<text wx:if="{{locationAccuracyText}}" class="location-accuracy">{{locationAccuracyText}}</text>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
wx:if="{{expandedPointKey === point.localKey}}"
|
||||||
|
class="btn primary locate-btn"
|
||||||
|
loading="{{locating}}"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
catchtap="useCurrentLocation"
|
||||||
|
>使用当前位置</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{expandedPointKey === point.localKey}}">
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">考勤点名称</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
value="{{point.name}}"
|
||||||
|
placeholder="请输入考勤点名称"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
data-field="name"
|
||||||
|
bindinput="onPointInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="point-section-title">作息时间</view>
|
||||||
|
<view class="schedule-panel">
|
||||||
|
<view class="schedule-row">
|
||||||
|
<text class="schedule-name">白班时间段</text>
|
||||||
|
<view class="time-range">
|
||||||
|
<picker mode="time" value="{{point.dayStart}}" data-index="{{pointIndex}}" data-field="dayStart" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.dayStart}}</view>
|
||||||
|
</picker>
|
||||||
|
<text class="range-sep">至</text>
|
||||||
|
<picker mode="time" value="{{point.dayEnd}}" data-index="{{pointIndex}}" data-field="dayEnd" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.dayEnd}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="schedule-row">
|
||||||
|
<text class="schedule-name">夜班时间段</text>
|
||||||
|
<view class="time-range">
|
||||||
|
<picker mode="time" value="{{point.nightStart}}" data-index="{{pointIndex}}" data-field="nightStart" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.nightStart}}</view>
|
||||||
|
</picker>
|
||||||
|
<text class="range-sep">至</text>
|
||||||
|
<picker mode="time" value="{{point.nightEnd}}" data-index="{{pointIndex}}" data-field="nightEnd" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.nightEnd}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="schedule-row">
|
||||||
|
<text class="schedule-name">中饭就餐</text>
|
||||||
|
<view class="time-range">
|
||||||
|
<picker mode="time" value="{{point.lunchStart}}" data-index="{{pointIndex}}" data-field="lunchStart" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.lunchStart}}</view>
|
||||||
|
</picker>
|
||||||
|
<text class="range-sep">至</text>
|
||||||
|
<picker mode="time" value="{{point.lunchEnd}}" data-index="{{pointIndex}}" data-field="lunchEnd" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.lunchEnd}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="schedule-row">
|
||||||
|
<text class="schedule-name">晚饭就餐</text>
|
||||||
|
<view class="time-range">
|
||||||
|
<picker mode="time" value="{{point.dinnerStart}}" data-index="{{pointIndex}}" data-field="dinnerStart" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.dinnerStart}}</view>
|
||||||
|
</picker>
|
||||||
|
<text class="range-sep">至</text>
|
||||||
|
<picker mode="time" value="{{point.dinnerEnd}}" data-index="{{pointIndex}}" data-field="dinnerEnd" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.dinnerEnd}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="schedule-row last">
|
||||||
|
<text class="schedule-name">加班时间段</text>
|
||||||
|
<view class="time-range">
|
||||||
|
<picker mode="time" value="{{point.overtimeStart}}" data-index="{{pointIndex}}" data-field="overtimeStart" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.overtimeStart}}</view>
|
||||||
|
</picker>
|
||||||
|
<text class="range-sep">至</text>
|
||||||
|
<picker mode="time" value="{{point.overtimeEnd}}" data-index="{{pointIndex}}" data-field="overtimeEnd" bindchange="onTimeChange">
|
||||||
|
<view class="picker time-picker">{{point.overtimeEnd}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="point-section-title">考勤范围</view>
|
||||||
|
<view class="location-grid">
|
||||||
|
<view class="location-cell">
|
||||||
|
<text class="label">纬度</text>
|
||||||
|
<input
|
||||||
|
class="input coordinate-input mono"
|
||||||
|
type="digit"
|
||||||
|
value="{{point.latitude}}"
|
||||||
|
placeholder="请输入纬度"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
data-field="latitude"
|
||||||
|
bindinput="onPointInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="location-cell">
|
||||||
|
<text class="label">经度</text>
|
||||||
|
<input
|
||||||
|
class="input coordinate-input mono"
|
||||||
|
type="digit"
|
||||||
|
value="{{point.longitude}}"
|
||||||
|
placeholder="请输入经度"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
data-field="longitude"
|
||||||
|
bindinput="onPointInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-item radius-item">
|
||||||
|
<text class="label">考勤范围半径(米)</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
value="{{point.radiusMeters}}"
|
||||||
|
placeholder="请输入米数"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
data-field="radiusMeters"
|
||||||
|
bindinput="onPointInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="label">备注</text>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
value="{{point.remark}}"
|
||||||
|
placeholder="可填写分公司或厂区说明"
|
||||||
|
data-index="{{pointIndex}}"
|
||||||
|
data-field="remark"
|
||||||
|
bindinput="onPointInput"
|
||||||
|
></textarea>
|
||||||
|
</view>
|
||||||
|
<button class="btn danger full" data-index="{{pointIndex}}" catchtap="removePoint">停用/移除考勤点</button>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card save-card">
|
||||||
|
<text wx:if="{{updatedAtText}}" class="subtitle">最近更新 {{updatedAtText}}</text>
|
||||||
|
<view class="button-row">
|
||||||
|
<button class="btn secondary full" bindtap="resetDefault">恢复默认</button>
|
||||||
|
<button class="btn primary full" loading="{{saving}}" bindtap="save">保存设置</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
186
pages/workSchedule/workSchedule.wxss
Normal file
186
pages/workSchedule/workSchedule.wxss
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
.schedule-panel {
|
||||||
|
margin-bottom: 26rpx;
|
||||||
|
padding: 6rpx 20rpx;
|
||||||
|
border: 2rpx solid #d7e1ef;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 178rpx minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
border-bottom: 1rpx solid #e6edf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row.last {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-name {
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42rpx minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker {
|
||||||
|
min-height: 74rpx;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-size: 27rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-sep {
|
||||||
|
color: #6b778c;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-card {
|
||||||
|
padding-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-submit-card {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-section-title {
|
||||||
|
margin: 28rpx 0 16rpx;
|
||||||
|
color: #1f64d8;
|
||||||
|
font-size: 25rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-head + .point-section-title {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-card .subtitle {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-action {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-action text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-action::before {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-point-btn {
|
||||||
|
width: 64rpx;
|
||||||
|
min-width: 64rpx;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card.collapsed {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card.expanded .location-head {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-card.collapsed .location-head {
|
||||||
|
min-height: 92rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-text .schedule-name {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-accuracy {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #1f64d8;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locate-btn {
|
||||||
|
flex: 0 0 220rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-cell {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 2rpx solid #d1dced;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8rpx 0 rgba(181, 194, 213, 0.68),
|
||||||
|
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: DIN Alternate, Arial, sans-serif;
|
||||||
|
font-size: 26rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
color: #162232;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radius-item {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
41
project.config.json
Normal file
41
project.config.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "3.15.0",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"enhance": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"compileWorklet": false,
|
||||||
|
"uglifyFileName": false,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"minifyWXML": true,
|
||||||
|
"localPlugins": false,
|
||||||
|
"condition": false,
|
||||||
|
"swc": false,
|
||||||
|
"disableSWC": true,
|
||||||
|
"disableUseStrict": false,
|
||||||
|
"useCompilerPlugins": false
|
||||||
|
},
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "auto",
|
||||||
|
"tabSize": 2
|
||||||
|
},
|
||||||
|
"appid": "wxaddf479fb3b0f848",
|
||||||
|
"simulatorPluginLibVersion": {}
|
||||||
|
}
|
||||||
32
scripts/test-material-stock-lot-wording.mjs
Normal file
32
scripts/test-material-stock-lot-wording.mjs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const userFacingFiles = [
|
||||||
|
"pages/reportForm/reportForm.wxml",
|
||||||
|
"pages/reportForm/reportForm.js",
|
||||||
|
"pages/dashboard/dashboard.wxml",
|
||||||
|
"pages/review/review.wxml",
|
||||||
|
"pages/records/records.wxml",
|
||||||
|
"utils/api.js"
|
||||||
|
];
|
||||||
|
const backendUserFacingFiles = [
|
||||||
|
"JhHardwareWRS_BackPoint/app/services/excel_export.py",
|
||||||
|
"JhHardwareWRS_BackPoint/app/routers/dashboard.py"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of userFacingFiles) {
|
||||||
|
const content = readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
|
||||||
|
assert.ok(!content.includes("原材料分批次号"), `${file} 仍包含“原材料分批次号”`);
|
||||||
|
assert.ok(!content.includes("请选择工单号"), `${file} 仍包含“请选择工单号”`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of backendUserFacingFiles) {
|
||||||
|
const content = readFileSync(new URL(`../../${file}`, import.meta.url), "utf8");
|
||||||
|
assert.ok(!content.includes("原材料分批次号"), `${file} 仍包含“原材料分批次号”`);
|
||||||
|
assert.ok(content.includes("材料库存批次号"), `${file} 缺少“材料库存批次号”`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportForm = readFileSync(new URL("../pages/reportForm/reportForm.wxml", import.meta.url), "utf8");
|
||||||
|
assert.ok(reportForm.includes("材料库存批次号"));
|
||||||
|
|
||||||
|
console.log("material stock lot wording checks passed");
|
||||||
7
sitemap.json
Normal file
7
sitemap.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}]
|
||||||
|
}
|
||||||
1894
utils/api.js
Normal file
1894
utils/api.js
Normal file
File diff suppressed because it is too large
Load Diff
5
utils/config.js
Normal file
5
utils/config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
apiBaseUrl: 'https://api.jiahengsmart.top',
|
||||||
|
// apiBaseUrl: 'http://192.168.3.42:8020',
|
||||||
|
// apiBaseUrl: 'http://172.16.144.66:8020',
|
||||||
|
}
|
||||||
183
utils/locationGuard.js
Normal file
183
utils/locationGuard.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const api = require('./api')
|
||||||
|
|
||||||
|
const EARTH_RADIUS_METERS = 6371000
|
||||||
|
const LOCATION_RETRY_COUNT = 3
|
||||||
|
const LOCATION_RETRY_DELAY_MS = 600
|
||||||
|
|
||||||
|
const isValidNumber = value => typeof value === 'number' && Number.isFinite(value)
|
||||||
|
|
||||||
|
const toRadians = degrees => (degrees * Math.PI) / 180
|
||||||
|
|
||||||
|
const sleep = ms => new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
const distanceMeters = (from, to) => {
|
||||||
|
const lat1 = toRadians(from.latitude)
|
||||||
|
const lat2 = toRadians(to.latitude)
|
||||||
|
const deltaLat = toRadians(to.latitude - from.latitude)
|
||||||
|
const deltaLng = toRadians(to.longitude - from.longitude)
|
||||||
|
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
|
||||||
|
+ Math.cos(lat1) * Math.cos(lat2)
|
||||||
|
* Math.sin(deltaLng / 2) * Math.sin(deltaLng / 2)
|
||||||
|
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePoint = point => {
|
||||||
|
const latitudeValue = point.latitude
|
||||||
|
const longitudeValue = point.longitude
|
||||||
|
const latitude = latitudeValue === '' || latitudeValue === null || latitudeValue === undefined
|
||||||
|
? NaN
|
||||||
|
: Number(latitudeValue)
|
||||||
|
const longitude = longitudeValue === '' || longitudeValue === null || longitudeValue === undefined
|
||||||
|
? NaN
|
||||||
|
: Number(longitudeValue)
|
||||||
|
return {
|
||||||
|
name: point.name || '',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
radiusMeters: Number(point.radiusMeters || 500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFactoryConfigs = async (scope = 'accessible') => {
|
||||||
|
const source = scope === 'public'
|
||||||
|
? await api.listPublicAttendancePoints()
|
||||||
|
: await api.listAccessibleAttendancePoints()
|
||||||
|
const points = source.map(normalizePoint)
|
||||||
|
const validPoints = points.filter(point => (
|
||||||
|
point.name
|
||||||
|
&& isValidNumber(point.latitude)
|
||||||
|
&& isValidNumber(point.longitude)
|
||||||
|
&& Number.isFinite(point.radiusMeters)
|
||||||
|
&& point.radiusMeters > 0
|
||||||
|
))
|
||||||
|
if (!validPoints.length) {
|
||||||
|
throw new Error('未配置考勤点经纬度,请经理先在考勤设置中配置')
|
||||||
|
}
|
||||||
|
return validPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentLocation = () => new Promise((resolve, reject) => {
|
||||||
|
wx.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
isHighAccuracy: true,
|
||||||
|
highAccuracyExpireTime: 8000,
|
||||||
|
success: resolve,
|
||||||
|
fail: error => {
|
||||||
|
const message = error && error.errMsg ? error.errMsg : ''
|
||||||
|
if (message.indexOf('auth deny') >= 0 || message.indexOf('authorize no response') >= 0) {
|
||||||
|
reject(new Error('请允许获取位置信息后再扫码报工'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error('获取当前位置失败,请确认已开启定位权限'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const locationAccuracyText = location => {
|
||||||
|
const accuracy = Number(location && location.accuracy)
|
||||||
|
return Number.isFinite(accuracy) && accuracy > 0 ? `,定位精度约${Math.round(accuracy)}米` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluateLocation = (current, factories, attendancePointName = '') => {
|
||||||
|
const targetName = String(attendancePointName || '').trim()
|
||||||
|
const matches = factories
|
||||||
|
.map(factory => ({
|
||||||
|
current,
|
||||||
|
factory,
|
||||||
|
distance: distanceMeters(current, factory),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.distance - b.distance)
|
||||||
|
|
||||||
|
const targetMatch = targetName
|
||||||
|
? matches.find(item => item.factory.name === targetName)
|
||||||
|
: null
|
||||||
|
const matched = targetMatch
|
||||||
|
? (targetMatch.distance <= targetMatch.factory.radiusMeters ? targetMatch : null)
|
||||||
|
: matches.find(item => item.distance <= item.factory.radiusMeters)
|
||||||
|
return matched || {
|
||||||
|
current,
|
||||||
|
factory: null,
|
||||||
|
distance: (targetMatch || matches[0]) ? (targetMatch || matches[0]).distance : 0,
|
||||||
|
nearest: targetMatch || matches[0] || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentAttendancePoint = async (scope = 'accessible', attendancePointName = '') => {
|
||||||
|
const factories = await getFactoryConfigs(scope)
|
||||||
|
const attempts = []
|
||||||
|
let lastError = null
|
||||||
|
|
||||||
|
for (let index = 0; index < LOCATION_RETRY_COUNT; index += 1) {
|
||||||
|
try {
|
||||||
|
const current = await getCurrentLocation()
|
||||||
|
if (isValidNumber(current.latitude) && isValidNumber(current.longitude)) {
|
||||||
|
const result = evaluateLocation(current, factories, attendancePointName)
|
||||||
|
attempts.push(result)
|
||||||
|
if (result.factory) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
sampleCount: attempts.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
if (index < LOCATION_RETRY_COUNT - 1) {
|
||||||
|
await sleep(LOCATION_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attempts.length && lastError) {
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
const best = attempts.sort((a, b) => (a.distance || 0) - (b.distance || 0))[0]
|
||||||
|
return {
|
||||||
|
...(best || { current: null, factory: null, distance: 0, nearest: null }),
|
||||||
|
sampleCount: attempts.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureFactoryLocation = async (attendancePointName = '', scope = 'accessible') => {
|
||||||
|
const result = await getCurrentAttendancePoint(scope, attendancePointName)
|
||||||
|
if (!result.factory) {
|
||||||
|
const nearest = result.nearest
|
||||||
|
if (nearest && nearest.factory) {
|
||||||
|
throw new Error(`已多次尝试定位,当前位置距离考勤点【${nearest.factory.name}】约${Math.round(nearest.distance)}米,超过${nearest.factory.radiusMeters}米范围${locationAccuracyText(result.current)},不能扫码报工`)
|
||||||
|
}
|
||||||
|
throw new Error('当前位置未进入任何考勤点,不能扫码报工')
|
||||||
|
}
|
||||||
|
if (attendancePointName && result.factory.name !== attendancePointName) {
|
||||||
|
throw new Error(`当前位置已进入【${result.factory.name}】考勤点,不是该二维码所属的【${attendancePointName}】考勤点`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
current: result.current,
|
||||||
|
factory: result.factory,
|
||||||
|
distance: result.distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTemporaryWorkerLocation = async () => {
|
||||||
|
const result = await getCurrentAttendancePoint('public')
|
||||||
|
if (!result.factory) {
|
||||||
|
const nearest = result.nearest
|
||||||
|
if (nearest && nearest.factory) {
|
||||||
|
throw new Error(`已多次尝试定位,当前位置距离最近考勤点【${nearest.factory.name}】约${Math.round(nearest.distance)}米,超过${nearest.factory.radiusMeters}米范围${locationAccuracyText(result.current)},不能创建临时工`)
|
||||||
|
}
|
||||||
|
throw new Error('当前位置未进入任何考勤点,不能创建临时工')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
current: result.current,
|
||||||
|
factory: result.factory,
|
||||||
|
distance: result.distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureFactoryLocation,
|
||||||
|
ensureTemporaryWorkerLocation,
|
||||||
|
getCurrentAttendancePoint,
|
||||||
|
distanceMeters,
|
||||||
|
}
|
||||||
134
utils/mockData.js
Normal file
134
utils/mockData.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
const people = [
|
||||||
|
{
|
||||||
|
phone: '13800000001',
|
||||||
|
name: '张三',
|
||||||
|
role: 'worker',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone: '13800000002',
|
||||||
|
name: '李四',
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone: '13800000003',
|
||||||
|
name: '王经理',
|
||||||
|
role: 'manager',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone: '13800000004',
|
||||||
|
name: '赵六',
|
||||||
|
role: 'worker',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6001-003',
|
||||||
|
productName: 'LAC-56011041后防撞梁左吸能盒端板',
|
||||||
|
materialCode: '110101002022',
|
||||||
|
materialName: 'LAC-56011041-后防撞梁左吸能盒端板-半成品',
|
||||||
|
deviceNo: '20#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 10.984,
|
||||||
|
standardWorkload: 2622,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6001-003',
|
||||||
|
productName: 'LAC-56011044后防撞梁右吸能盒端板',
|
||||||
|
materialCode: '110101002023',
|
||||||
|
materialName: 'LAC-56011044-后防撞梁右吸能盒端板-冲切-半成品',
|
||||||
|
deviceNo: '4#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 10.984,
|
||||||
|
standardWorkload: 2622,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6009-001',
|
||||||
|
productName: 'LAC-56010050/0014后防撞梁左连接板',
|
||||||
|
materialCode: '110501000186',
|
||||||
|
materialName: 'LAC-56010050-后防撞梁左连接板-半成品',
|
||||||
|
deviceNo: '18#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 5.112,
|
||||||
|
standardWorkload: 5634,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6009-001',
|
||||||
|
productName: 'LAC-56010050/0014后防撞梁右连接板',
|
||||||
|
materialCode: '110701000359',
|
||||||
|
materialName: 'LAC-56000014-后防撞梁右连接板-半成品',
|
||||||
|
deviceNo: '18#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 5.112,
|
||||||
|
standardWorkload: 5634,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6010-002',
|
||||||
|
productName: 'LAC-80010836左后纵梁后段加强板1',
|
||||||
|
materialCode: '110501000199',
|
||||||
|
materialName: 'LAC-80010836-左后纵梁后段加强板一-半成品',
|
||||||
|
deviceNo: '2#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 12.952,
|
||||||
|
standardWorkload: 2224,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6010-002',
|
||||||
|
productName: 'LAC-80010837右后纵梁后段加强板1',
|
||||||
|
materialCode: '110501000200',
|
||||||
|
materialName: 'LAC-80010837-右后纵梁后段加强板一-半成品',
|
||||||
|
deviceNo: '2#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '切边冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 12.952,
|
||||||
|
standardWorkload: 2224,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6011-001',
|
||||||
|
productName: '前围下横梁左支架',
|
||||||
|
materialCode: '110501000211',
|
||||||
|
materialName: '前围下横梁左支架-半成品',
|
||||||
|
deviceNo: '28#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '落料冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 8.64,
|
||||||
|
standardWorkload: 3333,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectNo: 'W02',
|
||||||
|
profileNo: 'XUS-157-6011-002',
|
||||||
|
productName: '前围下横梁右支架',
|
||||||
|
materialCode: '110501000212',
|
||||||
|
materialName: '前围下横梁右支架-半成品',
|
||||||
|
deviceNo: '28#',
|
||||||
|
process: '1序',
|
||||||
|
stampingMethod: '落料冲孔',
|
||||||
|
operatorCount: 1,
|
||||||
|
standardBeat: 8.64,
|
||||||
|
standardWorkload: 3333,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
people,
|
||||||
|
products,
|
||||||
|
}
|
||||||
156
utils/request.js
Normal file
156
utils/request.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
const config = require('./config')
|
||||||
|
|
||||||
|
const AUTH_KEY = 'jh_wrs_auth'
|
||||||
|
|
||||||
|
const getAuth = () => wx.getStorageSync(AUTH_KEY) || null
|
||||||
|
|
||||||
|
const setAuth = auth => {
|
||||||
|
wx.setStorageSync(AUTH_KEY, auth)
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAuth = () => {
|
||||||
|
wx.removeStorageSync(AUTH_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildQuery = params => Object.keys(params || {})
|
||||||
|
.filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '')
|
||||||
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const formatErrorDetail = detail => {
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail.map(item => item.msg || item.message || JSON.stringify(item)).join(';')
|
||||||
|
}
|
||||||
|
if (detail && typeof detail === 'object') {
|
||||||
|
return detail.msg || detail.message || JSON.stringify(detail)
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = options => new Promise((resolve, reject) => {
|
||||||
|
const auth = getAuth()
|
||||||
|
const query = buildQuery(options.params)
|
||||||
|
const url = `${config.apiBaseUrl}${options.url}${query ? `?${query}` : ''}`
|
||||||
|
wx.request({
|
||||||
|
url,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data || undefined,
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
|
header: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(auth && auth.accessToken ? { Authorization: `Bearer ${auth.accessToken}` } : {}),
|
||||||
|
...(options.header || {}),
|
||||||
|
},
|
||||||
|
success: res => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(res.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
const detail = res.data && (res.data.detail || res.data.message)
|
||||||
|
const message = formatErrorDetail(detail) || `接口请求失败:${res.statusCode}`
|
||||||
|
const error = new Error(message)
|
||||||
|
error.statusCode = res.statusCode
|
||||||
|
error.detail = detail
|
||||||
|
reject(error)
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
reject(new Error(err.errMsg || '网络请求失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = options => new Promise((resolve, reject) => {
|
||||||
|
const auth = getAuth()
|
||||||
|
wx.uploadFile({
|
||||||
|
url: `${config.apiBaseUrl}${options.url}`,
|
||||||
|
filePath: options.filePath,
|
||||||
|
name: options.name || 'file',
|
||||||
|
timeout: options.timeout || 30000,
|
||||||
|
header: {
|
||||||
|
...(auth && auth.accessToken ? { Authorization: `Bearer ${auth.accessToken}` } : {}),
|
||||||
|
...(options.header || {}),
|
||||||
|
},
|
||||||
|
success: res => {
|
||||||
|
let data = res.data
|
||||||
|
try {
|
||||||
|
data = data ? JSON.parse(data) : {}
|
||||||
|
} catch (error) {
|
||||||
|
data = { raw: res.data }
|
||||||
|
}
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(new Error(formatErrorDetail(data && (data.detail || data.message)) || `上传失败:${res.statusCode}`))
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
reject(new Error(err.errMsg || '上传失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const download = options => new Promise((resolve, reject) => {
|
||||||
|
const auth = getAuth()
|
||||||
|
const query = buildQuery(options.params)
|
||||||
|
wx.downloadFile({
|
||||||
|
url: `${config.apiBaseUrl}${options.url}${query ? `?${query}` : ''}`,
|
||||||
|
header: {
|
||||||
|
...(auth && auth.accessToken ? { Authorization: `Bearer ${auth.accessToken}` } : {}),
|
||||||
|
...(options.header || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || 30000,
|
||||||
|
success: res => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(res.tempFilePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
reject(new Error(`下载失败:${res.statusCode}`))
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
reject(new Error(err.errMsg || '下载失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadUrl = options => new Promise((resolve, reject) => {
|
||||||
|
const auth = getAuth()
|
||||||
|
wx.downloadFile({
|
||||||
|
url: options.url,
|
||||||
|
filePath: options.filePath,
|
||||||
|
header: {
|
||||||
|
...(auth && auth.accessToken ? { Authorization: `Bearer ${auth.accessToken}` } : {}),
|
||||||
|
...(options.header || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || 30000,
|
||||||
|
success: res => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(res.filePath || res.tempFilePath || options.filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
reject(new Error(`下载失败:${res.statusCode}`))
|
||||||
|
},
|
||||||
|
fail: err => {
|
||||||
|
reject(new Error(err.errMsg || '下载失败'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clearAuth,
|
||||||
|
download,
|
||||||
|
downloadUrl,
|
||||||
|
getAuth,
|
||||||
|
request,
|
||||||
|
setAuth,
|
||||||
|
upload,
|
||||||
|
}
|
||||||
53
utils/storage.js
Normal file
53
utils/storage.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const mockData = require('./mockData')
|
||||||
|
|
||||||
|
const STORE_KEY = 'jh_wrs_store_v1'
|
||||||
|
|
||||||
|
const clone = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
const baseStore = () => ({
|
||||||
|
currentUserPhone: '',
|
||||||
|
people: clone(mockData.people),
|
||||||
|
products: clone(mockData.products),
|
||||||
|
reports: [],
|
||||||
|
sessions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStore = () => {
|
||||||
|
const store = wx.getStorageSync(STORE_KEY)
|
||||||
|
if (store && store.people && store.products) {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStore = baseStore()
|
||||||
|
wx.setStorageSync(STORE_KEY, nextStore)
|
||||||
|
return nextStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveStore = store => {
|
||||||
|
wx.setStorageSync(STORE_KEY, store)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetStore = () => saveStore(baseStore())
|
||||||
|
|
||||||
|
const getCurrentUser = () => {
|
||||||
|
const store = getStore()
|
||||||
|
return store.people.find(person => person.phone === store.currentUserPhone) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCurrentUser = phone => {
|
||||||
|
const store = getStore()
|
||||||
|
const user = store.people.find(person => person.phone === phone)
|
||||||
|
store.currentUserPhone = user ? phone : ''
|
||||||
|
saveStore(store)
|
||||||
|
return user || null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clone,
|
||||||
|
getCurrentUser,
|
||||||
|
getStore,
|
||||||
|
resetStore,
|
||||||
|
saveStore,
|
||||||
|
setCurrentUser,
|
||||||
|
}
|
||||||
103
utils/util.js
Normal file
103
utils/util.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
const formatNumber = n => {
|
||||||
|
n = n.toString()
|
||||||
|
return n[1] ? n : `0${n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = date => {
|
||||||
|
const d = date instanceof Date ? date : new Date(date)
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
const day = d.getDate()
|
||||||
|
|
||||||
|
return `${[year, month, day].map(formatNumber).join('-')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = date => {
|
||||||
|
const d = date instanceof Date ? date : new Date(date)
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
const day = d.getDate()
|
||||||
|
const hour = d.getHours()
|
||||||
|
const minute = d.getMinutes()
|
||||||
|
const second = d.getSeconds()
|
||||||
|
|
||||||
|
return `${[year, month, day].map(formatNumber).join('-')} ${[hour, minute, second].map(formatNumber).join(':')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = () => formatDate(new Date())
|
||||||
|
|
||||||
|
const parseDate = value => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||||
|
}
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
const matched = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||||
|
if (matched) {
|
||||||
|
return new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]))
|
||||||
|
}
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addDays = (value, days) => {
|
||||||
|
const date = parseDate(value)
|
||||||
|
date.setDate(date.getDate() + toNumber(days))
|
||||||
|
return formatDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = prefix => `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`
|
||||||
|
|
||||||
|
const toNumber = value => {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesBetween = (start, end) => {
|
||||||
|
const startTime = new Date(start).getTime()
|
||||||
|
const endTime = new Date(end).getTime()
|
||||||
|
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return Math.round((endTime - startTime) / 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (value, digits = 1) => {
|
||||||
|
const base = Math.pow(10, digits)
|
||||||
|
return Math.round(toNumber(value) * base) / base
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = (value, digits = 0) => `${round(value, digits)}%`
|
||||||
|
|
||||||
|
const paginate = (rows, page = 1, pageSize = 10) => {
|
||||||
|
const safePage = Math.max(1, toNumber(page) || 1)
|
||||||
|
const safePageSize = Math.max(1, toNumber(pageSize) || 10)
|
||||||
|
const total = rows.length
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / safePageSize))
|
||||||
|
const current = Math.min(safePage, totalPages)
|
||||||
|
const start = (current - 1) * safePageSize
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.slice(start, start + safePageSize),
|
||||||
|
page: current,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
addDays,
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
formatNumber,
|
||||||
|
minutesBetween,
|
||||||
|
paginate,
|
||||||
|
percent,
|
||||||
|
round,
|
||||||
|
today,
|
||||||
|
toNumber,
|
||||||
|
uid,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user