commit fa73a3c184eaa5f49220cd0a02c3386317ed7d0b Author: souplearn Date: Wed Jun 24 15:19:14 2026 +0800 chore: initialize mini program frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74c826f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..79e9cc6 --- /dev/null +++ b/PRODUCT.md @@ -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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..edb2440 --- /dev/null +++ b/app.js @@ -0,0 +1,5 @@ +App({ + globalData: { + userInfo: null, + } +}) diff --git a/app.json b/app.json new file mode 100644 index 0000000..759dc58 --- /dev/null +++ b/app.json @@ -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" +} diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..a55fbbe --- /dev/null +++ b/app.wxss @@ -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; +} diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e9ed295 --- /dev/null +++ b/docs/api.md @@ -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` 合并。 +- 返回实际节拍、标准节拍、节拍正负百分比、报工数量、标准工作量、工作量正负百分比。 + +## 关键计算口径 + +- 报工日期:员工点击提交后再确认的日期。 +- 有效工时:`下班时间 - 上班时间 - 休息间隔`,休息间隔只能经理修改。 +- 实际节拍:`有效工时秒数 / (成品数量 + 不良数量)`,报废计入不良数量。 +- 标准节拍:按明细产出数量加权平均。 +- 标准工作量:使用产品表导入或手动维护的 `标准工作量`,多条明细时累加。 +- 工作量对比:`(成品数量 - 标准工作量) / 标准工作量`。 diff --git a/docs/database.sql b/docs/database.sql new file mode 100644 index 0000000..5a1df2d --- /dev/null +++ b/docs/database.sql @@ -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='设备二维码'; diff --git a/docs/env.example b/docs/env.example new file mode 100644 index 0000000..58215ef --- /dev/null +++ b/docs/env.example @@ -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 diff --git a/docs/superpowers/plans/2026-06-23-continuous-die-changeover-count.md b/docs/superpowers/plans/2026-06-23-continuous-die-changeover-count.md new file mode 100644 index 0000000..97e0dbd --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-continuous-die-changeover-count.md @@ -0,0 +1,1167 @@ +# Continuous Die Changeover Count Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the special stamping method `连续模`, require/report `换料次数` for continuous die report items, and carry it through worker reporting, admin review, records, manager dashboard, and Excel export. + +**Architecture:** Treat `连续模` as a special stamping method recognized by exact trimmed text, parallel to `清洗` but without changing normal work-time, device, beat, workload, lock, or wage rules. Store `换料次数` on `production_report_items` because it belongs to one product/process detail, not the whole report. Keep product `冲压方式` free-text with two suggested values, so field usage remains flexible for current shop-floor terms. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, MySQL, openpyxl, WeChat Mini Program WXML/WXSS/JS. + +--- + +## File Map + +Backend: +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/continuous_die.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_continuous_die_changeover_count.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py` +- Test: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_continuous_die.py` + +Frontend: +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/deviceQr/deviceQr.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/deviceQr/deviceQr.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.wxss` + +Important non-goal: +- Do not include `换料次数` in `参考工资` yet. Current wage formula is `成品数量 * 工序单价`; there is no maintained `换料单价`, so calculating extra pay now would require a hidden hard-coded price. This task only records and exports the count for later wage integration. + +--- + +### Task 1: Backend Continuous Die Recognition And Schema Field + +**Files:** +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/continuous_die.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_continuous_die_changeover_count.py` +- Test: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_continuous_die.py` + +- [ ] **Step 1: Write the helper test** + +Add this to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_continuous_die.py`: + +```python +from app.services.continuous_die import ( + CONTINUOUS_DIE_STAMPING_METHOD, + is_continuous_die_stamping_method, +) + + +def test_continuous_die_stamping_method_is_trimmed_exact_match(): + assert CONTINUOUS_DIE_STAMPING_METHOD == "连续模" + assert is_continuous_die_stamping_method("连续模") is True + assert is_continuous_die_stamping_method(" 连续模 ") is True + assert is_continuous_die_stamping_method("连续模具") is False + assert is_continuous_die_stamping_method("清洗") is False + assert is_continuous_die_stamping_method(None) is False +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_continuous_die.py -q +``` + +Expected: FAIL because `app.services.continuous_die` does not exist. + +- [ ] **Step 3: Create the backend helper** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/continuous_die.py`: + +```python +CONTINUOUS_DIE_STAMPING_METHOD = "连续模" + + +def clean_text(value: str | None) -> str: + return str(value or "").strip() + + +def is_continuous_die_stamping_method(value: str | None) -> bool: + return clean_text(value) == CONTINUOUS_DIE_STAMPING_METHOD + + +def is_continuous_die_product(product) -> bool: + return bool(product) and is_continuous_die_stamping_method(getattr(product, "stamping_method", None)) + + +def is_continuous_die_item(item) -> bool: + return is_continuous_die_stamping_method(getattr(item, "stamping_method", None)) +``` + +- [ ] **Step 4: Add the DB model field** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py`, add this field to `ProductionReportItem` immediately after `process_unit_price_yuan`: + +```python +changeover_count: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0) +``` + +- [ ] **Step 5: Add the migration script** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_continuous_die_changeover_count.py`: + +```python +from sqlalchemy import text + +from app.database import engine + + +def main() -> None: + with engine.begin() as conn: + exists = conn.execute( + text( + """ + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND column_name = 'changeover_count' + """ + ) + ).scalar() + if not exists: + conn.execute( + text( + """ + ALTER TABLE production_report_items + ADD COLUMN changeover_count DECIMAL(12, 2) NOT NULL DEFAULT 0 + AFTER process_unit_price_yuan + """ + ) + ) + print("migrate_continuous_die_changeover_count done") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 6: Add schema fields** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add these exact fields to the existing classes: + +```python +# class MoldNameOut +is_continuous_die: bool = False + +# class ProductOption +is_continuous_die: bool = False + +# class ReportDraftItem +changeover_count: float = 0 +is_continuous_die: bool = False + +# class ReportDraftBlock +is_continuous_die: bool = False + +# class ReportSubmitItem +changeover_count: float = Field(default=0, ge=0) +is_continuous_die: bool = False + +# class ReportItemOut +changeover_count: float = 0 +is_continuous_die: bool = False + +# class ReportItemCorrection +changeover_count: float | None = Field(default=None, ge=0) + +# class DeviceQrOut +is_continuous_die: bool = False + +# class DashboardRow +changeover_count: float = 0 +is_continuous_die: bool = False +``` + +- [ ] **Step 7: Run the helper test** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_continuous_die.py -q +``` + +Expected: PASS. + +--- + +### Task 2: Backend Report Flow, Review Flow, Dashboard, And Export + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py` +- Test: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_continuous_die.py` + +- [ ] **Step 1: Extend backend serialization tests** + +Append this test to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_continuous_die.py`: + +```python +from types import SimpleNamespace + +from app.services.serializers import product_option + + +def test_product_option_marks_continuous_die(): + product = SimpleNamespace( + attendance_point_name="嘉恒", + project_no="P001", + product_name="测试产品", + profile_no=None, + material_code=None, + material_name=None, + supplier=None, + product_net_weight_kg=None, + product_gross_weight_kg=None, + scrap_loss_rate=None, + waste_price_yuan_per_kg=None, + device_no="", + process_name="1", + stamping_method=" 连续模 ", + operator_count=1, + process_unit_price_yuan=0, + standard_beat=10, + standard_workload=0, + ) + + option = product_option(product) + + assert option.stamping_method == " 连续模 " + assert option.is_continuous_die is True + assert option.is_cleaning is False +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_continuous_die.py -q +``` + +Expected: FAIL because `ProductOption` is not yet populated with `is_continuous_die`. + +- [ ] **Step 3: Update serializers** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py`: + +Add import: + +```python +from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product +``` + +Update `product_option`: + +```python +def product_option(product: Product) -> ProductOption: + data = product_out(product).model_dump() + data["is_misc"] = is_misc_product(product) + data["is_multi_person"] = is_multi_person_product(product) + data["is_continuous_die"] = is_continuous_die_product(product) + return ProductOption(**data) +``` + +Update `report_out` item serialization so every `ReportItemOut` includes: + +```python +changeover_count=as_float(item.changeover_count), +is_continuous_die=is_continuous_die_item(item), +``` + +Update correction maps so `changeover_count` is treated as numeric: + +```python +numeric_fields = { + "operator_count", + "process_unit_price_yuan", + "good_qty", + "defect_qty", + "scrap_qty", + "changeover_count", +} +``` + +- [ ] **Step 4: Update report draft and submit** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`: + +Add import: + +```python +from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product +``` + +When building `ReportDraftBlock`, compute: + +```python +block_is_continuous_die = any(option.is_continuous_die for option in options) +``` + +Set: + +```python +is_continuous_die=block_is_continuous_die, +``` + +When building `ReportDraftItem`, set: + +```python +changeover_count=0, +is_continuous_die=is_continuous_die_product(first) if first else False, +``` + +When submitting normal report items, compute: + +```python +item_is_continuous_die = is_continuous_die_product(product) or is_continuous_die_item(item) +changeover_count = as_float(item.changeover_count) +if not item_is_continuous_die: + changeover_count = 0 +if changeover_count < 0: + raise HTTPException(status_code=400, detail="换料次数不能为负数") +``` + +Inside each `ProductionReportItem(` constructor in normal report submission, add this keyword argument: + +```python +changeover_count=changeover_count, +``` + +In cleaning submit path, set: + +```python +changeover_count=0, +``` + +- [ ] **Step 5: Update admin review** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`: + +Add import: + +```python +from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product +``` + +In `_snapshot`, include: + +```python +"changeover_count": float(item.changeover_count or 0), +``` + +When product correction changes an item, set: + +```python +item.changeover_count = item.changeover_count if is_continuous_die_product(product) else 0 +``` + +When applying item corrections, add: + +```python +if correction.changeover_count is not None: + next_changeover_count = as_float(correction.changeover_count) + if next_changeover_count < 0: + raise HTTPException(status_code=400, detail="换料次数不能为负数") + if not is_continuous_die_item(item): + next_changeover_count = 0 + if next_changeover_count != as_float(item.changeover_count): + item.changeover_count = next_changeover_count + has_correction = True +``` + +In system auto-submit validation, continuous die follows normal production validation; do not skip device or quantity validation. + +- [ ] **Step 6: Update dashboard aggregation** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`: + +Add import: + +```python +from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product +``` + +Add correction label: + +```python +"changeover_count": "换料次数", +``` + +Inside each item loop: + +```python +is_continuous_die = is_continuous_die_item(item) or is_continuous_die_product(product) +``` + +Initialize group fields: + +```python +"changeover_count": 0.0, +"is_continuous_die": is_continuous_die, +``` + +Accumulate: + +```python +group["changeover_count"] += as_float(item.changeover_count) +group["is_continuous_die"] = group["is_continuous_die"] or is_continuous_die +``` + +Set `DashboardRow` fields: + +```python +changeover_count=round2(group["changeover_count"]), +is_continuous_die=group["is_continuous_die"], +``` + +- [ ] **Step 7: Update Excel export** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py`, add `换料次数` after `冲压方式`: + +```python +"冲压方式", +"换料次数", +"操作人数", +``` + +Add the row value: + +```python +row.stamping_method, +row.changeover_count, +row.operator_count, +``` + +Add report flag: + +```python +if getattr(row, "is_continuous_die", False): + report_flags.append("连续模") +``` + +Leave `reference_wage` as-is: + +```python +# reference_wage remains good_qty * process_unit_price_yuan until a changeover unit price is maintained. +``` + +- [ ] **Step 8: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_continuous_die.py tests/test_metrics.py tests/test_review_corrections.py -q +``` + +Expected: all selected tests PASS. + +--- + +### Task 3: Frontend API Normalization + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` + +- [ ] **Step 1: Add frontend continuous die helpers** + +Near the existing cleaning helper in `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add: + +```javascript +const CONTINUOUS_DIE_STAMPING_METHOD = '连续模' + +const isContinuousDieStampingMethod = value => String(value || '').trim() === CONTINUOUS_DIE_STAMPING_METHOD + +const isContinuousDieRow = row => ( + !!row && ( + !!row.is_continuous_die + || !!row.isContinuousDie + || isContinuousDieStampingMethod(row.stamping_method || row.stampingMethod) + ) +) +``` + +- [ ] **Step 2: Add continuous die to product and mold mapping** + +In `toCamelProduct`, add: + +```javascript +isContinuousDie: isContinuousDieRow(row), +``` + +In `toCamelMoldOption`, add: + +```javascript +isContinuousDie: !!row.is_continuous_die || isContinuousDieRow(row), +``` + +- [ ] **Step 3: Add continuous die to report item mapping** + +In `toCamelReportItem`, add: + +```javascript +const isContinuousDie = isContinuousDieRow(row) +``` + +Return these fields: + +```javascript +isContinuousDie, +changeoverCount: row.changeover_count === null || row.changeover_count === undefined ? 0 : Number(row.changeover_count || 0), +``` + +Add correction display: + +```javascript +correctionPair(corrections, 'changeover_count', '换料次数', row.changeover_count || 0), +``` + +- [ ] **Step 4: Add continuous die to draft mapping and submit payload** + +In `emptyDraftItem`, `optionToDraftItem`, and `apiDraftItemToCamel`, add: + +```javascript +isContinuousDie: false, +changeoverCount: '', +changeoverCountError: false, +``` + +For product-derived items: + +```javascript +isContinuousDie: option ? !!option.isContinuousDie : false, +changeoverCount: '', +``` + +In `toSubmitPayload`, add: + +```javascript +changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0, +is_continuous_die: !!item.isContinuousDie, +``` + +- [ ] **Step 5: Add dashboard mapping** + +In `listDashboardReports` row mapping, add: + +```javascript +isContinuousDie: isContinuousDieRow(row), +changeoverCount: row.changeover_count || 0, +``` + +- [ ] **Step 6: Export helpers** + +At the bottom module export object, export: + +```javascript +isContinuousDieStampingMethod, +isContinuousDieRow, +``` + +--- + +### Task 4: Product List Input Suggestions And Continuous Die Watermark + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.wxss` + +- [ ] **Step 1: Add product page state and handlers** + +In `data`, add: + +```javascript +stampingMethodSuggestions: ['清洗', '连续模'], +stampingSuggestionVisible: false, +``` + +In `onInput`, trim stamping method only on save, not while typing. Add: + +```javascript +if (field === 'stampingMethod') { + this.setData({ + stampingSuggestionVisible: false, + [`form.${field}`]: value, + }) + return +} +``` + +Add handlers: + +```javascript +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) +}, +``` + +In `save`, normalize before validation: + +```javascript +form.stampingMethod = String(form.stampingMethod || '').trim() +``` + +Keep this rule unchanged except for trimming: + +```javascript +if (['清洗', '处理杂活'].includes(form.stampingMethod)) { + form.standardBeat = 0 +} +``` + +Continuous die must still show and require standard beat. + +- [ ] **Step 2: Update product page WXML** + +Replace the `冲压方式` input block with: + +```xml + + 冲压方式 + + + {{item}} + + +``` + +On product cards, add: + +```xml +连续模 +``` + +- [ ] **Step 3: Add product page CSS** + +Add styles to `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/manageProducts/manageProducts.wxss`: + +```css +.stamping-field { + position: relative; +} + +.stamping-suggestion-panel { + position: absolute; + left: 0; + right: 0; + top: 112rpx; + z-index: 20; + background: #fff; + border: 1rpx solid #d9e2f2; + border-radius: 8rpx; + box-shadow: 0 12rpx 32rpx rgba(16, 35, 72, 0.12); + overflow: hidden; +} + +.stamping-suggestion-option { + padding: 22rpx 24rpx; + font-size: 28rpx; + color: #1f2a44; + border-bottom: 1rpx solid #eef2f7; +} + +.stamping-suggestion-option:last-child { + border-bottom: 0; +} + +.continuous-die-stamp { + position: absolute; + right: 24rpx; + top: 24rpx; + transform: rotate(12deg); + color: #c26b00; + border: 2rpx solid rgba(194, 107, 0, 0.55); + border-radius: 6rpx; + padding: 6rpx 12rpx; + font-size: 24rpx; + font-weight: 700; + background: rgba(255, 244, 224, 0.88); +} +``` + +--- + +### Task 5: Worker Reporting Form + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxss` + +- [ ] **Step 1: Add validation for changeover count** + +In `validateDraft`, after raw material batch validation and before quantity validation, add: + +```javascript +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 + } +} +``` + +Add a generic input handler or reuse `onQtyInput` with `data-field="changeoverCount"`: + +```javascript +if (field === 'changeoverCount') { + this.setData({ + [`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].changeoverCountError`]: false, + }) +} +``` + +- [ ] **Step 2: Add WXML field** + +Inside each non-misc report item, place this between `材料库存批次号` and the readonly product grid: + +```xml + + 换料次数 + + +``` + +Add watermarks: + +```xml +连续模 +``` + +- [ ] **Step 3: Add report form CSS** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxss`, add: + +```css +.continuous-item-stamp { + position: absolute; + right: 20rpx; + top: 20rpx; + transform: rotate(12deg); + padding: 6rpx 12rpx; + border-radius: 6rpx; + border: 2rpx solid rgba(194, 107, 0, 0.55); + color: #b45f00; + background: rgba(255, 244, 224, 0.9); + font-size: 22rpx; + font-weight: 700; +} + +.lower-item-stamp { + top: 72rpx; +} +``` + +--- + +### Task 6: Admin Review, Records, Dashboard, QR, And Result UI + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/deviceQr/deviceQr.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/deviceQr/deviceQr.wxss` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.wxml` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.wxss` + +- [ ] **Step 1: Review page JS** + +In `makeEditItem`, include: + +```javascript +isContinuousDie: !!detail.isContinuousDie, +changeoverCount: detail.changeoverCount || 0, +changeoverCountError: false, +``` + +In `productToEditItem`, after product replacement: + +```javascript +isContinuousDie: !!product.isContinuousDie, +changeoverCount: product.isContinuousDie ? (item.changeoverCount || 0) : 0, +``` + +In `restoreProduct`, carry: + +```javascript +isContinuousDie: product.isContinuousDie === undefined ? original.isContinuousDie : product.isContinuousDie, +changeoverCount: product.isContinuousDie ? (item.changeoverCount || 0) : 0, +``` + +In `onItemInput`, recalculate only quantity fields as today, but allow `changeoverCount` to update and clear error: + +```javascript +if (field === 'changeoverCount') { + updates[`rows[${reportIndex}].editItems[${itemIndex}].changeoverCountError`] = false +} +``` + +In `approve`, validate: + +```javascript +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) { + const reportIndex = this.data.rows.findIndex(item => String(item.id) === String(id)) + this.setData({ + [`rows[${reportIndex}].editItems[${invalidChangeoverItemIndex}].changeoverCountError`]: true, + }) + wx.showToast({ title: '请填写非负整数换料次数', icon: 'none' }) + return +} +``` + +In approve patch item corrections, include: + +```javascript +changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0, +``` + +- [ ] **Step 2: Review page WXML** + +Add report-level watermark: + +```xml +连续模 +``` + +Add detail-level watermark: + +```xml +连续模 +``` + +Add display under non-cleaning/non-misc detail metrics: + +```xml + + 换料次数 + {{detail.changeoverCount || 0}} + +``` + +Add editable field in the review form grid: + +```xml + + 换料次数 + + +``` + +In product replacement modal cards, add: + +```xml +连续模 +``` + +- [ ] **Step 3: Records page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`, add report and detail watermarks plus a detail metric: + +```xml +连续模 +连续模 + + 换料次数 + {{detail.changeoverCount || 0}} + +``` + +- [ ] **Step 4: Dashboard page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`, add: + +```xml +连续模 + + 换料次数 + {{item.changeoverCount || 0}} + +``` + +- [ ] **Step 5: QR page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/deviceQr/deviceQr.wxml`, add tags in single-select and batch-select rows: + +```xml +连续模 +``` + +- [ ] **Step 6: Report result page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.js`, include item result fields: + +```javascript +isContinuousDie: !!item.isContinuousDie, +changeoverCount: item.changeoverCount || 0, +``` + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportResult/reportResult.wxml`, add: + +```xml +连续模 + + 换料次数 + {{result.changeoverCount || 0}} + +``` + +- [ ] **Step 7: Shared CSS pattern** + +Add orange continuous die styles to each page CSS touched in this task: + +```css +.continuous-die-watermark, +.detail-continuous-stamp, +.result-continuous-stamp { + position: absolute; + right: 24rpx; + top: 24rpx; + transform: rotate(12deg); + padding: 6rpx 12rpx; + border-radius: 6rpx; + border: 2rpx solid rgba(194, 107, 0, 0.55); + color: #b45f00; + background: rgba(255, 244, 224, 0.9); + font-size: 22rpx; + font-weight: 700; +} + +.continuous-tag { + color: #b45f00; + background: #fff4e0; +} +``` + +Use the class names shown in this task's WXML snippets. If a page already has `.lower-watermark`, use that existing class for vertical offset; otherwise add only the continuous die classes shown above. + +--- + +### Task 7: Manual Verification And Release Steps + +**Files:** +- Verify changed frontend files above. +- Verify backend files above. + +- [ ] **Step 1: Run backend migration locally** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python scripts/migrate_continuous_die_changeover_count.py +``` + +Expected output: + +```text +migrate_continuous_die_changeover_count done +``` + +- [ ] **Step 2: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_continuous_die.py tests/test_metrics.py tests/test_review_corrections.py -q +``` + +Expected: all tests PASS. + +- [ ] **Step 3: Product list verification** + +Manual test in WeChat devtools: +- Open 产品清单. +- Click 新增产品. +- Click 冲压方式 input. +- Verify suggestions show only `清洗` and `连续模`. +- Select `连续模`. +- Verify 标准节拍 is still visible and required. +- Save a product with `冲压方式= 连续模 ` including spaces. +- Reopen the product and verify it is recognized as `连续模`. +- Verify product card shows orange `连续模` watermark. + +- [ ] **Step 4: Worker reporting verification** + +Manual test: +- Scan a normal single-person `连续模` product QR. +- Start work. +- Finish work. +- In 下班报工, verify the product card shows `连续模` watermark. +- Verify `换料次数` input appears between `材料库存批次号` and product readonly information. +- Try submitting empty value. Expected: toast `请填写非负整数换料次数`. +- Enter `2`, fill production quantity and device, submit. +- Verify result page shows `换料次数 2`. + +- [ ] **Step 5: Admin review verification** + +Manual test: +- Open 报工审核. +- Expand the continuous die report. +- Verify detail card shows `连续模` watermark and `换料次数`. +- Change `换料次数` from `2` to `3`. +- Approve. +- Reopen record and verify: + - Card has `更改` watermark. + - Correction display shows `换料次数 2 -> 3`. + - Work time, standard workload, actual beat still recalculate from time and quantity only. + +- [ ] **Step 6: Manager dashboard and export verification** + +Manual test: +- Open 经理报工看板. +- Find the approved continuous die row. +- Verify orange `连续模` watermark. +- Verify `换料次数 3` displays. +- Export Excel. +- Verify first sheet has `换料次数` column. +- Verify the row has `报工标记=连续模`. +- Verify `参考工资` remains `成品数量 * 工序单价`, without adding changeover pay. + +- [ ] **Step 7: QR verification** + +Manual test: +- Open 模具二维码. +- Search the continuous die product. +- Verify single-select and batch modal rows show `连续模` tag. +- Generate QR. +- Verify bottom annotation includes product name / process / stamping method; for continuous die it includes `连续模`. + +- [ ] **Step 8: Server release** + +Backend release order: + +```bash +cd /software/JhHardwareWRS_BackPoint +git pull +.venv/bin/python scripts/migrate_continuous_die_changeover_count.py +``` + +If backend is running with reload, verify logs show reload. If not, restart the backend service. + +Frontend release: +- Upload WeChat mini-program version after devtools verification. +- This feature needs frontend release because worker/admin/manager pages all need new visible fields. + +--- + +## Self-Review + +Spec coverage: +- `连续模` special judgment by trimmed exact stamping method: Task 1 and Task 3. +- Product `冲压方式` remains free input with suggested `清洗` and `连续模`: Task 4. +- Continuous die watermark in product list, QR, report, review, records, dashboard, result: Tasks 4 and 6. +- Worker report requires `换料次数`: Task 5. +- Admin review displays and can edit `换料次数`: Task 6 plus backend Task 2. +- Manager dashboard and Excel export carry `换料次数`: Task 2 and Task 6. +- Dashboard aggregation sums `换料次数`: Task 2. +- No forced dictionary-maintenance page: preserved by Task 4. +- Wage calculation is not changed because no changeover unit price exists: File Map non-goal and Task 7. + +Placeholder scan: +- No unresolved placeholder markers remain. +- Every modified behavior has a concrete file target and exact code shape or verification step. + +Type consistency: +- Backend snake_case: `changeover_count`, `is_continuous_die`. +- Frontend camelCase: `changeoverCount`, `isContinuousDie`. +- Display label: `换料次数`. diff --git a/docs/superpowers/plans/2026-06-23-usage-stats.md b/docs/superpowers/plans/2026-06-23-usage-stats.md new file mode 100644 index 0000000..50c22a7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-usage-stats.md @@ -0,0 +1,1689 @@ +# Equipment And Mold Usage Statistics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an admin/manager workbench module that ranks equipment and mold usage with horizontal bar charts, date filters, detail drill-down, and Excel export. + +**Architecture:** Build usage statistics as read-only derived data from approved, non-voided production reports. Backend aggregation lives in a focused service and exposes summary, detail, and export endpoints; frontend adds one summary page and one detail page following the existing mini-program card/pager/date-filter patterns. No database migration is required for this first version. + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, openpyxl, WeChat Mini Program WXML/WXSS/JS. + +--- + +## Confirmed Product Decisions + +- Module name: `设备模具使用统计`. +- Entry points: admin workbench and manager workbench. +- Permissions: + - Admin sees only data for their accessible attendance points. + - Manager sees all attendance points. +- Default dataset: only `ProductionReport.status == approved` and `ProductionReport.is_voided == false`. +- Layout: A + C. + - Summary page: `设备 / 模具` segmented switch, filters, sort switch, horizontal bar ranking. + - Detail page: daily trend bars and related report summaries. +- Equipment metrics: + - Press equipment: usage minutes + report count. + - Cleaning equipment: cleaning quantity + report count. +- Cleaning multi-equipment rule: + - Current system stores one cleaning item with `device_no = "清洗机A、清洗机B"` and one total `good_qty`. + - First version splits that quantity evenly across selected cleaning equipment. + - Example: `清洗机A、清洗机B` with `good_qty=100` contributes `50` to each device and `+1` report count to each device. +- Mold dimension: + - `attendance_point_name + product_name + process_name + stamping_method`. + - Normal/continuous/multi-person mold: usage minutes + report count. + - Cleaning mold: cleaning quantity + report count. + - Misc work is excluded from mold statistics. +- Sorting: + - Default `sort_by=value`: press/normal rows sort by minutes; cleaning rows sort by quantity. + - Alternate `sort_by=count`: sorts by report count. +- Export: + - Excel with two sheets: `设备统计`, `模具统计`. + +## File Map + +Backend: +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` + +Frontend: +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json` + +Version-control note: +- The current workspace may not be a git repository. If `git status` works in the execution environment, commit after each task. If `git status` fails with “not a git repository”, skip commit steps and report that the workspace has no git metadata. + +--- + +### Task 1: Backend Usage Statistics Aggregation Service + +**Files:** +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` + +- [ ] **Step 1: Write failing tests for aggregation rules** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` with these tests: + +```python +from datetime import date +from types import SimpleNamespace + +from app.services.usage_stats import ( + UsageStatAccumulator, + build_usage_stats, + split_cleaning_device_nos, +) + + +def _report( + *, + report_id=1, + report_date=date(2026, 6, 23), + point="嘉恒", + employee_name="张三", + employee_phone="13900000000", + items=None, +): + return SimpleNamespace( + id=report_id, + attendance_point_name=point, + report_date=report_date, + employee_phone=employee_phone, + employee=SimpleNamespace(name=employee_name), + items=items or [], + ) + + +def _item( + *, + item_id=1, + point="嘉恒", + device_no="28#", + project_no="P001", + product_name="测试产品", + process_name="1", + stamping_method="普通", + allocated_minutes=60, + good_qty=10, + operator_count=1, +): + return SimpleNamespace( + id=item_id, + attendance_point_name=point, + device_no=device_no, + project_no=project_no, + product_name=product_name, + process_name=process_name, + stamping_method=stamping_method, + allocated_minutes=allocated_minutes, + good_qty=good_qty, + operator_count=operator_count, + ) + + +def test_split_cleaning_device_nos_supports_cn_and_ascii_separators(): + assert split_cleaning_device_nos("清洗机A、清洗机B, 清洗机C") == ["清洗机A", "清洗机B", "清洗机C"] + assert split_cleaning_device_nos("") == [] + assert split_cleaning_device_nos(None) == [] + + +def test_press_equipment_stats_use_allocated_minutes_and_report_count(): + item = _item(device_no="28#", allocated_minutes=75, good_qty=12) + rows = build_usage_stats( + reports=[_report(items=[item])], + category="device", + equipment_type_by_key={("嘉恒", "28#"): "冲压设备"}, + ) + + assert len(rows) == 1 + row = rows[0] + assert row.category == "device" + assert row.object_type == "冲压设备" + assert row.name == "28#" + assert row.metric_kind == "minutes" + assert row.value == 75 + assert row.report_count == 1 + + +def test_cleaning_equipment_stats_split_quantity_across_selected_devices(): + item = _item(device_no="清洗机A、清洗机B", stamping_method="清洗", allocated_minutes=0, good_qty=100) + rows = build_usage_stats( + reports=[_report(items=[item])], + category="device", + equipment_type_by_key={ + ("嘉恒", "清洗机A"): "清洗设备", + ("嘉恒", "清洗机B"): "清洗设备", + }, + ) + + assert [(row.name, row.value, row.report_count, row.metric_kind) for row in rows] == [ + ("清洗机A", 50, 1, "quantity"), + ("清洗机B", 50, 1, "quantity"), + ] + + +def test_mold_stats_group_by_attendance_product_process_and_stamping_method(): + first = _item(item_id=1, product_name="测试", process_name="1", stamping_method="连续模", allocated_minutes=30) + second = _item(item_id=2, product_name="测试", process_name="1", stamping_method="连续模", allocated_minutes=45) + rows = build_usage_stats( + reports=[_report(items=[first, second])], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + row = rows[0] + assert row.category == "mold" + assert row.attendance_point_name == "嘉恒" + assert row.product_name == "测试" + assert row.process_name == "1" + assert row.stamping_method == "连续模" + assert row.metric_kind == "minutes" + assert row.value == 75 + assert row.report_count == 2 + assert "连续模" in row.tags + + +def test_cleaning_mold_stats_use_cleaning_quantity_not_minutes(): + item = _item(product_name="清洗产品", process_name="1", stamping_method="清洗", allocated_minutes=0, good_qty=88) + rows = build_usage_stats( + reports=[_report(items=[item])], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].metric_kind == "quantity" + assert rows[0].value == 88 + assert rows[0].report_count == 1 +``` + +- [ ] **Step 2: Run tests and verify they fail because service does not exist** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py -q +``` + +Expected: + +```text +ModuleNotFoundError: No module named 'app.services.usage_stats' +``` + +- [ ] **Step 3: Implement the usage stats service** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`: + +```python +from dataclasses import dataclass, field +from datetime import date +import re + +from app.services.cleaning import is_cleaning_item +from app.services.common import as_float, round2 +from app.services.continuous_die import is_continuous_die_item +from app.services.misc_work import is_misc_item +from app.services.multi_person import is_multi_person_item + + +def split_cleaning_device_nos(value: str | None) -> list[str]: + result: list[str] = [] + for part in re.split(r"[、,,\s]+", str(value or "").strip()): + text = part.strip() + if text and text not in result: + result.append(text) + return result + + +@dataclass +class UsageStatRow: + id: str + category: str + attendance_point_name: str + name: str + object_type: str + metric_kind: str + value: float = 0 + report_count: int = 0 + product_name: str = "" + process_name: str = "" + stamping_method: str = "" + tags: list[str] = field(default_factory=list) + + +class UsageStatAccumulator: + def __init__( + self, + *, + category: str, + attendance_point_name: str, + name: str, + object_type: str, + metric_kind: str, + product_name: str = "", + process_name: str = "", + stamping_method: str = "", + tags: list[str] | None = None, + ) -> None: + identity_parts = [ + category, + attendance_point_name, + name, + product_name, + process_name, + stamping_method, + metric_kind, + ] + self.row = UsageStatRow( + id="||".join(str(part or "") for part in identity_parts), + category=category, + attendance_point_name=attendance_point_name, + name=name, + object_type=object_type, + metric_kind=metric_kind, + product_name=product_name, + process_name=process_name, + stamping_method=stamping_method, + tags=tags or [], + ) + + def add(self, value: float) -> None: + self.row.value += as_float(value) + self.row.report_count += 1 + + +def _item_tags(item) -> list[str]: + tags: list[str] = [] + if is_cleaning_item(item): + tags.append("清洗") + if is_continuous_die_item(item): + tags.append("连续模") + if is_multi_person_item(item): + tags.append("多人协作") + return tags + + +def _mold_object_type(item) -> str: + if is_cleaning_item(item): + return "清洗模具" + if is_continuous_die_item(item): + return "连续模" + if is_multi_person_item(item): + return "多人工序" + return "普通模具" + + +def build_usage_stats( + *, + reports: list, + category: str, + equipment_type_by_key: dict[tuple[str, str], str], +) -> list[UsageStatRow]: + groups: dict[tuple, UsageStatAccumulator] = {} + + for report in reports: + for item in report.items: + if is_misc_item(item): + continue + point_name = str(item.attendance_point_name or report.attendance_point_name or "").strip() + cleaning = is_cleaning_item(item) + if category == "device": + if cleaning: + device_nos = split_cleaning_device_nos(item.device_no) + if not device_nos: + continue + split_qty = as_float(item.good_qty) / len(device_nos) + for device_no in device_nos: + object_type = equipment_type_by_key.get((point_name, device_no), "清洗设备") + key = ("device", point_name, device_no, "quantity") + groups.setdefault( + key, + UsageStatAccumulator( + category="device", + attendance_point_name=point_name, + name=device_no, + object_type=object_type, + metric_kind="quantity", + tags=["清洗设备"], + ), + ).add(split_qty) + else: + device_no = str(item.device_no or "").strip() + if not device_no: + continue + object_type = equipment_type_by_key.get((point_name, device_no), "冲压设备") + key = ("device", point_name, device_no, "minutes") + groups.setdefault( + key, + UsageStatAccumulator( + category="device", + attendance_point_name=point_name, + name=device_no, + object_type=object_type, + metric_kind="minutes", + tags=[object_type], + ), + ).add(as_float(item.allocated_minutes)) + continue + + if category == "mold": + product_name = str(item.product_name or "").strip() + process_name = str(item.process_name or "").strip() + stamping_method = str(item.stamping_method or "").strip() + if not product_name or not process_name: + continue + metric_kind = "quantity" if cleaning else "minutes" + key = ("mold", point_name, product_name, process_name, stamping_method, metric_kind) + groups.setdefault( + key, + UsageStatAccumulator( + category="mold", + attendance_point_name=point_name, + name=f"{product_name} / {process_name} / {stamping_method or '-'}", + object_type=_mold_object_type(item), + metric_kind=metric_kind, + product_name=product_name, + process_name=process_name, + stamping_method=stamping_method, + tags=_item_tags(item), + ), + ).add(as_float(item.good_qty) if cleaning else as_float(item.allocated_minutes)) + + rows = [item.row for item in groups.values()] + for row in rows: + row.value = round2(row.value) + return rows +``` + +- [ ] **Step 4: Run aggregation tests and verify they pass** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py -q +``` + +Expected: + +```text +5 passed +``` + +--- + +### Task 2: Backend API Schemas And Router + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` + +- [ ] **Step 1: Extend tests for endpoint helper behavior** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`: + +```python +from app.routers.usage_stats import _sort_usage_rows + + +def test_usage_rows_sort_by_value_then_count_then_name(): + rows = [ + UsageStatRow(id="b", category="device", attendance_point_name="嘉恒", name="B", object_type="冲压设备", metric_kind="minutes", value=20, report_count=9), + UsageStatRow(id="a", category="device", attendance_point_name="嘉恒", name="A", object_type="冲压设备", metric_kind="minutes", value=30, report_count=1), + ] + + assert [row.name for row in _sort_usage_rows(rows, "value")] == ["A", "B"] + assert [row.name for row in _sort_usage_rows(rows, "count")] == ["B", "A"] +``` + +- [ ] **Step 2: Run test and verify it fails because router does not exist** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py::test_usage_rows_sort_by_value_then_count_then_name -q +``` + +Expected: + +```text +ModuleNotFoundError: No module named 'app.routers.usage_stats' +``` + +- [ ] **Step 3: Add usage stats schemas** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add these classes near `DashboardRow`: + +```python +class UsageStatsRow(BaseModel): + id: str + category: str + attendance_point_name: str = "" + name: str + object_type: str + metric_kind: str + value: float = 0 + report_count: int = 0 + product_name: str = "" + process_name: str = "" + stamping_method: str = "" + tags: list[str] = Field(default_factory=list) + + +class UsageStatsDailyRow(BaseModel): + report_date: date + value: float = 0 + report_count: int = 0 + + +class UsageStatsReportRow(BaseModel): + report_id: int + report_date: date + employee_phone: str = "" + employee_name: str = "" + display_name: str = "" + value: float = 0 + report_count: int = 1 + + +class UsageStatsDetailOut(BaseModel): + category: str + attendance_point_name: str = "" + name: str + object_type: str = "" + metric_kind: str + value: float = 0 + report_count: int = 0 + daily_rows: list[UsageStatsDailyRow] = Field(default_factory=list) + report_rows: list[UsageStatsReportRow] = Field(default_factory=list) +``` + +- [ ] **Step 4: Create usage stats router** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`: + +```python +from datetime import date +from math import ceil + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.database import get_db +from app.deps import require_roles +from app.models import Equipment, Personnel, ProductionReport, ReportStatus, Role +from app.schemas import PageResponse, UsageStatsDetailOut, UsageStatsDailyRow, UsageStatsReportRow, UsageStatsRow +from app.services.attendance_points import accessible_point_names, require_attendance_point_access +from app.services.common import round2 +from app.services.report_lifecycle import purge_expired_voided_reports +from app.services.usage_stats import UsageStatRow, build_usage_stats +from app.services.usage_stats_export import export_usage_stats_rows + +router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"]) + + +def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]: + rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all() + return {(row.attendance_point_name, row.device_no): row.device_type for row in rows} + + +def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None): + query = ( + select(ProductionReport) + .options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items)) + .where( + ProductionReport.status == ReportStatus.approved, + ProductionReport.is_voided.is_(False), + ProductionReport.attendance_point_name.in_(point_names), + ) + ) + if start_date: + query = query.where(ProductionReport.report_date >= start_date) + if end_date: + query = query.where(ProductionReport.report_date <= end_date) + return query + + +def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]: + if sort_by == "count": + return sorted(rows, key=lambda row: (row.report_count, row.value, row.name), reverse=True) + return sorted(rows, key=lambda row: (row.value, row.report_count, row.name), reverse=True) + + +def _to_schema_row(row: UsageStatRow) -> UsageStatsRow: + return UsageStatsRow( + id=row.id, + category=row.category, + attendance_point_name=row.attendance_point_name, + name=row.name, + object_type=row.object_type, + metric_kind=row.metric_kind, + value=row.value, + report_count=row.report_count, + product_name=row.product_name, + process_name=row.process_name, + stamping_method=row.stamping_method, + tags=row.tags, + ) + + +def _requested_points(db: Session, user: Personnel, attendance_point_name: str = "") -> list[str]: + if attendance_point_name: + return [require_attendance_point_access(db, user, attendance_point_name)] + return accessible_point_names(db, user) + + +@router.get("/summary", response_model=PageResponse) +def usage_stats_summary( + category: str = Query("device", pattern="^(device|mold)$"), + start_date: date | None = None, + end_date: date | None = None, + attendance_point_name: str = "", + sort_by: str = Query("value", pattern="^(value|count)$"), + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100), + user: Personnel = Depends(require_roles(Role.admin, Role.manager)), + db: Session = Depends(get_db), +) -> PageResponse: + purge_expired_voided_reports(db) + point_names = _requested_points(db, user, attendance_point_name) + reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() + rows = _sort_usage_rows( + build_usage_stats( + reports=reports, + category=category, + equipment_type_by_key=_equipment_type_map(db, point_names), + ), + sort_by, + ) + safe_page_size = min(100, max(1, page_size)) + total = len(rows) + total_pages = max(1, ceil(total / safe_page_size)) + start = (page - 1) * safe_page_size + return PageResponse( + page=page, + page_size=safe_page_size, + total=total, + total_pages=total_pages, + rows=[_to_schema_row(row) for row in rows[start:start + safe_page_size]], + ) + + +@router.get("/detail", response_model=UsageStatsDetailOut) +def usage_stats_detail( + category: str = Query("device", pattern="^(device|mold)$"), + start_date: date | None = None, + end_date: date | None = None, + attendance_point_name: str = "", + name: str = "", + product_name: str = "", + process_name: str = "", + stamping_method: str = "", + user: Personnel = Depends(require_roles(Role.admin, Role.manager)), + db: Session = Depends(get_db), +) -> UsageStatsDetailOut: + purge_expired_voided_reports(db) + point_names = _requested_points(db, user, attendance_point_name) + reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() + rows = build_usage_stats( + reports=reports, + category=category, + equipment_type_by_key=_equipment_type_map(db, point_names), + ) + target = next( + ( + row for row in rows + if row.attendance_point_name == attendance_point_name + and (row.name == name or not name) + and (category == "device" or ( + row.product_name == product_name + and row.process_name == process_name + and row.stamping_method == stamping_method + )) + ), + None, + ) + if target is None: + return UsageStatsDetailOut(category=category, attendance_point_name=attendance_point_name, name=name, metric_kind="minutes") + + daily: dict[date, UsageStatsDailyRow] = {} + report_rows: list[UsageStatsReportRow] = [] + for report in reports: + partial = build_usage_stats( + reports=[report], + category=category, + equipment_type_by_key=_equipment_type_map(db, point_names), + ) + match = next((row for row in partial if row.id == target.id), None) + if match is None: + continue + day = daily.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date)) + day.value = round2(day.value + match.value) + day.report_count += match.report_count + report_rows.append( + UsageStatsReportRow( + report_id=report.id, + report_date=report.report_date, + employee_phone=report.employee_phone, + employee_name=report.employee.name if report.employee else "", + display_name=target.name, + value=match.value, + report_count=match.report_count, + ) + ) + + return UsageStatsDetailOut( + category=target.category, + attendance_point_name=target.attendance_point_name, + name=target.name, + object_type=target.object_type, + metric_kind=target.metric_kind, + value=target.value, + report_count=target.report_count, + daily_rows=sorted(daily.values(), key=lambda row: row.report_date), + report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True), + ) + + +@router.get("/export") +def usage_stats_export( + start_date: date | None = None, + end_date: date | None = None, + attendance_point_name: str = "", + user: Personnel = Depends(require_roles(Role.admin, Role.manager)), + db: Session = Depends(get_db), +) -> Response: + purge_expired_voided_reports(db) + point_names = _requested_points(db, user, attendance_point_name) + reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all() + equipment_type_by_key = _equipment_type_map(db, point_names) + device_rows = _sort_usage_rows(build_usage_stats(reports=reports, category="device", equipment_type_by_key=equipment_type_by_key), "value") + mold_rows = _sort_usage_rows(build_usage_stats(reports=reports, category="mold", equipment_type_by_key=equipment_type_by_key), "value") + content = export_usage_stats_rows(device_rows, mold_rows) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'}, + ) +``` + +- [ ] **Step 5: Register router in main app** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py`: + +Add import: + +```python +from app.routers import usage_stats +``` + +Add router registration next to other routers: + +```python +app.include_router(usage_stats.router) +``` + +- [ ] **Step 6: Run backend tests for usage stats** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py -q +``` + +Expected: + +```text +6 passed +``` + +--- + +### Task 3: Excel Export + +**Files:** +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` + +- [ ] **Step 1: Add export test** + +Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`: + +```python +from io import BytesIO + +from openpyxl import load_workbook + +from app.services.usage_stats_export import export_usage_stats_rows + + +def test_export_usage_stats_rows_contains_device_and_mold_sheets(): + device_rows = [ + UsageStatRow(id="d1", category="device", attendance_point_name="嘉恒", name="28#", object_type="冲压设备", metric_kind="minutes", value=120, report_count=3) + ] + mold_rows = [ + UsageStatRow( + id="m1", + category="mold", + attendance_point_name="嘉恒", + name="测试 / 1 / 连续模", + object_type="连续模", + metric_kind="minutes", + value=80, + report_count=2, + product_name="测试", + process_name="1", + stamping_method="连续模", + ) + ] + + workbook = load_workbook(BytesIO(export_usage_stats_rows(device_rows, mold_rows))) + + assert workbook.sheetnames == ["设备统计", "模具统计"] + assert [cell.value for cell in workbook["设备统计"][1]] == ["考勤点", "设备号", "设备类型", "使用时长", "清洗数量", "报工次数"] + assert [cell.value for cell in workbook["设备统计"][2]] == ["嘉恒", "28#", "冲压设备", 120, 0, 3] + assert [cell.value for cell in workbook["模具统计"][1]] == ["考勤点", "产品名称", "工序", "冲压方式", "使用时长", "清洗数量", "报工次数"] + assert [cell.value for cell in workbook["模具统计"][2]] == ["嘉恒", "测试", "1", "连续模", 80, 0, 2] +``` + +- [ ] **Step 2: Run export test and verify it fails** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py::test_export_usage_stats_rows_contains_device_and_mold_sheets -q +``` + +Expected: + +```text +ModuleNotFoundError: No module named 'app.services.usage_stats_export' +``` + +- [ ] **Step 3: Implement export helper** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py`: + +```python +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill +from openpyxl.utils import get_column_letter + + +HEADER_FILL = PatternFill("solid", fgColor="DCEBFF") +HEADER_FONT = Font(bold=True, color="1F2A44") + + +def _style_sheet(sheet) -> None: + for cell in sheet[1]: + cell.fill = HEADER_FILL + cell.font = HEADER_FONT + for column in range(1, sheet.max_column + 1): + sheet.column_dimensions[get_column_letter(column)].width = 18 + + +def _usage_minutes(row) -> float: + return row.value if row.metric_kind == "minutes" else 0 + + +def _cleaning_quantity(row) -> float: + return row.value if row.metric_kind == "quantity" else 0 + + +def export_usage_stats_rows(device_rows: list, mold_rows: list) -> bytes: + workbook = Workbook() + device_sheet = workbook.active + device_sheet.title = "设备统计" + device_sheet.append(["考勤点", "设备号", "设备类型", "使用时长", "清洗数量", "报工次数"]) + for row in device_rows: + device_sheet.append([ + row.attendance_point_name, + row.name, + row.object_type, + _usage_minutes(row), + _cleaning_quantity(row), + row.report_count, + ]) + _style_sheet(device_sheet) + + mold_sheet = workbook.create_sheet("模具统计") + mold_sheet.append(["考勤点", "产品名称", "工序", "冲压方式", "使用时长", "清洗数量", "报工次数"]) + for row in mold_rows: + mold_sheet.append([ + row.attendance_point_name, + row.product_name, + row.process_name, + row.stamping_method, + _usage_minutes(row), + _cleaning_quantity(row), + row.report_count, + ]) + _style_sheet(mold_sheet) + + output = BytesIO() + workbook.save(output) + return output.getvalue() +``` + +- [ ] **Step 4: Run export test and verify it passes** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py::test_export_usage_stats_rows_contains_device_and_mold_sheets -q +``` + +Expected: + +```text +1 passed +``` + +--- + +### Task 4: Frontend API Mapping + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` + +- [ ] **Step 1: Add mapping helpers** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add near dashboard mapping helpers: + +```javascript +const toCamelUsageStatsRow = row => ({ + id: row.id, + category: row.category, + attendancePointName: row.attendance_point_name || '', + name: row.name || '', + objectType: row.object_type || '', + metricKind: row.metric_kind || 'minutes', + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), + productName: row.product_name || '', + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + tags: row.tags || [], +}) + +const toCamelUsageStatsDetail = data => ({ + category: data.category, + attendancePointName: data.attendance_point_name || '', + name: data.name || '', + objectType: data.object_type || '', + metricKind: data.metric_kind || 'minutes', + value: Number(data.value || 0), + reportCount: Number(data.report_count || 0), + dailyRows: (data.daily_rows || []).map(row => ({ + reportDate: row.report_date, + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), + })), + reportRows: (data.report_rows || []).map(row => ({ + reportId: row.report_id, + reportDate: row.report_date, + employeePhone: row.employee_phone || '', + employeeName: row.employee_name || '', + displayName: row.display_name || '', + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), + })), +}) +``` + +- [ ] **Step 2: Add API calls** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add: + +```javascript +const listUsageStats = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/usage-stats/summary', + params: { + category: filters.category || 'device', + start_date: filters.startDate, + end_date: filters.endDate, + attendance_point_name: filters.attendancePointName, + sort_by: filters.sortBy || 'value', + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelUsageStatsRow) +} + +const getUsageStatsDetail = async (filters = {}) => toCamelUsageStatsDetail(await requestClient.request({ + url: '/api/usage-stats/detail', + params: { + category: filters.category || 'device', + start_date: filters.startDate, + end_date: filters.endDate, + attendance_point_name: filters.attendancePointName, + name: filters.name, + product_name: filters.productName, + process_name: filters.processName, + stamping_method: filters.stampingMethod, + }, +})) + +const exportUsageStats = filters => requestClient.download({ + url: '/api/usage-stats/export', + params: { + start_date: filters && filters.startDate, + end_date: filters && filters.endDate, + attendance_point_name: filters && filters.attendancePointName, + }, +}) +``` + +- [ ] **Step 3: Export API functions** + +At the bottom `module.exports` object in `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add: + +```javascript +listUsageStats, +getUsageStatsDetail, +exportUsageStats, +``` + +- [ ] **Step 4: Run JS syntax check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check utils/api.js +``` + +Expected: exit code `0`. + +--- + +### Task 5: Frontend Summary Page + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json` +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json` + +- [ ] **Step 1: Register page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`, add this page before `pages/dashboard/dashboard`: + +```json +"pages/usageStats/usageStats", +``` + +- [ ] **Step 2: Add workbench entries** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js`, add this action to both admin and manager action arrays: + +```javascript +{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' }, +``` + +- [ ] **Step 3: Create page JSON** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json`: + +```json +{ + "navigationBarTitleText": "使用统计" +} +``` + +- [ ] **Step 4: Create summary page JS** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js`: + +```javascript +const api = require('../../utils/api') + +const todayDate = () => { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +const monthStartDate = () => { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + return `${year}-${month}-01` +} + +Page({ + data: { + category: 'device', + sortBy: 'value', + startDate: monthStartDate(), + endDate: todayDate(), + attendancePoints: [], + attendancePointLabels: ['全部考勤点'], + attendancePointIndex: 0, + attendancePointName: '', + rows: [], + page: 1, + pageInput: '1', + totalPages: 1, + refreshing: false, + }, + onLoad() { + this.init() + }, + async init() { + await this.loadAttendancePoints() + await this.load(1) + }, + async loadAttendancePoints() { + const points = await api.listAccessibleAttendancePoints() + this.setData({ + attendancePoints: points || [], + attendancePointLabels: ['全部考勤点'].concat((points || []).map(item => item.name)), + }) + }, + async load(page = this.data.page) { + 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, + page, + pageSize: 10, + }) + const maxValue = Math.max(1, ...(result.rows || []).map(item => Number(item.value || 0))) + this.setData({ + rows: (result.rows || []).map(item => ({ + ...item, + barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxValue * 100)), + valueText: item.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`, + primarySortLabel: item.metricKind === 'quantity' ? '按清洗数量' : '按使用时长', + })), + page: result.page, + pageInput: String(result.page || 1), + totalPages: result.totalPages, + }) + }, + async onPullDownRefresh() { + this.setData({ refreshing: true }) + try { + await this.load(1) + } finally { + this.setData({ refreshing: false }) + if (wx.stopPullDownRefresh) { + wx.stopPullDownRefresh() + } + } + }, + chooseCategory(e) { + this.setData({ category: e.currentTarget.dataset.category, page: 1 }) + this.load(1) + }, + chooseSort(e) { + this.setData({ sortBy: e.currentTarget.dataset.sort, 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 index = Number(e.detail.value) + const point = index > 0 ? this.data.attendancePoints[index - 1] : null + this.setData({ + attendancePointIndex: index, + attendancePointName: point ? point.name : '', + page: 1, + }) + this.load(1) + }, + openDetail(e) { + const row = this.data.rows[Number(e.currentTarget.dataset.index)] + if (!row) { + return + } + const params = [ + `category=${encodeURIComponent(row.category)}`, + `startDate=${encodeURIComponent(this.data.startDate)}`, + `endDate=${encodeURIComponent(this.data.endDate)}`, + `attendancePointName=${encodeURIComponent(row.attendancePointName)}`, + `name=${encodeURIComponent(row.name)}`, + `productName=${encodeURIComponent(row.productName || '')}`, + `processName=${encodeURIComponent(row.processName || '')}`, + `stampingMethod=${encodeURIComponent(row.stampingMethod || '')}`, + ].join('&') + wx.navigateTo({ url: `/pages/usageStatsDetail/usageStatsDetail?${params}` }) + }, + async exportFile() { + await api.exportUsageStats({ + startDate: this.data.startDate, + endDate: this.data.endDate, + attendancePointName: this.data.attendancePointName, + }) + }, + onPageInput(e) { + this.setData({ pageInput: e.detail.value }) + }, + jumpToPage() { + const next = Math.min(this.data.totalPages, Math.max(1, Number(this.data.pageInput || 1))) + this.load(next) + }, + 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) + } + }, +}) +``` + +- [ ] **Step 5: Create summary WXML** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml`: + +```xml + + + 设备模具使用统计 + 默认统计审核通过且未作废的报工数据 + + + + + {{startDate || '开始日期'}} + + + {{endDate || '结束日期'}} + + + + + {{attendancePointLabels[attendancePointIndex] || '全部考勤点'}} + + + + + + + + + + + + + + + 暂无统计数据 + + + + {{item.name}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.objectType}} · 报工 {{item.reportCount}} 次 + + {{item.metricKind === 'quantity' ? '数量' : '时长'}} + + + + + + {{item.metricKind === 'quantity' ? '清洗数量' : '使用时长'}} + {{item.valueText}} + + + + + + + + / {{totalPages}} + + + + + +``` + +- [ ] **Step 6: Create summary WXSS** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss`: + +```css +.usage-point-picker { + margin-bottom: 18rpx; +} + +.usage-switch-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12rpx; + margin-bottom: 16rpx; + padding: 6rpx; + border-radius: 16rpx; + background: #e8eef7; +} + +.usage-switch { + height: 66rpx; + line-height: 66rpx; + margin: 0; + padding: 0; + border-radius: 14rpx; + color: #475467; + background: transparent; + font-size: 25rpx; + font-weight: 800; +} + +.usage-switch::after { + border: 0; +} + +.usage-switch.active { + color: #ffffff; + background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%); + box-shadow: 0 7rpx 0 #0d3f93; +} + +.sort-row { + margin-bottom: 18rpx; +} + +.export-btn { + margin-bottom: 18rpx; +} + +.usage-card { + position: relative; + overflow: hidden; +} + +.usage-bar-track { + overflow: hidden; + height: 22rpx; + margin-top: 18rpx; + border-radius: 999rpx; + background: #dbe4f0; + box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.12); +} + +.usage-bar-fill { + height: 100%; + min-width: 10rpx; + border-radius: 999rpx; + background: linear-gradient(90deg, #1463ff 0%, #12a375 100%); +} + +.usage-bar-fill.quantity { + background: linear-gradient(90deg, #12a375 0%, #56c596 100%); +} + +.usage-value-row { + margin-top: 14rpx; +} +``` + +- [ ] **Step 7: Run frontend JS syntax check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check pages/usageStats/usageStats.js +``` + +Expected: exit code `0`. + +--- + +### Task 6: Frontend Detail Page + +**Files:** +- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss` +- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json` + +- [ ] **Step 1: Register detail page** + +In `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`, add this page after `pages/usageStats/usageStats`: + +```json +"pages/usageStatsDetail/usageStatsDetail", +``` + +- [ ] **Step 2: Create detail page JSON** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json`: + +```json +{ + "navigationBarTitleText": "统计详情" +} +``` + +- [ ] **Step 3: Create detail page JS** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js`: + +```javascript +const api = require('../../utils/api') + +const decode = value => decodeURIComponent(String(value || '')) + +Page({ + data: { + filters: {}, + detail: null, + maxDailyValue: 1, + }, + onLoad(options) { + const filters = { + category: decode(options.category || 'device'), + startDate: decode(options.startDate || ''), + endDate: decode(options.endDate || ''), + attendancePointName: decode(options.attendancePointName || ''), + name: decode(options.name || ''), + productName: decode(options.productName || ''), + processName: decode(options.processName || ''), + stampingMethod: decode(options.stampingMethod || ''), + } + this.setData({ filters }) + this.load() + }, + async load() { + try { + const detail = await api.getUsageStatsDetail(this.data.filters) + const maxDailyValue = Math.max(1, ...(detail.dailyRows || []).map(item => Number(item.value || 0))) + this.setData({ + detail: { + ...detail, + valueText: detail.metricKind === 'quantity' ? `${detail.value} 件` : `${detail.value} 分钟`, + dailyRows: (detail.dailyRows || []).map(item => ({ + ...item, + barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxDailyValue * 100)), + valueText: detail.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`, + })), + reportRows: (detail.reportRows || []).map(item => ({ + ...item, + valueText: detail.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`, + })), + }, + maxDailyValue, + }) + } catch (error) { + wx.showToast({ title: error.message || '加载统计详情失败', icon: 'none' }) + } + }, +}) +``` + +- [ ] **Step 4: Create detail WXML** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml`: + +```xml + + + {{detail.name || '统计详情'}} + 考勤点 {{detail.attendancePointName || '-'}} + + + + + + {{detail.metricKind === 'quantity' ? '清洗数量' : '使用时长'}} + {{detail.valueText}} + + + 报工次数 + {{detail.reportCount}} + + + + {{detail.objectType}} + + + + 每日趋势 + 暂无每日数据 + + + {{item.reportDate}} + {{item.valueText}} · {{item.reportCount}} 次 + + + + + + + + + 关联报工 + 暂无关联报工 + + {{item.employeeName || item.employeePhone}} · {{item.reportDate}} + {{item.displayName}} + {{item.valueText}} · {{item.reportCount}} 次 + + + +``` + +- [ ] **Step 5: Create detail WXSS** + +Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss`: + +```css +.section-title { + display: block; + margin-bottom: 18rpx; + color: #162232; + font-size: 28rpx; + font-weight: 900; +} + +.daily-row { + padding: 16rpx 0; + border-bottom: 1rpx solid #e4ebf5; +} + +.daily-row:last-child { + border-bottom: 0; +} + +.detail-bar-track { + overflow: hidden; + height: 20rpx; + margin-top: 12rpx; + border-radius: 999rpx; + background: #dbe4f0; +} + +.detail-bar-fill { + height: 100%; + min-width: 10rpx; + border-radius: 999rpx; + background: linear-gradient(90deg, #1463ff 0%, #12a375 100%); +} + +.detail-bar-fill.quantity { + background: linear-gradient(90deg, #12a375 0%, #56c596 100%); +} + +.report-row { + padding: 18rpx 0; + border-bottom: 1rpx solid #e4ebf5; +} + +.report-row:last-child { + border-bottom: 0; +} +``` + +- [ ] **Step 6: Run detail page JS syntax check** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check pages/usageStatsDetail/usageStatsDetail.js +``` + +Expected: exit code `0`. + +--- + +### Task 7: Verification + +**Files:** +- Verify all files changed above. + +- [ ] **Step 1: Run backend tests** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m pytest tests/test_usage_stats.py tests/test_product_delete.py tests/test_continuous_die.py tests/test_metrics.py tests/test_review_corrections.py -q +``` + +Expected: + +```text +all selected tests pass +``` + +- [ ] **Step 2: Run Python compile checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +.venv/bin/python -m compileall app/services/usage_stats.py app/services/usage_stats_export.py app/routers/usage_stats.py app/schemas.py app/main.py +``` + +Expected: exit code `0`. + +- [ ] **Step 3: Run frontend JS checks** + +Run: + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS +node --check utils/api.js +node --check pages/index/index.js +node --check pages/usageStats/usageStats.js +node --check pages/usageStatsDetail/usageStatsDetail.js +``` + +Expected: every command exits `0`. + +- [ ] **Step 4: Manual mini-program verification** + +In WeChat DevTools: + +```text +1. Log in as admin. +2. Open 工作台 -> 设备模具使用统计. +3. Confirm default date range is this month. +4. Confirm admin only sees accessible attendance-point data. +5. Switch 设备 / 模具. +6. Switch 按时长/数量 / 按报工次数. +7. Click one row and confirm detail page opens. +8. Confirm detail daily bars and report summaries show. +9. Click 导出统计 and confirm Excel downloads. +10. Log in as manager and confirm all attendance points are available. +``` + +- [ ] **Step 5: Manual data spot check for cleaning split** + +Use one approved cleaning report with a multi-device item: + +```text +device_no = 清洗机A、清洗机B +good_qty = 100 +``` + +Expected equipment summary: + +```text +清洗机A 清洗数量 +50 报工次数 +1 +清洗机B 清洗数量 +50 报工次数 +1 +``` + +Expected mold summary: + +```text +对应清洗模具 清洗数量 +100 报工次数 +1 +``` + +If no multi-device cleaning report exists, create one in test data first; do not change production history solely for this check. + +--- + +## Self-Review + +Spec coverage: +- Admin/manager workbench entry: Task 5. +- Default approved + not voided: Task 2 `_approved_reports_query`. +- Date filter, attendance-point filter, category switch, sort switch: Task 5. +- Equipment statistics for press and cleaning equipment: Task 1 and Task 2. +- Cleaning multi-device even split: Task 1 and Task 7. +- Mold statistics by attendance point + product + process + stamping method: Task 1 and Task 2. +- Misc work excluded: Task 1. +- Detail drill-down with daily bars and report summaries: Task 2 and Task 6. +- Excel export with two sheets: Task 3 and Task 5. +- No database migration: preserved by architecture and file map. + +Placeholder scan: +- No `TBD`, `TODO`, or open-ended implementation steps remain. +- Every code-producing task names exact files and provides concrete code. + +Type consistency: +- Backend category values: `device`, `mold`. +- Backend metric values: `minutes`, `quantity`. +- Frontend fields: `attendancePointName`, `objectType`, `metricKind`, `reportCount`. +- API params use snake_case only at HTTP boundary. diff --git a/pages/cleaningReport/cleaningReport.js b/pages/cleaningReport/cleaningReport.js new file mode 100644 index 0000000..6e6f1bf --- /dev/null +++ b/pages/cleaningReport/cleaningReport.js @@ -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 }) + } + }, +}) diff --git a/pages/cleaningReport/cleaningReport.json b/pages/cleaningReport/cleaningReport.json new file mode 100644 index 0000000..fe99a54 --- /dev/null +++ b/pages/cleaningReport/cleaningReport.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "清洗报工" +} diff --git a/pages/cleaningReport/cleaningReport.wxml b/pages/cleaningReport/cleaningReport.wxml new file mode 100644 index 0000000..fedcd64 --- /dev/null +++ b/pages/cleaningReport/cleaningReport.wxml @@ -0,0 +1,94 @@ + + + 清洗报工 + 添加本次清洗产品,只填写清洗数量,不计算工时 + + + + + + 已添加清洗产品 + {{items.length}} 项 + 考勤点 {{attendancePointName}} + + 清洗 + + + + 暂无清洗产品 + + + + {{item.displayName || item.moldName}} + 考勤点 {{item.attendancePointName}} + 工序 {{item.processName}} · 冲压方式 {{item.stampingMethod || '-'}} + + 已添加 + + + 清洗设备号 + + {{item.deviceNo || '请选择清洗设备号(可多选)'}} + + 暂无清洗设备,请先在设备管理中维护 + + + 清洗数量 + + + + + + + + + + + + + + + + + + + + + 选择清洗设备 + 已选择 {{pendingDeviceNos.length}} 台 + + + + + + + + + + + + + + diff --git a/pages/cleaningReport/cleaningReport.wxss b/pages/cleaningReport/cleaningReport.wxss new file mode 100644 index 0000000..5f057a4 --- /dev/null +++ b/pages/cleaningReport/cleaningReport.wxss @@ -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; +} diff --git a/pages/clock/clock.js b/pages/clock/clock.js new file mode 100644 index 0000000..797b9ad --- /dev/null +++ b/pages/clock/clock.js @@ -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() {}, +}) diff --git a/pages/clock/clock.json b/pages/clock/clock.json new file mode 100644 index 0000000..8fa5303 --- /dev/null +++ b/pages/clock/clock.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "报工打卡" +} diff --git a/pages/clock/clock.wxml b/pages/clock/clock.wxml new file mode 100644 index 0000000..59aaea5 --- /dev/null +++ b/pages/clock/clock.wxml @@ -0,0 +1,108 @@ + + + 报工打卡 + + 考勤点 + {{attendancePointName || '-'}} + 当前模具 + {{moldDisplayName || moldName || '-'}} + + + + + + + + 上班打卡时间 + {{state.startAtText || '-'}} + + + 下班打卡时间 + {{state.endAtText || '-'}} + + + + + + + + 当前人员 + + {{user.name}} · {{user.phone}} + {{user.role === 'worker' ? '冲压工人' : user.role === 'admin' ? '管理员' : '经理'}} + + 未登录 + + + + + + + + + + 请选择角色 + + + + + + + + + + + 模具已被占用 + {{occupiedDetail.message || '该模具已被其他员工占用,请联系管理员解除占用'}} + + + + + 当前模具码不可用 + + + + {{locationChecking ? '正在校验位置' : '无法打开报工页面'}} + {{locationMessage}} + + + + {{toastMessage}} + + + + + 创建临时工 + {{temporaryWorkerPrompt}} + + 姓名 + + + + + + + + + diff --git a/pages/clock/clock.wxss b/pages/clock/clock.wxss new file mode 100644 index 0000000..78e8175 --- /dev/null +++ b/pages/clock/clock.wxss @@ -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; +} diff --git a/pages/dashboard/dashboard.js b/pages/dashboard/dashboard.js new file mode 100644 index 0000000..f6a2e24 --- /dev/null +++ b/pages/dashboard/dashboard.js @@ -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() + } + }, +}) diff --git a/pages/dashboard/dashboard.json b/pages/dashboard/dashboard.json new file mode 100644 index 0000000..a29e803 --- /dev/null +++ b/pages/dashboard/dashboard.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "报工看板" +} diff --git a/pages/dashboard/dashboard.wxml b/pages/dashboard/dashboard.wxml new file mode 100644 index 0000000..1c581ae --- /dev/null +++ b/pages/dashboard/dashboard.wxml @@ -0,0 +1,136 @@ + + + + + 报工看板 + 同人同日同项目、产品、工序、冲压方式的已审核报工自动合并 + + + 过滤作废 + + + + + + + + + + {{startDate || '开始日期'}} + + + + + + {{endDate || '结束日期'}} + + + + + + 暂无已审核报工 + + 作废 + 更改 + 超时-系统自动提交 + 清洗 + 包含杂活 + 多人协作 + 连续模 + + + {{item.employeeName}} · {{item.reportDate}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.employeePhone}} · {{item.projectNo}} / {{item.productName}} + {{item.processDisplayName || item.productName || '-'}} + {{item.isMisc ? '杂活事项' : '工序'}} {{item.processName || '-'}} · 冲压方式 {{item.stampingMethod || '-'}} · {{item.reportCount}} 条报工 + 材料库存批次号 {{item.rawMaterialBatchNo || '-'}} + {{item.shiftDistributionText}} + 有效工时 {{item.effectiveMinutes}} 分钟 + + + {{pair.label}} + {{pair.oldValue}} + {{pair.newValue}} + + + + 已合并 + + + + + {{item.isMisc ? '处理杂活' : '成品数量'}} + + {{item.isMisc ? '不计产量' : item.totalGood}} + {{item.workloadReason}} + + + + 不良数量 + {{item.totalDefect}} + + + 标准工作量 + {{item.expectedWorkload}} + + + 实际节拍 + + {{item.actualBeat}} 秒 + {{item.beatReason}} + + + + 标准节拍 + {{item.standardBeat}} 秒 + + + 节拍对比 + {{item.paceText}} + + + 工作量对比 + {{item.workloadText}} + + + 工时 + {{item.effectiveMinutes}} 分钟 + + + 材料库存批次号 + {{item.rawMaterialBatchNo || '-'}} + + + 换料次数 + {{item.changeoverCount || 0}} + + + + 备注 + {{item.reviewRemark}} + + + + 参考工资 + ¥{{item.referenceWage || 0}} + + + + + + + + / {{totalPages}} + + + + + diff --git a/pages/dashboard/dashboard.wxss b/pages/dashboard/dashboard.wxss new file mode 100644 index 0000000..dc45135 --- /dev/null +++ b/pages/dashboard/dashboard.wxss @@ -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; +} diff --git a/pages/deviceQr/deviceQr.js b/pages/deviceQr/deviceQr.js new file mode 100644 index 0000000..645a470 --- /dev/null +++ b/pages/deviceQr/deviceQr.js @@ -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, + }) + }, +}) diff --git a/pages/deviceQr/deviceQr.json b/pages/deviceQr/deviceQr.json new file mode 100644 index 0000000..0fd7640 --- /dev/null +++ b/pages/deviceQr/deviceQr.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "模具二维码" +} diff --git a/pages/deviceQr/deviceQr.wxml b/pages/deviceQr/deviceQr.wxml new file mode 100644 index 0000000..6a3463f --- /dev/null +++ b/pages/deviceQr/deviceQr.wxml @@ -0,0 +1,191 @@ + + + 模具二维码 + 选择产品名称、工序和冲压方式生成扫码报工入口 + + + + + + 产品名称 / 工序 / 冲压方式 + + + + + + {{item.moldName || item.name}} + 多人协作 + 连续模 + 考勤点 {{item.attendancePointName || '-'}} + 工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}} + + 未找到模具 + + + + + + + + + {{qrLoadError}} + 模具 {{generatedMoldName}} + 考勤点 {{selectedMold.attendancePointName}} + {{qrPath}} + + + + + + + ZIP任务列表 + 批量二维码后台生成,完成后可转发ZIP + + + + + 暂无ZIP任务 + + + + 删除 + + + + + {{item.fileName}} + 二维码数量 {{item.itemCount}} · {{item.statusName}} + + + {{item.progressPercent}}% + + + + + + + + + + 开始时间 + {{item.startedAtText || '-'}} + + + 完成时间 + {{item.finishedAtText || '-'}} + + + {{item.errorMessage}} + + + + + + + + + + + / {{taskTotalPages}} + + + + + + + + + + + 批量生成二维码 + 已选择 {{selectedMoldCount}} 个模具 + + + + + + + + + + 未找到模具 + + + + + + + + + diff --git a/pages/deviceQr/deviceQr.wxss b/pages/deviceQr/deviceQr.wxss new file mode 100644 index 0000000..4668618 --- /dev/null +++ b/pages/deviceQr/deviceQr.wxss @@ -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; +} diff --git a/pages/index/index.js b/pages/index/index.js new file mode 100644 index 0000000..62442c2 --- /dev/null +++ b/pages/index/index.js @@ -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() + } + }, + }) + }, +}) diff --git a/pages/index/index.json b/pages/index/index.json new file mode 100644 index 0000000..b55b5a2 --- /dev/null +++ b/pages/index/index.json @@ -0,0 +1,4 @@ +{ + "usingComponents": { + } +} \ No newline at end of file diff --git a/pages/index/index.wxml b/pages/index/index.wxml new file mode 100644 index 0000000..1a70086 --- /dev/null +++ b/pages/index/index.wxml @@ -0,0 +1,268 @@ + + + + + + 嘉恒智能报工 + 宁波嘉恒智能科技有限公司 + + 当前位置: + + 已进入 + {{currentAttendancePointName}} + 考勤点 + + 未进入考勤点 + + 下拉刷新位置 + + + + + {{avatarText}} + + + {{currentUser.name}} + + {{currentRoleName}} + + + + + + + + + + + + + + + + + + 首页 + + + + 工作台 + + + + 我的 + + + + + + 创建临时工 + {{temporaryWorkerPrompt}} + + 姓名 + + + + + + + + + diff --git a/pages/index/index.wxss b/pages/index/index.wxss new file mode 100644 index 0000000..634f39e --- /dev/null +++ b/pages/index/index.wxss @@ -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); +} diff --git a/pages/logs/logs.js b/pages/logs/logs.js new file mode 100644 index 0000000..85f6aac --- /dev/null +++ b/pages/logs/logs.js @@ -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 + } + }) + }) + } +}) diff --git a/pages/logs/logs.json b/pages/logs/logs.json new file mode 100644 index 0000000..b55b5a2 --- /dev/null +++ b/pages/logs/logs.json @@ -0,0 +1,4 @@ +{ + "usingComponents": { + } +} \ No newline at end of file diff --git a/pages/logs/logs.wxml b/pages/logs/logs.wxml new file mode 100644 index 0000000..85cf1bf --- /dev/null +++ b/pages/logs/logs.wxml @@ -0,0 +1,6 @@ + + + + {{index + 1}}. {{log.date}} + + diff --git a/pages/logs/logs.wxss b/pages/logs/logs.wxss new file mode 100644 index 0000000..33f9d9e --- /dev/null +++ b/pages/logs/logs.wxss @@ -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); +} diff --git a/pages/manageEquipment/manageEquipment.js b/pages/manageEquipment/manageEquipment.js new file mode 100644 index 0000000..f3b3db5 --- /dev/null +++ b/pages/manageEquipment/manageEquipment.js @@ -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) + }, +}) diff --git a/pages/manageEquipment/manageEquipment.json b/pages/manageEquipment/manageEquipment.json new file mode 100644 index 0000000..e8546d3 --- /dev/null +++ b/pages/manageEquipment/manageEquipment.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "设备管理" +} diff --git a/pages/manageEquipment/manageEquipment.wxml b/pages/manageEquipment/manageEquipment.wxml new file mode 100644 index 0000000..d32c23c --- /dev/null +++ b/pages/manageEquipment/manageEquipment.wxml @@ -0,0 +1,93 @@ + + + 设备管理 + 维护员工报工可选择的设备号和设备类型 + + + + + + + + + + {{filterAttendancePointLabels[filterAttendancePointIndex] || '全部考勤点'}} + + + + 暂无设备 + + + + {{item.deviceNo}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.remark || '暂无备注'}} + + {{item.deviceType || '冲压设备'}} + + + + + + + + + + + + / {{totalPages}} + + + + + + + + + + {{formTitle}} + + + + 考勤点 + + {{form.attendancePointName || '请选择考勤点'}} + + 暂无可选考勤点 + + + 设备号 + + + + 设备类型 + + {{form.deviceType || '冲压设备'}} + + + + 备注 + + + + + + + + diff --git a/pages/manageEquipment/manageEquipment.wxss b/pages/manageEquipment/manageEquipment.wxss new file mode 100644 index 0000000..bee1efd --- /dev/null +++ b/pages/manageEquipment/manageEquipment.wxss @@ -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; +} diff --git a/pages/manageNotices/manageNotices.js b/pages/manageNotices/manageNotices.js new file mode 100644 index 0000000..c1c98fd --- /dev/null +++ b/pages/manageNotices/manageNotices.js @@ -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) + }, +}) diff --git a/pages/manageNotices/manageNotices.json b/pages/manageNotices/manageNotices.json new file mode 100644 index 0000000..547cc71 --- /dev/null +++ b/pages/manageNotices/manageNotices.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "通知配置" +} diff --git a/pages/manageNotices/manageNotices.wxml b/pages/manageNotices/manageNotices.wxml new file mode 100644 index 0000000..4849bf8 --- /dev/null +++ b/pages/manageNotices/manageNotices.wxml @@ -0,0 +1,91 @@ + + + 通知配置 + 首页通知内容维护 + + + + + + + 暂无通知 + + + + {{item.title}} + 考勤点 {{item.attendancePointsText || '-'}} + 排序 {{item.sortOrder}} · {{item.updatedAtText}} + + {{item.isActive ? '展示中' : '已停用'}} + + {{item.content}} + + + + + + + + + + + / {{totalPages}} + + + + + + + + + + {{formTitle}} + + + + 标题 + + + + 内容 + + + + 通知考勤点 + + + + + + + 排序 + + + + 展示状态 + + {{form.isActive ? '展示中' : '停用'}} + + + + + + + + + + diff --git a/pages/manageNotices/manageNotices.wxss b/pages/manageNotices/manageNotices.wxss new file mode 100644 index 0000000..6982d1a --- /dev/null +++ b/pages/manageNotices/manageNotices.wxss @@ -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; +} diff --git a/pages/managePeople/managePeople.js b/pages/managePeople/managePeople.js new file mode 100644 index 0000000..e2aa87c --- /dev/null +++ b/pages/managePeople/managePeople.js @@ -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) + }, +}) diff --git a/pages/managePeople/managePeople.json b/pages/managePeople/managePeople.json new file mode 100644 index 0000000..807f3b3 --- /dev/null +++ b/pages/managePeople/managePeople.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "人员清单" +} diff --git a/pages/managePeople/managePeople.wxml b/pages/managePeople/managePeople.wxml new file mode 100644 index 0000000..b5d6e9f --- /dev/null +++ b/pages/managePeople/managePeople.wxml @@ -0,0 +1,117 @@ + + + 人员清单 + 电话号唯一,角色决定可用功能 + + + + + + + + + 已选择:{{importFileName}} + + + + + 暂无人员 + + + + {{item.name}} + {{item.phone}} + 考勤点 {{item.attendancePointsText || '-'}} + + {{item.temporaryStatusText}} + + + + + + {{roleItem.roleName}} + + + + + + + + + + + + + + + + + / {{totalPages}} + + + + + + + + + + {{formTitle}} + + + + 电话号 + + + + 姓名 + + + + 角色 + + {{roleOptions[roleIndex].label}} + + + + 考勤点 + + + + + + + + + + diff --git a/pages/managePeople/managePeople.wxss b/pages/managePeople/managePeople.wxss new file mode 100644 index 0000000..5b9ac3c --- /dev/null +++ b/pages/managePeople/managePeople.wxss @@ -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; +} diff --git a/pages/manageProducts/manageProducts.js b/pages/manageProducts/manageProducts.js new file mode 100644 index 0000000..6e32d1a --- /dev/null +++ b/pages/manageProducts/manageProducts.js @@ -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) + }, +}) diff --git a/pages/manageProducts/manageProducts.json b/pages/manageProducts/manageProducts.json new file mode 100644 index 0000000..72e50be --- /dev/null +++ b/pages/manageProducts/manageProducts.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "产品清单" +} diff --git a/pages/manageProducts/manageProducts.wxml b/pages/manageProducts/manageProducts.wxml new file mode 100644 index 0000000..246673b --- /dev/null +++ b/pages/manageProducts/manageProducts.wxml @@ -0,0 +1,180 @@ + + + 产品清单 + 项目号、产品名称、工序共同维护产品信息 + + + + + + + + + 已选择:{{importFileName}} + + + + + 暂无产品 + + + + {{item.productName}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.projectNo}} · {{item.materialCode}} + 物料名称 {{item.materialName || '-'}} + + {{item.processDisplay || item.process || '工序'}} + 清洗 + 杂活 + 多人协作 + 连续模 + + + + 标准节拍 {{item.standardBeat}} 秒 + 工序单价 {{item.processUnitPriceYuan || 0}} 元/件 + + + + + + + + + + + + / {{totalPages}} + + + + + + + + + + {{formTitle}} + + + + + 考勤点 + + {{form.attendancePointName || '请选择考勤点'}} + + 暂无可选考勤点 + + + 项目号 + + + + 型材号 + + + + 产品名称 + + + + 物料编码 + + + + 物料名称 + + + + 供应商(非必填) + + + + 工序 + + + + + + + 冲压方式 + + + {{item}} + + + + 操作人数 + + + + 工序单价(元/件) + + + + 标准节拍 + + + + {{form.stampingMethod === '处理杂活' ? '处理杂活' : '清洗方式'}} + 标准节拍不需要填写 + + + 产品净重(kg)(非必填) + + + + 产品毛重(kg)(非必填) + + + + 允许报废率(非必填) + + + + 废料单价(元/kg)(非必填) + + + + + + + + + diff --git a/pages/manageProducts/manageProducts.wxss b/pages/manageProducts/manageProducts.wxss new file mode 100644 index 0000000..49ded75 --- /dev/null +++ b/pages/manageProducts/manageProducts.wxss @@ -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; +} diff --git a/pages/reconciliationLedger/reconciliationLedger.js b/pages/reconciliationLedger/reconciliationLedger.js new file mode 100644 index 0000000..06b6669 --- /dev/null +++ b/pages/reconciliationLedger/reconciliationLedger.js @@ -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: '' }) + } + }, +}) diff --git a/pages/reconciliationLedger/reconciliationLedger.json b/pages/reconciliationLedger/reconciliationLedger.json new file mode 100644 index 0000000..8057868 --- /dev/null +++ b/pages/reconciliationLedger/reconciliationLedger.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "对账小账本" +} diff --git a/pages/reconciliationLedger/reconciliationLedger.wxml b/pages/reconciliationLedger/reconciliationLedger.wxml new file mode 100644 index 0000000..494ce14 --- /dev/null +++ b/pages/reconciliationLedger/reconciliationLedger.wxml @@ -0,0 +1,152 @@ + + + 对账小账本 + 按产品名称汇总最后一道工序的已审核成品数量,对账数量和退货数量可手工维护 + + + + + + {{selectedYear}}年 + + + + 已报工合计 + {{totalReported}} + + + 对账合计 + {{totalReconciled}} + + + 退货合计 + {{totalReturn}} + + + + + 当前年份默认定位到本月,向右滑动可查看前面月份,已过月份可点击月份标题展开。 + + + + 暂无产品 + + + + + 考勤点 / 产品名称 + + + {{selectedPointLabel || '全部考勤点'}} + + + + + {{row.attendancePointName || '-'}} + {{row.productName}} + + + 当前考勤点暂无产品 + + + + + + + + + {{month.label}} + {{month.expanded ? '收起' : '展开'}} + + + 已报工成品 + 对账成品 + 退货数量 + + + + + + + + + + {{month.reportedGoodQtyText}} + + + + + + + + + + {{month.label}} + + + + + + + 当前考勤点暂无产品 + + + + + diff --git a/pages/reconciliationLedger/reconciliationLedger.wxss b/pages/reconciliationLedger/reconciliationLedger.wxss new file mode 100644 index 0000000..b03ecca --- /dev/null +++ b/pages/reconciliationLedger/reconciliationLedger.wxss @@ -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; +} diff --git a/pages/records/records.js b/pages/records/records.js new file mode 100644 index 0000000..d02e995 --- /dev/null +++ b/pages/records/records.js @@ -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}`, + }) + }, +}) diff --git a/pages/records/records.json b/pages/records/records.json new file mode 100644 index 0000000..2684351 --- /dev/null +++ b/pages/records/records.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "报工记录" +} diff --git a/pages/records/records.wxml b/pages/records/records.wxml new file mode 100644 index 0000000..2f0dedb --- /dev/null +++ b/pages/records/records.wxml @@ -0,0 +1,172 @@ + + + + + {{title}} + 支持按日期和状态筛选,点击记录查看明细 + + + + 过滤作废 + + + + + + + + + + + + {{startDate || '开始日期'}} + + + + + + {{endDate || '结束日期'}} + + + + + + + + + 暂无记录 + + 作废 + 更改 + {{item.autoSubmitReason || '超时-系统自动提交'}} + 清洗 + 包含杂活 + 多人协作 + 连续模 + + + {{item.employeeName}} · {{item.reportDate}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.startAtText}} 至 {{item.endAtText}} + {{item.metrics.shiftDistributionText || ''}} + + + {{pair.label}} + {{pair.oldValue}} + {{pair.newValue}} + + + + {{item.statusName}} + + + + + + + + + 报工明细 + + 多人协作 + 连续模 + + + {{detail.displayName || detail.productName || '-'}} + 考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}} + 项目 {{detail.projectNo || '-'}} · 工序 {{detail.process || '-'}} + 冲压方式 {{detail.stampingMethod || '-'}} + 材料库存批次号 {{detail.rawMaterialBatchNo || '-'}} + + {{detail.deviceNo || '-'}} + + + + {{detail.isMisc ? '处理杂活' : (detail.isCleaning ? '清洗数量' : '成品数量')}} + {{detail.isMisc ? '不计产量' : detail.goodQty}} + + + 不良数量 + {{detail.defectQty}} + + + 工时 + {{detail.workTimeText}} + + + 杂活事项 + {{detail.remark || '待备注'}} + + + 实际节拍 + + {{detail.actualBeat}} 秒 + {{detail.beatReason}} + + + + 节拍对比 + {{detail.paceText}} + + + 工作量对比 + {{detail.workloadText}} + + + 换料次数 + {{detail.changeoverCount || 0}} + + + + + {{pair.label}} + {{pair.oldValue}} + {{pair.newValue}} + + + + + + + + + + + / {{totalPages}} + + + + + diff --git a/pages/records/records.wxss b/pages/records/records.wxss new file mode 100644 index 0000000..d301536 --- /dev/null +++ b/pages/records/records.wxss @@ -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; +} diff --git a/pages/reportForm/reportForm.js b/pages/reportForm/reportForm.js new file mode 100644 index 0000000..b59fa8f --- /dev/null +++ b/pages/reportForm/reportForm.js @@ -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 }) + } + }, + }) + }, +}) diff --git a/pages/reportForm/reportForm.json b/pages/reportForm/reportForm.json new file mode 100644 index 0000000..9a03719 --- /dev/null +++ b/pages/reportForm/reportForm.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "下班报工" +} diff --git a/pages/reportForm/reportForm.wxml b/pages/reportForm/reportForm.wxml new file mode 100644 index 0000000..ffe3cc7 --- /dev/null +++ b/pages/reportForm/reportForm.wxml @@ -0,0 +1,269 @@ + + + + + 下班报工 + 按模具和设备填写产量,提交后进入审核 + + + 剩余 + {{remainingText}} + + + + + + + + + 上班打卡时间 + {{draft.startAtText}} + + + 下班打卡时间 + {{draft.endAtText}} + + + + + 考勤点 + {{draft.attendancePointName || '-'}} + + + + 报工时长 + {{draft.durationMinutes}} 分钟 + + + + + + + 模具名称 + {{block.moldDisplayName || block.moldName || block.deviceNo}} + 考勤点 {{block.attendancePointName}} + 累计工时 {{block.durationMinutes || 0}} 分钟 + + + + + 该模具暂无产品 + + + 多人协作 + 连续模 + + 报工 {{itemIndex + 1}} + + + + + 开始工作时间 + {{reportItem.startAtText || '-'}} + + + + 处理杂活 + 系统自动生成该报工卡片,只记录本段工时,杂活事项由管理员审核时备注。 + 累计工时 {{block.durationMinutes || 0}} 分钟 + + + + 设备号 + + {{reportItem.deviceNo || '请选择设备号'}} + + 暂无可选设备 + + + + 材料库存批次号 + + {{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}} + + 暂无可选材料库存批次号 + + + + 换料次数 + + + + + + 产品名称 + {{reportItem.productName || '-'}} + + + 考勤点 + {{reportItem.attendancePointName || block.attendancePointName || '-'}} + + + 项目号 + {{reportItem.projectNo || '-'}} + + + 物料编码 + {{reportItem.materialCode || '-'}} + + + 工序 + {{reportItem.process || '-'}} + + + 冲压方式 + {{reportItem.stampingMethod || '-'}} + + + 标准节拍 + {{reportItem.standardBeat || 0}} 秒 + + + + + + 成品数量 + + + + 不良数量 + + + + + + + + + + + + + + 确认提交报工 + + + 模具 + {{confirmSummary.deviceText}} + + + 成品数量 + {{confirmSummary.totalGood}} + + + 不良数量 + {{confirmSummary.totalDefect}} + + + 审核说明 + {{confirmSummary.note}} + + + + + + + + + + + + 新增产品开始时间 + + 开始日期 + + {{addStartForm.date}} + + + + 开始时间 + + {{addStartForm.timeText}} + + + + + + + + diff --git a/pages/reportForm/reportForm.wxss b/pages/reportForm/reportForm.wxss new file mode 100644 index 0000000..0d81c14 --- /dev/null +++ b/pages/reportForm/reportForm.wxss @@ -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; +} diff --git a/pages/reportResult/reportResult.js b/pages/reportResult/reportResult.js new file mode 100644 index 0000000..7329bc7 --- /dev/null +++ b/pages/reportResult/reportResult.js @@ -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', + }) + }, +}) diff --git a/pages/reportResult/reportResult.json b/pages/reportResult/reportResult.json new file mode 100644 index 0000000..1917725 --- /dev/null +++ b/pages/reportResult/reportResult.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "disableSwipeBack": true +} diff --git a/pages/reportResult/reportResult.wxml b/pages/reportResult/reportResult.wxml new file mode 100644 index 0000000..242b536 --- /dev/null +++ b/pages/reportResult/reportResult.wxml @@ -0,0 +1,74 @@ + + + 报工结果 + + + + 报工成功 + 已提交管理员审核 + + + + + 包含杂活 + 多人协作 + 连续模 + 报工日期 + {{report.reportDate}} + 考勤点 {{report.attendancePointName || '-'}} + {{report.metrics.shiftDistributionText}} + + + 多人协作 + 连续模 + {{result.title}} + {{result.subtitle}} + {{result.resultText}} + + + 工时 + {{result.workTimeText}} + + + + + 清洗数量 + {{result.goodQty}} + + + + + 实际节拍 + {{result.actualBeat}} 秒/件 + + + 标准节拍 + {{result.standardBeat}} 秒/件 + + + 成品数量 + {{result.goodQty}} + + + 标准工作量 + {{result.standardWorkload}} + + + 换料次数 + {{result.changeoverCount || 0}} + + + + + + + + + + + diff --git a/pages/reportResult/reportResult.wxss b/pages/reportResult/reportResult.wxss new file mode 100644 index 0000000..e1b3b62 --- /dev/null +++ b/pages/reportResult/reportResult.wxss @@ -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); +} diff --git a/pages/review/review.js b/pages/review/review.js new file mode 100644 index 0000000..eaac452 --- /dev/null +++ b/pages/review/review.js @@ -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() + } + }, +}) diff --git a/pages/review/review.json b/pages/review/review.json new file mode 100644 index 0000000..0e6f214 --- /dev/null +++ b/pages/review/review.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "报工审核" +} diff --git a/pages/review/review.wxml b/pages/review/review.wxml new file mode 100644 index 0000000..104056c --- /dev/null +++ b/pages/review/review.wxml @@ -0,0 +1,338 @@ + + + + + 报工审核 + 复核并校正员工上报数据后通过 + + + + 过滤作废 + + + + + + + + 暂无待审核报工 + + + 作废 + 更改 + {{item.autoSubmitReason || '超时-系统自动提交'}} + 清洗 + 包含杂活 + 多人协作 + 连续模 + + + {{item.employeeName}} · {{item.reportDate}} + 考勤点 {{item.attendancePointName || '-'}} + {{item.timeRangeText}} + {{item.metrics.shiftDistributionText || ''}} + + {{item.statusName}} + + + + + + 上下班时间校正 + + + {{item.editStartDate || '上班日期'}} + + + {{item.editStartTime || '上班时间'}} + + + {{item.editEndDate || '下班日期'}} + + + {{item.editEndTime || '下班时间'}} + + + + + + + 多人协作 + 连续模 + {{detail.deviceNo}} · {{detail.productName}} / {{detail.process || '-'}} / {{detail.stampingMethod || '-'}} + 考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}} + 处理杂活 · {{detail.workTimeText}} + 工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 清洗数量 {{detail.goodQty}} + 工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 成品 {{detail.goodQty}} · 不良 {{detail.defectQty}} + 材料库存批次号 {{detail.rawMaterialBatchNo || '-'}} + + 换模具时间校正 + + + {{segment.label}} · {{segment.displayName}} + 原记录 {{segment.scannedAtText}} + + + + {{segment.editScanDate || '日期'}} + + + {{segment.editScanTime || '时间'}} + + + + + + + 工时 + {{detail.workTimeText}} + + + 杂活事项 + {{detail.remark || '待备注'}} + + + + + 清洗数量 + {{detail.goodQty}} + + + 工时 + {{detail.workTimeText}} + + + + + 工时 + {{detail.workTimeText}} + + + 成品 / 标准工作量 + + {{detail.goodQty}} / {{detail.standardWorkload}} + {{detail.workloadReason}} + + + + 实际节拍 + + {{detail.actualBeat}} 秒 + {{detail.beatReason}} + + + + 标准节拍 + {{detail.standardBeat}} 秒 + + + 换料次数 + {{detail.changeoverCount || 0}} + + + + + 杂活事项备注 + + + + + + + + 最近更新 {{updatedAtText}} + + + + + + diff --git a/pages/workSchedule/workSchedule.wxss b/pages/workSchedule/workSchedule.wxss new file mode 100644 index 0000000..d5a9374 --- /dev/null +++ b/pages/workSchedule/workSchedule.wxss @@ -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; +} diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..e4c6167 --- /dev/null +++ b/project.config.json @@ -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": {} +} diff --git a/scripts/test-material-stock-lot-wording.mjs b/scripts/test-material-stock-lot-wording.mjs new file mode 100644 index 0000000..1113966 --- /dev/null +++ b/scripts/test-material-stock-lot-wording.mjs @@ -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"); diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..ca02add --- /dev/null +++ b/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} \ No newline at end of file diff --git a/utils/api.js b/utils/api.js new file mode 100644 index 0000000..f1d7f7e --- /dev/null +++ b/utils/api.js @@ -0,0 +1,1894 @@ +const requestClient = require('./request') +const util = require('./util') + +const roleNames = { + worker: '冲压工人', + admin: '管理员', + manager: '经理', +} + +const defaultRolePriority = ['worker', 'admin', 'manager'] + +const pickDefaultRole = people => { + const availableRoles = [] + const personList = people || [] + personList.forEach(person => { + if (person.role) { + availableRoles.push(person.role) + } + const roles = person.roles || [] + roles.forEach(role => { + availableRoles.push(role) + }) + }) + const uniqueRoles = Array.from(new Set(availableRoles)) + return defaultRolePriority.find(role => uniqueRoles.includes(role)) || uniqueRoles[0] || '' +} + +const normalizeDeviceNo = value => { + return String(value || '').trim() +} + +const moldDisplayName = (name, processName, stampingMethod = '') => { + const moldName = String(name || '').trim() + const process = String(processName || '').trim() + const stamping = String(stampingMethod || '').trim() + if (!moldName) { + return '' + } + if (stamping) { + return `${moldName} / ${process || '-'} / ${stamping}` + } + return process ? `${moldName} / ${process}` : moldName +} + +const normalizeProcessValue = value => { + const text = String(value || '').trim() + if (text === '杂活') { + return text + } + const legacyMatch = text.match(/^0*(\d+)序$/) + if (legacyMatch) { + return String(Number(legacyMatch[1])) + } + const numberMatch = text.match(/^0*(\d+)$/) + if (numberMatch) { + return String(Number(numberMatch[1])) + } + return text +} + +const processDisplayName = value => { + const process = normalizeProcessValue(value) + if (!process) { + return '' + } + return process === '杂活' ? process : `${process}序` +} + +const isCleaningStampingMethod = value => String(value || '').trim() === '清洗' + +const isCleaningRow = row => ( + isCleaningStampingMethod(row.stamping_method || row.stampingMethod) +) + +const CONTINUOUS_DIE_STAMPING_METHOD = '连续模' + +const isContinuousDieStampingMethod = value => String(value || '').trim() === CONTINUOUS_DIE_STAMPING_METHOD + +const isContinuousDieRow = row => ( + !!row && ( + !!row.is_continuous_die + || !!row.isContinuousDie + || isContinuousDieStampingMethod(row.stamping_method || row.stampingMethod) + ) +) + +const MISC_WORK_PRODUCT_NAME = '处理杂活' +const MISC_WORK_PROCESS_NAME = '杂活' +const MISC_WORK_STAMPING_METHOD = '处理杂活' + +const isMiscRow = row => ( + !!row && ( + row.is_misc + || row.isMisc + || String(row.stamping_method || row.stampingMethod || '').trim() === MISC_WORK_STAMPING_METHOD + || ( + String(row.product_name || row.productName || row.name || row.moldName || '').trim() === MISC_WORK_PRODUCT_NAME + && String(row.process_name || row.process || row.processName || '').trim() === MISC_WORK_PROCESS_NAME + ) + ) +) + +const isMultiPersonRow = row => ( + !!row && ( + !!row.is_multi_person + || !!row.isMultiPerson + || Number(row.operator_count || row.operatorCount || 1) > 1 + ) +) + +const correctionValue = value => ( + value === null || value === undefined || value === '' ? '-' : value +) + +const correctionDisplayValue = value => { + const normalized = correctionValue(value) + if (typeof normalized === 'string' && normalized.includes('T')) { + return util.formatTime(normalized) + } + return normalized +} + +const correctionPair = (corrections, key, label, currentValue) => { + if (!corrections || corrections[key] === undefined) { + return null + } + return { + key, + label, + oldValue: correctionValue(corrections[key]), + newValue: correctionValue(currentValue), + } +} + +const round1 = value => Math.round(Number(value || 0) * 10) / 10 + +const round2 = value => Math.round(Number(value || 0) * 100) / 100 + +const calculateStandardWorkload = (allocatedMinutes, standardBeat) => { + const beat = Number(standardBeat || 0) + if (beat <= 0) { + return 0 + } + return round1(Number(allocatedMinutes || 0) * 60 / beat) +} + +const classifyBeat = (actualBeat, standardBeat) => { + const actual = Number(actualBeat || 0) + const standard = Number(standardBeat || 0) + if (standard <= 0 || actual <= 0) { + return { className: '', reason: '', isAbnormal: false } + } + if (actual > standard) { + return { className: 'metric-danger', reason: '慢', isAbnormal: true } + } + if (actual < standard * 0.7) { + return { className: 'metric-danger', reason: '快', isAbnormal: true } + } + if (actual < standard) { + return { className: 'metric-success', reason: '', isAbnormal: false } + } + return { className: '', reason: '', isAbnormal: false } +} + +const classifyWorkload = (goodQty, standardWorkload) => { + const good = Number(goodQty || 0) + const standard = Number(standardWorkload || 0) + if (standard <= 0) { + return { className: '', reason: '', isAbnormal: false } + } + if (good < standard) { + return { className: 'metric-danger', reason: '小', isAbnormal: true } + } + if (good > standard * 1.3) { + return { className: 'metric-danger', reason: '大', isAbnormal: true } + } + if (good > standard) { + return { className: 'metric-success', reason: '', isAbnormal: false } + } + return { className: '', reason: '', isAbnormal: false } +} + +const toCamelAttendancePoint = row => ({ + name: row.name || '', + latitude: row.latitude === null || row.latitude === undefined ? '' : row.latitude, + longitude: row.longitude === null || row.longitude === undefined ? '' : row.longitude, + radiusMeters: row.radius_meters === null || row.radius_meters === undefined ? 500 : row.radius_meters, + dayStart: row.day_start || '08:00', + dayEnd: row.day_end || '17:20', + lunchStart: row.lunch_start || '11:40', + lunchEnd: row.lunch_end || '12:40', + dinnerStart: row.dinner_start || '17:20', + dinnerEnd: row.dinner_end || '18:00', + overtimeStart: row.overtime_start || '18:00', + overtimeEnd: row.overtime_end || '20:00', + nightStart: row.night_start || '20:00', + nightEnd: row.night_end || '06:00', + remark: row.remark || '', + isActive: row.is_active !== false, + createdAt: row.created_at || '', + updatedAt: row.updated_at || '', + updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', +}) + +const toSnakeAttendancePoint = row => ({ + name: row.name, + latitude: row.latitude === '' || row.latitude === undefined ? null : Number(row.latitude), + longitude: row.longitude === '' || row.longitude === undefined ? null : Number(row.longitude), + radius_meters: row.radiusMeters === '' || row.radiusMeters === undefined ? 500 : Number(row.radiusMeters), + day_start: row.dayStart || '08:00', + day_end: row.dayEnd || '17:20', + lunch_start: row.lunchStart || '11:40', + lunch_end: row.lunchEnd || '12:40', + dinner_start: row.dinnerStart || '17:20', + dinner_end: row.dinnerEnd || '18:00', + overtime_start: row.overtimeStart || '18:00', + overtime_end: row.overtimeEnd || '20:00', + night_start: row.nightStart || '20:00', + night_end: row.nightEnd || '06:00', + remark: row.remark || null, + is_active: row.isActive !== false, + original_name: row.originalName || null, +}) + +const toCamelPerson = row => { + const roles = row.roles && row.roles.length ? row.roles : (row.role ? [row.role] : []) + const roleNamesList = row.role_names && row.role_names.length + ? row.role_names + : roles.map(role => roleNames[role] || role) + const temporaryExpiresAtText = row.temporary_expires_at ? util.formatTime(row.temporary_expires_at) : '' + const attendancePointNames = row.attendance_point_names || [] + return { + phone: row.phone, + name: row.name, + role: row.role || roles[0] || '', + roleName: row.role_name || roleNamesList.join('、'), + roles, + roleNames: roleNamesList, + attendancePointNames, + attendancePoints: (row.attendance_points || []).map(toCamelAttendancePoint), + attendancePointsText: attendancePointNames.join('、'), + rolesDisplay: roles.map((role, index) => ({ + role, + roleName: roleNamesList[index] || roleNames[role] || role, + })), + isTemporary: !!row.is_temporary, + temporaryExpiresAt: row.temporary_expires_at || '', + temporaryExpiresAtText, + temporaryExpired: !!row.temporary_expired, + temporaryStatusText: row.is_temporary + ? (row.temporary_expired ? '临时工已过期' : `临时工有效至 ${temporaryExpiresAtText || '-'}`) + : '', + } +} + +const toSnakePerson = row => ({ + phone: row.phone, + name: row.name, + role: row.role, + attendance_point_names: row.attendancePointNames || [], + original_phone: row.originalPhone || null, +}) + +const toCamelProduct = row => ({ + attendancePointName: row.attendance_point_name || '', + projectNo: row.project_no, + productName: row.product_name, + profileNo: row.profile_no || '', + materialCode: row.material_code || '', + materialName: row.material_name || '', + supplier: row.supplier || '', + productNetWeightKg: row.product_net_weight_kg === null || row.product_net_weight_kg === undefined ? '' : row.product_net_weight_kg, + productGrossWeightKg: row.product_gross_weight_kg === null || row.product_gross_weight_kg === undefined ? '' : row.product_gross_weight_kg, + allowedScrapRate: row.scrap_loss_rate === null || row.scrap_loss_rate === undefined ? '' : row.scrap_loss_rate, + wastePriceYuanPerKg: row.waste_price_yuan_per_kg === null || row.waste_price_yuan_per_kg === undefined ? '' : row.waste_price_yuan_per_kg, + deviceNo: row.device_no, + process: normalizeProcessValue(row.process_name || ''), + rawProcess: row.process_name || '', + processDisplay: processDisplayName(row.process_name || ''), + stampingMethod: row.stamping_method || '', + isCleaning: isCleaningRow(row), + isContinuousDie: isContinuousDieRow(row), + isMisc: isMiscRow(row), + isMultiPerson: isMultiPersonRow(row), + operatorCount: row.operator_count, + processUnitPriceYuan: row.process_unit_price_yuan || 0, + standardBeat: row.standard_beat, + standardWorkload: row.standard_workload, + displayName: row.display_name || `${row.project_no} / ${moldDisplayName(row.product_name, row.process_name, row.stamping_method)}`, + uniqueKey: `${row.attendance_point_name || ''}||${row.project_no || ''}||${row.product_name || ''}||${row.process_name || ''}`, +}) + +const toCamelNotice = row => ({ + id: row.id, + title: row.title || '', + content: row.content || '', + attendancePointNames: row.attendance_point_names || [], + attendancePointsText: (row.attendance_point_names || []).join('、'), + sortOrder: row.sort_order || 0, + isActive: !!row.is_active, + createdBy: row.created_by || '', + creatorName: row.creator_name || '', + createdAt: row.created_at || '', + updatedAt: row.updated_at || '', + updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', +}) + +const toCamelEquipment = row => ({ + attendancePointName: row.attendance_point_name || '', + deviceNo: row.device_no || '', + deviceType: row.device_type || '冲压设备', + remark: row.remark || '', + createdAt: row.created_at || '', + updatedAt: row.updated_at || '', + updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', + displayName: row.display_name || (row.remark ? `${row.device_no} / ${row.remark}` : row.device_no), + uniqueKey: `${row.attendance_point_name || ''}||${row.device_no || ''}`, +}) + +const toCamelMoldOption = row => ({ + attendancePointName: row.attendance_point_name || '', + name: row.name || row.device_no || '', + moldName: row.name || row.device_no || '', + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + operatorCount: row.operator_count || 1, + isCleaning: !!row.is_cleaning || isCleaningRow(row), + isMisc: !!row.is_misc || isMiscRow(row), + isContinuousDie: !!row.is_continuous_die || isContinuousDieRow(row), + isMultiPerson: isMultiPersonRow(row), + displayName: row.display_name || moldDisplayName(row.name || row.device_no, row.process_name, row.stamping_method), + uniqueKey: `${row.attendance_point_name || ''}||${row.name || row.device_no || ''}||${row.process_name || ''}`, +}) + +const optionalNumber = value => ( + value === '' || value === undefined || value === null ? null : Number(value) +) + +const toSnakeProduct = row => ({ + attendance_point_name: row.attendancePointName, + project_no: row.projectNo, + product_name: row.productName, + profile_no: row.profileNo || null, + material_code: row.materialCode || null, + material_name: row.materialName || null, + supplier: row.supplier || null, + product_net_weight_kg: optionalNumber(row.productNetWeightKg), + product_gross_weight_kg: optionalNumber(row.productGrossWeightKg), + scrap_loss_rate: optionalNumber(row.allowedScrapRate), + waste_price_yuan_per_kg: optionalNumber(row.wastePriceYuanPerKg), + device_no: '', + process_name: normalizeProcessValue(row.process || ''), + stamping_method: row.stampingMethod || null, + operator_count: Number(row.operatorCount || 1), + process_unit_price_yuan: Number(row.processUnitPriceYuan || 0), + standard_beat: Number(row.standardBeat || 0), + original_project_no: row.originalProjectNo || null, + original_attendance_point_name: row.originalAttendancePointName || null, + original_product_name: row.originalProductName || null, + original_device_no: row.originalDeviceNo !== undefined ? row.originalDeviceNo : '', + original_process_name: row.originalProcess !== undefined ? row.originalProcess : null, +}) + +const toSnakeNotice = row => ({ + title: row.title, + content: row.content, + attendance_point_names: row.attendancePointNames || [], + sort_order: Number(row.sortOrder || 0), + is_active: !!row.isActive, +}) + +const toSnakeEquipment = row => ({ + attendance_point_name: row.attendancePointName, + device_no: normalizeDeviceNo(row.deviceNo), + device_type: row.deviceType || '冲压设备', + remark: row.remark || null, + original_attendance_point_name: row.originalAttendancePointName || null, + original_device_no: row.originalDeviceNo || null, +}) + +const toCamelPage = (page, mapper) => ({ + rows: (page.rows || []).map(mapper), + page: page.page, + pageSize: page.page_size, + total: page.total, + totalPages: page.total_pages, +}) + +const toCamelUsageStatsRow = row => ({ + id: row.id === undefined || row.id === null ? '' : String(row.id), + category: row.category || '', + attendancePointName: row.attendance_point_name || '', + name: row.name || '', + objectType: row.object_type || '', + metricKind: row.metric_kind || '', + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), + productName: row.product_name || '', + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + tags: Array.isArray(row.tags) ? row.tags : [], +}) + +const toCamelUsageStatsDailyRow = row => ({ + reportDate: row.report_date || '', + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), +}) + +const toCamelUsageStatsReportRow = row => ({ + reportId: Number(row.report_id || 0), + reportDate: row.report_date || '', + employeePhone: row.employee_phone || '', + employeeName: row.employee_name || '', + displayName: row.display_name || '', + value: Number(row.value || 0), + reportCount: Number(row.report_count || 0), +}) + +const toCamelUsageStatsDetail = data => ({ + ...toCamelUsageStatsRow(data || {}), + dailyRows: ((data && data.daily_rows) || []).map(toCamelUsageStatsDailyRow), + reportRows: ((data && data.report_rows) || []).map(toCamelUsageStatsReportRow), +}) + +const usageStatsParams = filters => ({ + category: filters.category, + id: filters.id, + metric_kind: filters.metricKind, + sort_by: filters.sortBy, + start_date: filters.startDate, + end_date: filters.endDate, + attendance_point_name: filters.attendancePointName, + keyword: filters.keyword, + name: filters.name, + product_name: filters.productName, + process_name: filters.processName, + stamping_method: filters.stampingMethod, +}) + +const formatDurationMinutes = minutes => { + const totalMinutes = Math.max(0, Math.round(Number(minutes || 0))) + if (totalMinutes < 60) { + return `${totalMinutes}分钟` + } + const days = Math.floor(totalMinutes / 1440) + const hours = Math.floor((totalMinutes % 1440) / 60) + const restMinutes = totalMinutes % 60 + const parts = [] + if (days) { + parts.push(`${days}天`) + } + if (hours) { + parts.push(`${hours}小时`) + } + if (restMinutes) { + parts.push(`${restMinutes}分钟`) + } + return parts.join('') +} + +const trimDurationNumber = value => { + const rounded = Math.round(Number(value || 0) * 100) / 100 + return Number.isInteger(rounded) ? String(rounded) : String(rounded) +} + +const formatDurationAllUnits = minutes => { + const safeMinutes = Math.max(0, Number(minutes || 0)) + return `${trimDurationNumber(safeMinutes)}分钟(${trimDurationNumber(safeMinutes / 60)}小时 / ${trimDurationNumber(safeMinutes / 1440)}天)` +} + +const toCamelMonitorLock = row => ({ + id: row.id, + attendancePointName: row.attendance_point_name || '', + moldName: row.mold_name || '', + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + moldDisplayName: row.mold_display_name || moldDisplayName(row.mold_name, row.process_name, row.stamping_method), + occupiedPhone: row.occupied_phone || '', + occupiedName: row.occupied_name || '', + occupiedStartAt: row.occupied_start_at || '', + occupiedStartAtText: row.occupied_start_at ? util.formatTime(row.occupied_start_at) : '', + occupiedMinutes: row.occupied_minutes || 0, + occupiedDurationText: formatDurationMinutes(row.occupied_minutes), +}) + +const toCamelMoldLockFeedback = row => ({ + id: row.id, + attendancePointName: row.attendance_point_name || '', + moldName: row.mold_name || '', + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + moldDisplayName: row.mold_display_name || moldDisplayName(row.mold_name, row.process_name, row.stamping_method), + lockId: row.lock_id, + reporterPhone: row.reporter_phone || '', + reporterName: row.reporter_name || '', + occupiedPhone: row.occupied_phone || '', + occupiedName: row.occupied_name || '', + feedbackAt: row.feedback_at || '', + feedbackAtText: row.feedback_at ? util.formatTime(row.feedback_at) : '', + readAt: row.read_at || '', + readAtText: row.read_at ? util.formatTime(row.read_at) : '', + handledAt: row.handled_at || '', + handledAtText: row.handled_at ? util.formatTime(row.handled_at) : '', + status: row.status || '', + statusName: row.status_name || '', + isHandled: row.status === 'handled', + canRelease: row.can_release !== false, +}) + +const toCamelSession = row => ({ + id: row.id, + attendancePointName: row.attendance_point_name || '', + employeePhone: row.employee_phone, + startAt: row.start_at, + endAt: row.end_at || '', + status: row.status, + devices: row.devices || [], +}) + +const toCamelMetrics = row => ({ + effectiveMinutes: row.effective_minutes, + shiftDistributionText: row.shift_distribution_text || '', + shiftDayMinutes: row.shift_day_minutes || 0, + shiftOvertimeMinutes: row.shift_overtime_minutes || 0, + shiftNightMinutes: row.shift_night_minutes || 0, + shiftOtherMinutes: row.shift_other_minutes || 0, + shiftMealBreakMinutes: row.shift_meal_break_minutes || 0, + totalGood: row.total_good_qty, + totalOutput: row.total_output_qty, + actualBeat: row.actual_beat, + standardBeat: row.standard_beat, + expectedWorkload: row.expected_workload, + paceRate: row.pace_rate, + workloadRate: row.workload_rate, +}) + +const formatWorkTime = minutes => { + const safeMinutes = Number(minutes || 0) + const seconds = Math.round(safeMinutes * 60) + if (seconds < 60) { + return `${seconds}秒` + } + return `${Math.round(safeMinutes * 100) / 100}分钟` +} + +const toCamelReportItem = row => { + const defectQty = Number(row.defect_qty || 0) + const scrapQty = Number(row.scrap_qty || 0) + const goodQty = Number(row.good_qty || 0) + const totalDefectQty = defectQty + scrapQty + const outputQty = goodQty + totalDefectQty + const allocatedMinutes = Number(row.allocated_minutes || 0) + const actualBeat = outputQty > 0 ? round2(allocatedMinutes * 60 / outputQty) : 0 + const standardBeat = Number(row.standard_beat || 0) + const calculatedWorkload = calculateStandardWorkload(allocatedMinutes, standardBeat) + const standardWorkload = calculatedWorkload || Number(row.standard_workload || 0) + const paceRate = standardBeat > 0 ? round1((actualBeat - standardBeat) / standardBeat * 100) : 0 + const workloadRate = standardWorkload > 0 ? round1((goodQty - standardWorkload) / standardWorkload * 100) : 0 + const isCleaning = isCleaningRow(row) + const isContinuousDie = isContinuousDieRow(row) + const isMisc = isMiscRow(row) + const noOutputMetric = isCleaning || isMisc + const beatComparison = noOutputMetric ? classifyBeat(0, 0) : classifyBeat(actualBeat, standardBeat) + const workloadComparison = noOutputMetric ? classifyWorkload(0, 0) : classifyWorkload(goodQty, standardWorkload) + const corrections = row.corrections || {} + const correctionPairs = [ + correctionPair(corrections, 'device_no', '设备号', row.device_no), + correctionPair(corrections, 'project_no', '项目号', row.project_no), + correctionPair(corrections, 'product_name', '产品名称', row.product_name), + correctionPair(corrections, 'process_name', '工序', row.process_name || ''), + correctionPair(corrections, 'stamping_method', '冲压方式', row.stamping_method || ''), + correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || ''), + correctionPair(corrections, 'process_unit_price_yuan', '工序单价', row.process_unit_price_yuan || 0), + correctionPair(corrections, 'changeover_count', '换料次数', row.changeover_count || 0), + correctionPair(corrections, 'good_qty', isCleaning ? '清洗数量' : '成品数量', goodQty), + correctionPair(corrections, 'defect_qty', '不良数量', Math.round(totalDefectQty * 100) / 100), + correctionPair(corrections, 'scrap_qty', '报废数量', scrapQty), + correctionPair(corrections, 'remark', '杂活事项', row.remark || ''), + ].filter(Boolean) + return { + id: row.id, + attendancePointName: row.attendance_point_name || '', + startAt: row.started_at || '', + startAtText: row.started_at ? util.formatTime(row.started_at) : '', + deviceNo: row.device_no, + projectNo: row.project_no, + productName: row.product_name, + displayName: moldDisplayName(row.product_name, row.process_name, row.stamping_method), + materialCode: row.material_code || '', + materialName: row.material_name || '', + rawMaterialBatchNo: row.raw_material_batch_no || '', + process: row.process_name || '', + stampingMethod: row.stamping_method || '', + operatorCount: row.operator_count || 1, + processUnitPriceYuan: row.process_unit_price_yuan || 0, + isCleaning, + isContinuousDie, + changeoverCount: row.changeover_count === null || row.changeover_count === undefined ? 0 : Number(row.changeover_count || 0), + isMisc, + isMultiPerson: isMultiPersonRow(row), + isMultiPersonAssistant: !!row.is_multi_person_assistant, + standardBeat, + standardWorkload, + goodQty, + defectQty: Math.round(totalDefectQty * 100) / 100, + scrapQty, + allocatedMinutes, + workTimeText: isCleaning ? '不计工时' : formatWorkTime(allocatedMinutes), + actualBeat, + paceRate, + workloadRate, + paceText: noOutputMetric ? '不计节拍' : (paceRate > 0 ? `慢${Math.abs(paceRate)}%` : `快${Math.abs(paceRate)}%`), + workloadText: noOutputMetric ? '不计标准工作量' : (workloadRate >= 0 ? `多${Math.abs(workloadRate)}%` : `少${Math.abs(workloadRate)}%`), + beatCompareClass: beatComparison.className, + beatReason: beatComparison.reason, + workloadCompareClass: workloadComparison.className, + workloadReason: workloadComparison.reason, + remark: row.remark || '', + corrections, + correctionPairs, + hasCorrections: correctionPairs.length > 0, + } +} + +const toCamelDeviceSegment = row => { + const corrections = row.corrections || {} + const correctionPairs = [ + correctionPair(corrections, 'scanned_at', '换模具时间', row.scanned_at ? util.formatTime(row.scanned_at) : ''), + ].filter(Boolean).map(item => ({ + ...item, + oldValue: item.oldValue === '-' ? '-' : util.formatTime(item.oldValue), + })) + return { + id: row.id, + attendancePointName: row.attendance_point_name || '', + deviceNo: row.device_no || '', + moldName: row.mold_name || row.device_no || '', + process: row.process_name || '', + stampingMethod: row.stamping_method || '', + displayName: row.display_name || moldDisplayName(row.mold_name || row.device_no, row.process_name, row.stamping_method), + scannedAt: row.scanned_at || '', + scannedAtText: row.scanned_at ? util.formatTime(row.scanned_at) : '', + releasedAt: row.released_at || '', + releasedAtText: row.released_at ? util.formatTime(row.released_at) : '', + sortOrder: Number(row.sort_order || 0), + corrections, + correctionPairs, + hasCorrections: correctionPairs.length > 0, + } +} + +const toCamelReport = row => { + const paceRate = Number(row.metrics && row.metrics.pace_rate || 0) + const workloadRate = Number(row.metrics && row.metrics.workload_rate || 0) + const items = (row.items || []).map(toCamelReportItem) + const deviceSegments = (row.device_segments || []).map(toCamelDeviceSegment) + const isCleaning = items.length > 0 && items.every(item => item.isCleaning) + const hasMisc = items.some(item => item.isMisc) + const hasContinuousDie = items.some(item => item.isContinuousDie) + const hasMultiPerson = items.some(item => item.isMultiPerson) + const totalDefect = Math.round(items.reduce((sum, item) => sum + Number(item.defectQty || 0), 0) * 100) / 100 + const corrections = row.corrections || {} + const reportCorrectionPairs = [ + correctionPair(corrections, 'start_at', '上班时间', row.start_at ? util.formatTime(row.start_at) : ''), + correctionPair(corrections, 'end_at', '下班时间', row.end_at ? util.formatTime(row.end_at) : ''), + correctionPair(corrections, 'review_remark', '备注', row.review_remark || ''), + ].filter(Boolean).map(item => ({ + ...item, + oldValue: (item.key === 'start_at' || item.key === 'end_at') && item.oldValue !== '-' + ? util.formatTime(item.oldValue) + : item.oldValue, + })).concat(deviceSegments.flatMap(segment => ( + (segment.correctionPairs || []).map(pair => ({ + ...pair, + label: `${segment.displayName} ${pair.label}`, + })) + ))) + return { + id: row.id, + attendancePointName: row.attendance_point_name || '', + sessionId: row.session_id, + employeePhone: row.employee_phone, + employeeName: row.employee_name, + reportDate: row.report_date, + startAt: row.start_at, + endAt: row.end_at, + startAtText: util.formatTime(row.start_at), + endAtText: util.formatTime(row.end_at), + durationMinutes: row.duration_minutes, + breakMinutes: row.break_minutes, + status: row.status, + statusName: row.status_name, + reviewerPhone: row.reviewer_phone || '', + reviewerName: row.reviewer_name || '', + reviewedAt: row.reviewed_at || '', + reviewedAtText: row.reviewed_at ? util.formatTime(row.reviewed_at) : '', + rejectReason: row.reject_reason || '', + reviewRemark: row.review_remark || '', + submittedAt: row.submitted_at, + submittedAtText: util.formatTime(row.submitted_at), + isSystemAutoSubmitted: !!row.is_system_auto_submitted, + autoSubmitReason: row.auto_submit_reason || '超时-系统自动提交', + isMultiPersonAssistant: !!row.is_multi_person_assistant, + multiPersonSourceReportId: row.multi_person_source_report_id || null, + isVoided: !!row.is_voided, + voidedAt: row.voided_at || '', + voidedAtText: row.voided_at ? util.formatTime(row.voided_at) : '', + voidedBy: row.voided_by || '', + voidedByName: row.voided_by_name || '', + unvoidDeadlineAt: row.unvoid_deadline_at || '', + unvoidDeadlineAtText: row.unvoid_deadline_at ? util.formatTime(row.unvoid_deadline_at) : '', + canVoid: !!row.can_void, + canUnvoid: !!row.can_unvoid, + canEdit: !!row.can_edit, + isModified: !!row.is_modified, + deviceSegments, + items, + isCleaning, + hasMisc, + hasContinuousDie, + hasMultiPerson, + metrics: { + ...toCamelMetrics(row.metrics || {}), + totalDefect, + }, + resultText: row.result_text, + paceText: paceRate > 0 ? `慢${Math.abs(paceRate)}%` : `快${Math.abs(paceRate)}%`, + workloadText: workloadRate >= 0 ? `多${Math.abs(workloadRate)}%` : `少${Math.abs(workloadRate)}%`, + corrections, + correctionPairs: reportCorrectionPairs, + hasCorrections: reportCorrectionPairs.length > 0 || items.some(item => item.hasCorrections) || deviceSegments.some(item => item.hasCorrections), + } +} + +const emptyDraftItem = startAt => ({ + attendancePointName: '', + productIndex: -1, + deviceIndex: -1, + deviceNo: '', + startAt: startAt || '', + startAtText: startAt ? util.formatTime(startAt) : '', + projectNo: '', + productName: '', + materialCode: '', + materialName: '', + rawMaterialBatchNo: '', + rawMaterialBatchOptions: [], + rawMaterialBatchIndex: -1, + rawMaterialBatchError: false, + process: '', + stampingMethod: '', + operatorCount: 1, + processUnitPriceYuan: 0, + isContinuousDie: false, + isMultiPerson: false, + isMisc: false, + standardBeat: 0, + standardWorkload: 0, + changeoverCount: '', + changeoverCountError: false, + goodQty: '', + defectQty: '', + deviceError: false, +}) + +const batchIndexOf = (options = [], value = '') => { + if (!value) { + return -1 + } + return (options || []).findIndex(option => String(option) === String(value)) +} + +const optionToDraftItem = (option, productIndex = 0, startAt = '', equipmentOptions = [], deviceNo = '', rawMaterialBatchOptions = [], rawMaterialBatchNo = '') => { + const selectedDeviceNo = deviceNo || '' + const deviceIndex = selectedDeviceNo + ? Math.max(0, equipmentOptions.findIndex(item => item.deviceNo === selectedDeviceNo)) + : -1 + const batchOptions = rawMaterialBatchOptions || [] + return { + productIndex, + attendancePointName: option ? option.attendancePointName : '', + deviceIndex, + deviceNo: selectedDeviceNo, + startAt: startAt || '', + startAtText: startAt ? util.formatTime(startAt) : '', + projectNo: option ? option.projectNo : '', + productName: option ? option.productName : '', + materialCode: option ? option.materialCode : '', + materialName: option ? option.materialName : '', + rawMaterialBatchNo: rawMaterialBatchNo || '', + rawMaterialBatchOptions: batchOptions, + rawMaterialBatchIndex: batchIndexOf(batchOptions, rawMaterialBatchNo), + rawMaterialBatchError: false, + process: option ? option.process : '', + stampingMethod: option ? option.stampingMethod : '', + operatorCount: option ? option.operatorCount : 1, + processUnitPriceYuan: option ? option.processUnitPriceYuan : 0, + isContinuousDie: option ? !!option.isContinuousDie : false, + isMultiPerson: option ? !!option.isMultiPerson : false, + isMisc: option ? !!option.isMisc : false, + standardBeat: option ? option.standardBeat : 0, + standardWorkload: option ? option.standardWorkload : 0, + changeoverCount: '', + changeoverCountError: false, + goodQty: '', + defectQty: '', + deviceError: false, + } +} + +const apiDraftItemToCamel = (item, options, startAt = '', equipmentOptions = []) => { + const rawMaterialBatchOptions = item && item.raw_material_batch_options ? item.raw_material_batch_options : [] + const rawMaterialBatchNo = item && item.raw_material_batch_no ? item.raw_material_batch_no : '' + if (options.length >= 1 && (!item || !item.product_name)) { + return optionToDraftItem( + options[0], + 0, + startAt, + equipmentOptions, + item && item.device_no, + rawMaterialBatchOptions, + rawMaterialBatchNo, + ) + } + const selectedDeviceNo = item.device_no || '' + const productIndex = Math.max(0, options.findIndex(option => ( + option.projectNo === item.project_no + && option.productName === item.product_name + && (option.process || '') === (item.process_name || '') + ))) + return { + productIndex, + attendancePointName: item.attendance_point_name || '', + deviceNo: selectedDeviceNo, + deviceIndex: selectedDeviceNo + ? Math.max(0, equipmentOptions.findIndex(option => option.deviceNo === selectedDeviceNo)) + : -1, + startAt: item.started_at || startAt || '', + startAtText: util.formatTime(item.started_at || startAt || ''), + projectNo: item.project_no || '', + productName: item.product_name || '', + materialCode: item.material_code || '', + materialName: item.material_name || '', + rawMaterialBatchNo, + rawMaterialBatchOptions, + rawMaterialBatchIndex: batchIndexOf(rawMaterialBatchOptions, rawMaterialBatchNo), + rawMaterialBatchError: false, + process: item.process_name || '', + stampingMethod: item.stamping_method || '', + operatorCount: item.operator_count || 1, + processUnitPriceYuan: item.process_unit_price_yuan || 0, + isContinuousDie: !!item.is_continuous_die || isContinuousDieRow(item), + isMultiPerson: isMultiPersonRow(item), + isMisc: !!item.is_misc || isMiscRow(item), + standardBeat: item.standard_beat || 0, + standardWorkload: item.standard_workload || 0, + changeoverCount: item.changeover_count === null || item.changeover_count === undefined ? '' : String(item.changeover_count || ''), + changeoverCountError: false, + goodQty: '', + defectQty: '', + deviceError: false, + } +} + +const toCamelDraft = row => ({ + sessionId: row.session_id, + attendancePointName: row.attendance_point_name || '', + employeePhone: row.employee_phone, + employeeName: row.employee_name, + startAt: row.start_at, + endAt: row.end_at, + startAtText: util.formatTime(row.start_at), + endAtText: util.formatTime(row.end_at), + durationMinutes: row.duration_minutes, + breakMinutes: 0, + deviceBlocks: (row.device_blocks || []).map(block => { + const options = (block.options || []).map(toCamelProduct) + const equipmentOptions = (block.equipment_options || []).map(toCamelEquipment) + const rawItems = block.items && block.items.length ? block.items : [{}] + return { + deviceNo: block.device_no, + attendancePointName: block.attendance_point_name || row.attendance_point_name || '', + moldName: block.mold_name || block.device_no, + process: block.process_name || '', + stampingMethod: block.stamping_method || '', + moldDisplayName: block.display_name || moldDisplayName(block.mold_name || block.device_no, block.process_name, block.stamping_method), + blockKey: `${block.device_no || ''}||${block.process_name || ''}`, + isMisc: !!block.is_misc || isMiscRow({ product_name: block.mold_name || block.device_no, process_name: block.process_name }), + isContinuousDie: !!block.is_continuous_die || isContinuousDieRow(block), + startAt: block.start_at, + endAt: block.end_at, + durationMinutes: block.duration_minutes || 0, + startAtText: util.formatTime(block.start_at), + endAtText: util.formatTime(block.end_at), + options, + canAddItem: new Set(options.map(option => `${option.projectNo}||${option.productName}||${option.process}`)).size > 1, + equipmentOptions, + items: rawItems.map(item => apiDraftItemToCamel(item, options, block.start_at, equipmentOptions)), + } + }), +}) + +const toSubmitPayload = draft => ({ + session_id: Number(draft.sessionId), + device_blocks: (draft.deviceBlocks || []).map(block => ({ + attendance_point_name: block.attendancePointName || draft.attendancePointName || '', + device_no: block.deviceNo, + process_name: block.process || '', + items: (block.items || []).map(item => ({ + device_no: normalizeDeviceNo(item.deviceNo), + attendance_point_name: item.attendancePointName || block.attendancePointName || draft.attendancePointName || '', + project_no: item.projectNo, + product_name: item.productName, + material_code: item.materialCode || null, + material_name: item.materialName || null, + raw_material_batch_no: item.rawMaterialBatchNo || null, + process_name: item.process || null, + stamping_method: item.stampingMethod || null, + operator_count: Number(item.operatorCount || 1), + process_unit_price_yuan: Number(item.processUnitPriceYuan || 0), + changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0, + is_continuous_die: !!item.isContinuousDie, + is_misc: !!item.isMisc, + remark: item.remark || null, + standard_beat: Number(item.standardBeat || 0), + standard_workload: Number(item.standardWorkload || 0), + good_qty: Number(item.goodQty || 0), + defect_qty: Number(item.defectQty || 0), + scrap_qty: 0, + started_at: item.startAt || null, + })), + })), +}) + +const getCurrentUser = () => { + const auth = requestClient.getAuth() + return auth && auth.user ? auth.user : null +} + +const refreshCurrentUser = async () => { + const auth = requestClient.getAuth() + if (!auth || !auth.accessToken) { + return null + } + const user = toCamelPerson(await requestClient.request({ + url: '/api/auth/me', + })) + requestClient.setAuth({ ...auth, user }) + return user +} + +const saveAuth = data => { + const auth = { + accessToken: data.access_token, + user: toCamelPerson(data.user), + } + requestClient.setAuth(auth) + return auth.user +} + +const normalizeLoginResult = data => { + if (data.needs_temporary_worker) { + return { + needsTemporaryWorker: true, + temporaryToken: data.temporary_token, + temporaryPhone: data.temporary_phone, + temporaryExpiresAt: data.temporary_expires_at || '', + } + } + if (data.needs_selection) { + return { + needsSelection: true, + selectionToken: data.selection_token, + matchedPeople: (data.matched_people || []).map(toCamelPerson), + } + } + return saveAuth(data) +} + +const autoSelectDefaultRole = async result => { + if (!result || !result.needsSelection) { + return result + } + const role = pickDefaultRole(result.matchedPeople) + if (!role || !result.selectionToken) { + return result + } + return selectRole(result.selectionToken, role) +} + +const loginWithPhoneCode = async (phoneCode, selectedRole = '') => { + const data = await requestClient.request({ + url: '/api/auth/wechat-login', + method: 'POST', + data: { + phone_code: phoneCode, + selected_role: selectedRole || null, + }, + }) + return autoSelectDefaultRole(normalizeLoginResult(data)) +} + +const createTemporaryWorker = async (temporaryToken, temporaryName = '', attendancePoint = null) => { + const data = await requestClient.request({ + url: '/api/auth/wechat-login', + method: 'POST', + data: { + create_temporary_worker: true, + temporary_token: temporaryToken, + temporary_name: temporaryName, + temporary_attendance_point_name: attendancePoint && attendancePoint.factory ? attendancePoint.factory.name : null, + temporary_latitude: attendancePoint && attendancePoint.current ? attendancePoint.current.latitude : null, + temporary_longitude: attendancePoint && attendancePoint.current ? attendancePoint.current.longitude : null, + }, + }) + return normalizeLoginResult(data) +} + +const selectRole = async (selectionToken, role) => saveAuth(await requestClient.request({ + url: '/api/auth/select-role', + method: 'POST', + data: { + selection_token: selectionToken, + role, + }, +})) + +const switchRole = async role => saveAuth(await requestClient.request({ + url: '/api/auth/switch-role', + method: 'POST', + data: { + role, + }, +})) + +const logout = () => requestClient.clearAuth() + +const listAttendancePoints = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/attendance-points', + params: { + keyword: filters.keyword, + page: filters.page || 1, + page_size: filters.pageSize || 100, + }, + }) + return toCamelPage(data, toCamelAttendancePoint) +} + +const listAccessibleAttendancePoints = async () => { + const data = await requestClient.request({ + url: '/api/attendance-points/accessible', + }) + return (data || []).map(toCamelAttendancePoint) +} + +const listPublicAttendancePoints = async () => { + const data = await requestClient.request({ + url: '/api/attendance-points/public', + }) + return (data || []).map(toCamelAttendancePoint) +} + +const saveAttendancePoint = async payload => toCamelAttendancePoint(await requestClient.request({ + url: '/api/attendance-points', + method: 'POST', + data: toSnakeAttendancePoint(payload), +})) + +const deleteAttendancePoint = name => requestClient.request({ + url: `/api/attendance-points/${encodeURIComponent(name)}`, + method: 'DELETE', +}) + +const listPeople = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/people', + params: { + keyword: filters.keyword, + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelPerson) +} + +const savePerson = async payload => toCamelPerson(await requestClient.request({ + url: '/api/people', + method: 'POST', + data: toSnakePerson(payload), +})) + +const deletePerson = (phone, role = '') => requestClient.request({ + url: `/api/people/${encodeURIComponent(phone)}`, + method: 'DELETE', + params: { + role, + }, +}) + +const importPeople = filePath => requestClient.upload({ + url: '/api/people/import', + filePath, +}) + +const exportPeople = () => requestClient.download({ + url: '/api/people/export', +}) + +const listProducts = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/products', + params: { + keyword: filters.keyword, + attendance_point_name: filters.attendancePointName, + device_no: filters.deviceNo, + product_name: filters.productName, + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelProduct) +} + +const listMoldNames = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/products/molds', + params: { + keyword: filters.keyword, + limit: filters.limit || 200, + }, + }) + return (data || []).map(toCamelMoldOption) +} + +const saveProduct = async payload => toCamelProduct(await requestClient.request({ + url: '/api/products', + method: 'POST', + data: toSnakeProduct(payload), +})) + +const deleteProduct = product => requestClient.request({ + url: '/api/products', + method: 'DELETE', + params: { + attendance_point_name: product.attendancePointName, + project_no: product.projectNo, + product_name: product.productName, + device_no: product.deviceNo, + process_name: product.rawProcess || product.process, + }, +}) + +const importProducts = filePath => requestClient.upload({ + url: '/api/products/import', + filePath, +}) + +const exportProducts = () => requestClient.download({ + url: '/api/products/export', + params: { t: Date.now() }, +}) + +const listEquipment = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/equipment', + params: { + keyword: filters.keyword, + attendance_point_name: filters.attendancePointName, + device_type: filters.deviceType, + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelEquipment) +} + +const saveEquipment = async payload => toCamelEquipment(await requestClient.request({ + url: '/api/equipment', + method: 'POST', + data: toSnakeEquipment(payload), +})) + +const deleteEquipment = equipment => { + const deviceNo = typeof equipment === 'object' ? equipment.deviceNo : equipment + const attendancePointName = typeof equipment === 'object' ? equipment.attendancePointName : '' + return requestClient.request({ + url: `/api/equipment/${encodeURIComponent(normalizeDeviceNo(deviceNo))}`, + method: 'DELETE', + params: { + attendance_point_name: attendancePointName, + }, + }) +} + +const listNotices = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/notices', + params: { + include_inactive: filters.includeInactive ? 1 : undefined, + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelNotice) +} + +const saveNotice = async payload => { + const noticeId = payload.id + const data = await requestClient.request({ + url: noticeId ? `/api/notices/${noticeId}` : '/api/notices', + method: noticeId ? 'PUT' : 'POST', + data: toSnakeNotice(payload), + }) + return toCamelNotice(data) +} + +const deleteNotice = noticeId => requestClient.request({ + url: `/api/notices/${noticeId}`, + method: 'DELETE', +}) + +const locationPayload = location => ({ + latitude: location && location.latitude !== undefined ? location.latitude : undefined, + longitude: location && location.longitude !== undefined ? location.longitude : undefined, +}) + +const getClockState = async (moldName, processName = '', attendancePointName = '', location = null) => { + const row = await requestClient.request({ + url: '/api/clock/state', + params: { + mold_name: String(moldName || '').trim(), + process_name: String(processName || '').trim(), + attendance_point_name: String(attendancePointName || '').trim(), + ...locationPayload(location), + }, + }) + return { + action: row.action, + actionText: row.action_text, + attendancePointName: row.attendance_point_name || '', + deviceNo: row.device_no, + moldName: row.mold_name || row.device_no, + processName: row.process_name || '', + stampingMethod: row.stamping_method || '', + isCleaning: !!row.is_cleaning || isCleaningRow(row), + isMisc: !!row.is_misc || isMiscRow(row), + displayName: row.display_name || moldDisplayName(row.mold_name || row.device_no, row.process_name, row.stamping_method), + currentMoldName: row.current_mold_name || '', + currentProcessName: row.current_process_name || '', + session: row.session_id ? { id: row.session_id } : null, + startAtText: row.start_at ? util.formatTime(row.start_at) : '', + endAtText: row.end_at ? util.formatTime(row.end_at) : '', + devices: row.devices || [], + autoSubmitHours: row.auto_submit_hours || 15, + } +} + +const startWork = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ + url: '/api/clock/start', + method: 'POST', + data: { + mold_name: String(moldName || '').trim(), + process_name: String(processName || '').trim(), + attendance_point_name: String(attendancePointName || '').trim(), + ...locationPayload(location), + }, +})) + +const switchDevice = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ + url: '/api/clock/switch-device', + method: 'POST', + data: { + mold_name: String(moldName || '').trim(), + process_name: String(processName || '').trim(), + attendance_point_name: String(attendancePointName || '').trim(), + ...locationPayload(location), + }, +})) + +const finishWork = async (moldName, processName = '', attendancePointName = '', location = null) => toCamelSession(await requestClient.request({ + url: '/api/clock/finish', + method: 'POST', + data: { + mold_name: String(moldName || '').trim(), + process_name: String(processName || '').trim(), + attendance_point_name: String(attendancePointName || '').trim(), + ...locationPayload(location), + }, +})) + +const continueWork = async sessionId => toCamelSession(await requestClient.request({ + url: '/api/clock/continue-work', + method: 'POST', + data: { session_id: Number(sessionId) }, +})) + +const createMoldLockFeedback = async lockId => toCamelMoldLockFeedback(await requestClient.request({ + url: '/api/monitor/feedbacks', + method: 'POST', + data: { lock_id: Number(lockId) }, +})) + +const listMonitorLocks = async () => (await requestClient.request({ + url: '/api/monitor/locks', +})).map(toCamelMonitorLock) + +const listMoldLockFeedbacks = async (filters = {}) => toCamelPage(await requestClient.request({ + url: '/api/monitor/feedbacks', + params: { + box: filters.box || 'unread', + page: filters.page || 1, + page_size: filters.pageSize || 10, + }, +}), toCamelMoldLockFeedback) + +const readMoldLockFeedback = async feedbackId => toCamelMoldLockFeedback(await requestClient.request({ + url: `/api/monitor/feedbacks/${feedbackId}/read`, + method: 'POST', +})) + +const releaseMoldLock = async lockId => requestClient.request({ + url: `/api/monitor/locks/${lockId}/release`, + method: 'POST', +}) + +const releaseMoldLockByFeedback = async feedbackId => toCamelMoldLockFeedback(await requestClient.request({ + url: `/api/monitor/feedbacks/${feedbackId}/release`, + method: 'POST', +})) + +const buildReportDraft = async sessionId => toCamelDraft(await requestClient.request({ + url: '/api/reports/draft', + params: { session_id: sessionId }, +})) + +const submitReport = async draft => toCamelReport(await requestClient.request({ + url: '/api/reports', + method: 'POST', + data: toSubmitPayload(draft), +})) + +const submitCleaningReport = async (items, location = null) => toCamelReport(await requestClient.request({ + url: '/api/reports/cleaning', + method: 'POST', + data: { + ...locationPayload(location), + items: (items || []).map(item => ({ + attendance_point_name: item.attendancePointName || '', + device_no: normalizeDeviceNo(item.deviceNo), + device_nos: item.deviceNos && item.deviceNos.length ? item.deviceNos.map(normalizeDeviceNo).filter(Boolean) : [], + product_name: item.moldName || item.productName || item.name || '', + process_name: item.processName || '', + stamping_method: item.stampingMethod || '', + quantity: Number(item.quantity || 0), + })), + }, +})) + +const getReport = async reportId => toCamelReport(await requestClient.request({ + url: `/api/reports/${reportId}`, +})) + +const listReports = async (filters = {}) => { + let url = '/api/reports/mine' + if (filters.status === 'pending' && !filters.employeePhone) { + url = '/api/reviews/pending' + } + if (filters.reviewerPhone && url !== '/api/reviews/pending') { + url = '/api/reviews/mine' + } + + const params = { + start_date: filters.startDate, + end_date: filters.endDate, + page: filters.page, + page_size: filters.pageSize, + include_voided: filters.includeVoided ? 1 : undefined, + } + if (url === '/api/reports/mine' && filters.status) { + params.status = filters.status + } + if (url === '/api/reviews/mine' && filters.status) { + params.status = filters.status + } + + const data = await requestClient.request({ url, params }) + return toCamelPage(data, toCamelReport) +} + +const approveReport = async (reportId, user, patch = {}) => toCamelReport(await requestClient.request({ + url: `/api/reviews/${reportId}/approve`, + method: 'POST', + data: { + start_at: patch.startAt || null, + end_at: patch.endAt || null, + device_corrections: (patch.deviceCorrections || []).map(item => ({ + id: item.id, + scanned_at: item.scannedAt || null, + })), + item_corrections: patch.itemCorrections || [], + review_remark: Object.prototype.hasOwnProperty.call(patch, 'reviewRemark') ? (patch.reviewRemark || null) : undefined, + remark: patch.remark || null, + }, +})) + +const voidReport = async reportId => toCamelReport(await requestClient.request({ + url: `/api/reviews/${reportId}/void`, + method: 'POST', +})) + +const unvoidReport = async reportId => toCamelReport(await requestClient.request({ + url: `/api/reviews/${reportId}/unvoid`, + method: 'POST', +})) + +const toCamelWorkSchedule = row => ({ + id: row.id, + dayStart: row.day_start, + dayEnd: row.day_end, + lunchStart: row.lunch_start, + lunchEnd: row.lunch_end, + dinnerStart: row.dinner_start, + dinnerEnd: row.dinner_end, + overtimeStart: row.overtime_start, + overtimeEnd: row.overtime_end, + nightStart: row.night_start, + nightEnd: row.night_end, + attendanceLatitude: row.attendance_latitude === null || row.attendance_latitude === undefined ? '' : row.attendance_latitude, + attendanceLongitude: row.attendance_longitude === null || row.attendance_longitude === undefined ? '' : row.attendance_longitude, + attendanceRadiusMeters: row.attendance_radius_meters === null || row.attendance_radius_meters === undefined ? '' : row.attendance_radius_meters, + autoSubmitHours: row.auto_submit_hours === null || row.auto_submit_hours === undefined ? 15 : row.auto_submit_hours, + attendancePoints: (row.attendance_points || []).map(toCamelAttendancePoint), + updatedBy: row.updated_by || '', + updatedAt: row.updated_at || '', + updatedAtText: row.updated_at ? util.formatTime(row.updated_at) : '', +}) + +const toSnakeWorkSchedule = row => ({ + day_start: row.dayStart, + day_end: row.dayEnd, + lunch_start: row.lunchStart, + lunch_end: row.lunchEnd, + dinner_start: row.dinnerStart, + dinner_end: row.dinnerEnd, + overtime_start: row.overtimeStart, + overtime_end: row.overtimeEnd, + night_start: row.nightStart, + night_end: row.nightEnd, + attendance_latitude: row.attendanceLatitude === '' || row.attendanceLatitude === undefined ? null : Number(row.attendanceLatitude), + attendance_longitude: row.attendanceLongitude === '' || row.attendanceLongitude === undefined ? null : Number(row.attendanceLongitude), + attendance_radius_meters: row.attendanceRadiusMeters === '' || row.attendanceRadiusMeters === undefined ? null : Number(row.attendanceRadiusMeters), + auto_submit_hours: row.autoSubmitHours === '' || row.autoSubmitHours === undefined ? 15 : Number(row.autoSubmitHours), +}) + +const getWorkSchedule = async () => toCamelWorkSchedule(await requestClient.request({ + url: '/api/work-schedule', +})) + +const saveWorkSchedule = async payload => toCamelWorkSchedule(await requestClient.request({ + url: '/api/work-schedule', + method: 'PUT', + data: toSnakeWorkSchedule(payload), +})) + +const saveWorkScheduleSettings = async payload => toCamelWorkSchedule(await requestClient.request({ + url: '/api/work-schedule/settings', + method: 'PUT', + data: { + ...toSnakeWorkSchedule(payload), + attendance_points: (payload.attendancePoints || []).map(toSnakeAttendancePoint), + }, +})) + +const listDashboard = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/dashboard/reports', + params: { + start_date: filters.startDate, + end_date: filters.endDate, + page: filters.page, + page_size: filters.pageSize, + include_voided: filters.includeVoided ? 1 : undefined, + }, + }) + return toCamelPage(data, row => { + const isMisc = !!row.is_misc || isMiscRow(row) + const noOutputMetric = isMisc || isCleaningRow(row) + const beatComparison = noOutputMetric ? classifyBeat(0, 0) : classifyBeat(row.actual_beat, row.standard_beat) + const workloadComparison = noOutputMetric ? classifyWorkload(0, 0) : classifyWorkload(row.total_good_qty, row.expected_workload) + return { + id: row.id, + attendancePointName: row.attendance_point_name || '', + reportDate: row.report_date, + employeePhone: row.employee_phone, + employeeName: row.employee_name, + projectNo: row.project_no, + productName: row.product_name, + processName: row.process_name, + processDisplayName: moldDisplayName(row.product_name, row.process_name, row.stamping_method), + rawMaterialBatchNo: row.raw_material_batch_no || '', + stampingMethod: row.stamping_method || '', + isCleaning: isCleaningRow(row), + isContinuousDie: isContinuousDieRow(row), + changeoverCount: row.changeover_count || 0, + isMisc, + isSystemAutoSubmitted: !!row.is_system_auto_submitted, + isVoided: !!row.is_voided, + isMultiPerson: isMultiPersonRow(row), + isMultiPersonAssistant: !!row.is_multi_person_assistant, + workerType: row.worker_type || '正式工', + reportCount: row.report_count, + effectiveMinutes: row.effective_minutes, + shiftDistributionText: row.shift_distribution_text || '', + shiftDayMinutes: row.shift_day_minutes || 0, + shiftOvertimeMinutes: row.shift_overtime_minutes || 0, + shiftNightMinutes: row.shift_night_minutes || 0, + shiftOtherMinutes: row.shift_other_minutes || 0, + totalGood: row.total_good_qty, + totalDefect: row.total_defect_qty, + totalOutput: row.total_output_qty, + actualBeat: row.actual_beat, + standardBeat: row.standard_beat, + processUnitPriceYuan: row.process_unit_price_yuan || 0, + referenceWage: round2(row.reference_wage || 0), + expectedWorkload: row.expected_workload, + paceRate: row.pace_rate, + workloadRate: row.workload_rate, + paceText: row.pace_text, + workloadText: row.workload_text, + beatCompareClass: beatComparison.className, + beatReason: beatComparison.reason, + workloadCompareClass: workloadComparison.className, + workloadReason: workloadComparison.reason, + reviewRemark: row.review_remark || '', + correctionPairs: (row.correction_pairs || []).map(pair => ({ + key: pair.key, + label: pair.label, + oldValue: correctionDisplayValue(pair.old_value), + newValue: correctionDisplayValue(pair.new_value), + })), + } + }) +} + +const listUsageStats = async (filters = {}) => { + const data = await requestClient.request({ + url: '/api/usage-stats/summary', + params: { + ...usageStatsParams(filters), + page: filters.page, + page_size: filters.pageSize, + }, + }) + return toCamelPage(data, toCamelUsageStatsRow) +} + +const getUsageStatsDetail = async (filters = {}) => toCamelUsageStatsDetail(await requestClient.request({ + url: '/api/usage-stats/detail', + params: usageStatsParams(filters), +})) + +const listReconciliationYears = async () => { + const data = await requestClient.request({ + url: '/api/reconciliation/years', + }) + return { + years: data.years || [], + currentYear: data.current_year, + } +} + +const listReconciliationLedger = async year => { + const data = await requestClient.request({ + url: '/api/reconciliation', + params: { year }, + }) + return { + year: data.year, + currentYear: data.current_year, + currentMonth: data.current_month, + rows: (data.rows || []).map(row => ({ + attendancePointName: row.attendance_point_name || '', + productName: row.product_name, + uniqueKey: `${row.attendance_point_name || ''}||${row.product_name || ''}`, + months: (row.months || []).map(month => ({ + month: month.month, + reportedGoodQty: month.reported_good_qty || 0, + reconciledGoodQty: month.reconciled_good_qty || 0, + returnQty: month.return_qty || 0, + })), + })), + } +} + +const saveReconciliationEntry = async payload => { + const data = await requestClient.request({ + url: '/api/reconciliation/entry', + method: 'PUT', + data: { + year: Number(payload.year), + month: Number(payload.month), + attendance_point_name: payload.attendancePointName || '', + product_name: payload.productName, + reconciled_good_qty: payload.reconciledGoodQty === undefined ? undefined : Number(payload.reconciledGoodQty || 0), + return_qty: payload.returnQty === undefined ? undefined : Number(payload.returnQty || 0), + }, + }) + return { + month: data.month, + reconciledGoodQty: data.reconciled_good_qty || 0, + returnQty: data.return_qty || 0, + } +} + +const exportDashboard = filters => requestClient.download({ + url: '/api/dashboard/reports/export', + params: { + start_date: filters && filters.startDate, + end_date: filters && filters.endDate, + include_voided: filters && filters.includeVoided ? 1 : undefined, + }, +}) + +const exportUsageStats = (filters = {}) => requestClient.download({ + url: '/api/usage-stats/export', + params: usageStatsParams(filters), +}) + +const generateMoldQr = async mold => { + const original = typeof mold === 'object' ? String(mold.moldName || mold.name || '').trim() : String(mold || '').trim() + const processName = typeof mold === 'object' ? String(mold.processName || '').trim() : '' + const attendancePointName = typeof mold === 'object' ? String(mold.attendancePointName || '').trim() : '' + const data = await requestClient.request({ + url: '/api/devices/molds/qrcode', + method: 'POST', + data: { + attendance_point_name: attendancePointName, + product_name: original, + process_name: processName, + }, + }) + return { + deviceNo: data.device_no, + attendancePointName: data.attendance_point_name || '', + moldName: data.mold_name || data.device_no, + processName: data.process_name || '', + stampingMethod: data.stamping_method || '', + isCleaning: !!data.is_cleaning || isCleaningRow(data), + isMisc: !!data.is_misc || isMiscRow(data), + displayName: data.display_name || moldDisplayName(data.mold_name || data.device_no, data.process_name, data.stamping_method), + qrPath: `${data.page}?scene=${encodeURIComponent(data.scene)}`, + qrUrl: data.qr_url || '', + scene: data.scene, + } +} + +const generateMoldQrBatch = async molds => { + const data = await requestClient.request({ + url: '/api/devices/molds/qrcode/batch', + method: 'POST', + timeout: 120000, + data: { + items: (molds || []).map(mold => ({ + attendance_point_name: String(mold.attendancePointName || '').trim(), + product_name: String(mold.moldName || mold.name || '').trim(), + process_name: String(mold.processName || '').trim(), + })), + }, + }) + return { + count: data.count || 0, + fileName: data.file_name || '', + zipUrl: data.zip_url || '', + } +} + +const toCamelQrcodeBatchTask = row => ({ + id: row.id, + fileName: row.file_name || '', + status: row.status || '', + statusName: row.status_name || '', + itemCount: Number(row.item_count || 0), + completedCount: Number(row.completed_count || 0), + failedCount: Number(row.failed_count || 0), + progressPercent: Number(row.progress_percent || 0), + zipUrl: row.zip_url || '', + errorMessage: row.error_message || '', + startedAt: row.started_at || '', + startedAtText: row.started_at ? util.formatTime(row.started_at) : '', + finishedAt: row.finished_at || '', + finishedAtText: row.finished_at ? util.formatTime(row.finished_at) : '', + createdAt: row.created_at || '', + createdAtText: row.created_at ? util.formatTime(row.created_at) : '', + updatedAt: row.updated_at || '', + isPending: row.status === 'pending', + isRunning: row.status === 'running', + isPaused: row.status === 'paused', + isCompleted: row.status === 'completed', + isFailed: row.status === 'failed', +}) + +const createMoldQrBatchTask = async molds => { + const data = await requestClient.request({ + url: '/api/devices/molds/qrcode/batch/tasks', + method: 'POST', + timeout: 30000, + data: { + items: (molds || []).map(mold => ({ + attendance_point_name: String(mold.attendancePointName || '').trim(), + product_name: String(mold.moldName || mold.name || '').trim(), + process_name: String(mold.processName || '').trim(), + })), + }, + }) + return toCamelQrcodeBatchTask(data) +} + +const listMoldQrBatchTasks = async (filters = {}) => { + const page = await requestClient.request({ + url: '/api/devices/molds/qrcode/batch/tasks', + params: { + page: filters.page || 1, + page_size: filters.pageSize || 5, + }, + }) + return toCamelPage(page, toCamelQrcodeBatchTask) +} + +const stopMoldQrBatchTask = async taskId => toCamelQrcodeBatchTask(await requestClient.request({ + url: `/api/devices/molds/qrcode/batch/tasks/${taskId}/stop`, + method: 'POST', +})) + +const resumeMoldQrBatchTask = async taskId => toCamelQrcodeBatchTask(await requestClient.request({ + url: `/api/devices/molds/qrcode/batch/tasks/${taskId}/resume`, + method: 'POST', +})) + +const deleteMoldQrBatchTask = async taskId => requestClient.request({ + url: `/api/devices/molds/qrcode/batch/tasks/${taskId}`, + method: 'DELETE', +}) + +const downloadFileByUrl = (url, filePath, timeout = 120000) => requestClient.downloadUrl({ + url, + filePath, + timeout, +}) + +const generateDeviceQr = generateMoldQr + +const resolveMoldScene = async scene => { + const data = await requestClient.request({ + url: '/api/devices/resolve-scene', + params: { scene }, + }) + return { + attendancePointName: data.attendance_point_name || '', + moldName: data.mold_name || data.device_no || '', + processName: data.process_name || '', + stampingMethod: data.stamping_method || '', + isCleaning: !!data.is_cleaning || isCleaningRow(data), + isMisc: !!data.is_misc || isMiscRow(data), + displayName: data.display_name || moldDisplayName(data.mold_name || data.device_no, data.process_name, data.stamping_method), + } +} + +const getHomeSummary = async user => { + if (!user) { + return {} + } + const summary = { + productCount: 0, + peopleCount: 0, + pendingCount: 0, + myToday: 0, + approvedToday: 0, + } + const today = util.today() + const tasks = [ + listProducts({ page: 1, pageSize: 1 }).then(result => { summary.productCount = result.total }), + listReports({ startDate: today, endDate: today, page: 1, pageSize: 1 }).then(result => { summary.myToday = result.total }).catch(() => {}), + ] + if (['admin', 'manager'].includes(user.role)) { + tasks.push(listPeople({ page: 1, pageSize: 1 }).then(result => { summary.peopleCount = result.total }).catch(() => {})) + } + if (user.role === 'admin') { + tasks.push(listReports({ + status: 'pending', + page: 1, + pageSize: 1, + }).then(result => { summary.pendingCount = result.total }).catch(() => {})) + tasks.push(listReports({ + reviewerPhone: user.phone, + status: 'approved', + startDate: today, + endDate: today, + page: 1, + pageSize: 1, + }).then(result => { summary.approvedToday = result.total }).catch(() => {})) + } + if (user.role === 'manager') { + tasks.push(listDashboard({ startDate: today, endDate: today, page: 1, pageSize: 1 }).then(result => { summary.approvedToday = result.total }).catch(() => {})) + } + await Promise.all(tasks) + return summary +} + +module.exports = { + approveReport, + buildReportDraft, + calculateStandardWorkload, + classifyBeat, + classifyWorkload, + continueWork, + createTemporaryWorker, + createMoldLockFeedback, + createMoldQrBatchTask, + deleteMoldQrBatchTask, + deleteAttendancePoint, + deleteEquipment, + deletePerson, + deleteProduct, + deleteNotice, + downloadFileByUrl, + exportPeople, + exportProducts, + exportDashboard, + exportUsageStats, + formatDurationAllUnits, + formatDurationMinutes, + finishWork, + generateDeviceQr, + generateMoldQr, + generateMoldQrBatch, + getClockState, + getCurrentUser, + getHomeSummary, + getReport, + getUsageStatsDetail, + getWorkSchedule, + importPeople, + importProducts, + listAccessibleAttendancePoints, + listAttendancePoints, + listDashboard, + listEquipment, + listMoldNames, + listMonitorLocks, + listMoldLockFeedbacks, + listMoldQrBatchTasks, + listNotices, + listPeople, + listProducts, + listPublicAttendancePoints, + listReconciliationLedger, + listReconciliationYears, + listReports, + listUsageStats, + loginWithPhoneCode, + logout, + isContinuousDieRow, + isContinuousDieStampingMethod, + moldDisplayName, + normalizeDeviceNo, + normalizeProcessValue, + processDisplayName, + refreshCurrentUser, + readMoldLockFeedback, + releaseMoldLock, + releaseMoldLockByFeedback, + resolveMoldScene, + roleNames, + saveAttendancePoint, + saveNotice, + saveEquipment, + savePerson, + saveProduct, + saveReconciliationEntry, + saveWorkSchedule, + saveWorkScheduleSettings, + selectRole, + startWork, + resumeMoldQrBatchTask, + stopMoldQrBatchTask, + submitCleaningReport, + submitReport, + switchRole, + switchDevice, + unvoidReport, + voidReport, +} diff --git a/utils/config.js b/utils/config.js new file mode 100644 index 0000000..3f32879 --- /dev/null +++ b/utils/config.js @@ -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', +} diff --git a/utils/locationGuard.js b/utils/locationGuard.js new file mode 100644 index 0000000..37ed0cf --- /dev/null +++ b/utils/locationGuard.js @@ -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, +} diff --git a/utils/mockData.js b/utils/mockData.js new file mode 100644 index 0000000..06bb4af --- /dev/null +++ b/utils/mockData.js @@ -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, +} diff --git a/utils/request.js b/utils/request.js new file mode 100644 index 0000000..76236dc --- /dev/null +++ b/utils/request.js @@ -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, +} diff --git a/utils/storage.js b/utils/storage.js new file mode 100644 index 0000000..16b61e6 --- /dev/null +++ b/utils/storage.js @@ -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, +} diff --git a/utils/util.js b/utils/util.js new file mode 100644 index 0000000..2912dfb --- /dev/null +++ b/utils/util.js @@ -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, +}