chore: initialize mini program frontend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit fa73a3c184
96 changed files with 20561 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
const api = require('../../utils/api')
const locationGuard = require('../../utils/locationGuard')
const decodeRepeated = value => {
let text = String(value || '').trim()
for (let index = 0; index < 3; index += 1) {
try {
const decoded = decodeURIComponent(text)
if (decoded === text) {
break
}
text = decoded
} catch (error) {
break
}
}
return text
}
const itemKey = item => `${item.attendancePointName || ''}||${item.moldName || item.productName || item.name}||${item.processName || ''}`
Page({
data: {
items: [],
attendancePointName: '',
equipmentOptions: [],
equipmentLabels: [],
filling: false,
submitting: false,
deviceModalVisible: false,
activeDeviceIndex: -1,
deviceSelectOptions: [],
pendingDeviceNos: [],
currentLocation: null,
},
onLoad(options) {
const user = api.getCurrentUser()
if (!user || user.role !== 'worker') {
wx.showToast({ title: '请先用冲压工人登录', icon: 'none' })
setTimeout(() => {
wx.reLaunch({ url: '/pages/index/index' })
}, 800)
return
}
const attendancePointName = decodeRepeated(options.attendancePointName || '')
this.setData({ attendancePointName })
this.loadCleaningEquipment(attendancePointName)
this.addResolvedItem({
attendancePointName,
moldName: decodeRepeated(options.moldName || ''),
processName: decodeRepeated(options.processName || ''),
stampingMethod: decodeRepeated(options.stampingMethod || ''),
isCleaning: options.isCleaning === '1',
displayName: decodeRepeated(options.displayName || ''),
})
},
addResolvedItem(raw) {
const item = {
attendancePointName: String(raw.attendancePointName || this.data.attendancePointName || '').trim(),
moldName: String(raw.moldName || raw.name || '').trim(),
processName: String(raw.processName || '').trim(),
stampingMethod: String(raw.stampingMethod || '').trim(),
isCleaning: !!raw.isCleaning || String(raw.stampingMethod || '').trim() === '清洗',
displayName: raw.displayName || api.moldDisplayName(raw.moldName || raw.name, raw.processName, raw.stampingMethod),
deviceNo: '',
deviceNos: [],
quantity: '',
}
if (!item.moldName) {
wx.showToast({ title: '未识别清洗产品', icon: 'none' })
return
}
if (!item.processName) {
wx.showToast({ title: '未识别工序', icon: 'none' })
return
}
if (!item.isCleaning) {
wx.showToast({ title: '只能添加冲压方式为清洗的二维码', icon: 'none' })
return
}
if (this.data.attendancePointName && item.attendancePointName !== this.data.attendancePointName) {
wx.showToast({ title: '只能添加同一考勤点的清洗产品', icon: 'none' })
return
}
const exists = this.data.items.some(existing => itemKey(existing) === itemKey(item))
if (exists) {
wx.showToast({ title: '该清洗产品已添加', icon: 'none' })
return
}
this.setData({
items: this.data.items.concat(item),
})
},
async loadCleaningEquipment(attendancePointName = this.data.attendancePointName) {
try {
const result = await api.listEquipment({
attendancePointName,
deviceType: '清洗设备',
page: 1,
pageSize: 100,
})
const equipmentOptions = result.rows || []
this.setData({
equipmentOptions,
equipmentLabels: equipmentOptions.map(item => item.displayName || item.deviceNo),
})
} catch (error) {
wx.showToast({ title: error.message || '加载清洗设备失败', icon: 'none' })
}
},
extractScene(scanResult) {
const raw = decodeRepeated(scanResult)
if (!raw) {
return ''
}
const patterns = [
/(?:^|[?&])scene=([^&]+)/i,
/(?:^|[?&])mold=([^&]+)/i,
]
for (let index = 0; index < patterns.length; index += 1) {
const matched = raw.match(patterns[index])
if (matched && matched[1]) {
const value = decodeRepeated(matched[1])
return value.startsWith('mold=') ? value : `mold=${value}`
}
}
return raw.startsWith('mold=') ? raw : ''
},
async addByScanResult(scanResult) {
const scene = this.extractScene(scanResult)
if (!scene) {
wx.showToast({ title: '未识别清洗二维码', icon: 'none' })
return
}
try {
const resolved = await api.resolveMoldScene(scene)
await locationGuard.ensureFactoryLocation(resolved.attendancePointName)
this.addResolvedItem(resolved)
} catch (error) {
wx.showToast({ title: error.message || '二维码解析失败', icon: 'none' })
}
},
startFill() {
if (!this.data.items.length) {
wx.showToast({ title: '请先添加清洗产品', icon: 'none' })
return
}
this.setData({ filling: true })
},
async continueAdd() {
try {
await locationGuard.ensureFactoryLocation(this.data.attendancePointName)
} catch (error) {
wx.showModal({
title: '无法继续扫码',
content: error.message || '当前位置不在允许范围内',
showCancel: false,
})
return
}
wx.scanCode({
onlyFromCamera: false,
success: res => {
const candidates = [res.path, res.result, res.rawData]
const raw = candidates.find(item => item) || ''
this.addByScanResult(raw)
},
})
},
abandon() {
wx.showModal({
title: '放弃报工',
content: '确认放弃本次清洗报工?',
confirmText: '放弃',
confirmColor: '#d92d20',
success: res => {
if (res.confirm) {
wx.reLaunch({ url: '/pages/index/index' })
}
},
})
},
onQuantityInput(e) {
const index = e.currentTarget.dataset.index
this.setData({
[`items[${index}].quantity`]: e.detail.value,
})
},
noop() {},
openDeviceSelector(e) {
const index = Number(e.currentTarget.dataset.index)
const item = this.data.items[index]
if (!item) {
return
}
if (!this.data.equipmentOptions.length) {
wx.showToast({ title: '暂无清洗设备', icon: 'none' })
return
}
const selected = new Set(item.deviceNos && item.deviceNos.length ? item.deviceNos : (item.deviceNo ? String(item.deviceNo).split('、') : []))
const deviceSelectOptions = this.data.equipmentOptions.map(equipment => ({
...equipment,
checked: selected.has(equipment.deviceNo),
}))
this.setData({
activeDeviceIndex: index,
pendingDeviceNos: Array.from(selected).filter(Boolean),
deviceSelectOptions,
deviceModalVisible: true,
})
},
closeDeviceSelector() {
this.setData({
deviceModalVisible: false,
activeDeviceIndex: -1,
deviceSelectOptions: [],
pendingDeviceNos: [],
})
},
onDeviceSelectionChange(e) {
const pendingDeviceNos = e.detail.value || []
const selected = new Set(pendingDeviceNos)
this.setData({
pendingDeviceNos,
deviceSelectOptions: this.data.deviceSelectOptions.map(item => ({
...item,
checked: selected.has(item.deviceNo),
})),
})
},
confirmDeviceSelection() {
const index = Number(this.data.activeDeviceIndex)
if (index < 0 || !this.data.items[index]) {
this.closeDeviceSelector()
return
}
if (!this.data.pendingDeviceNos.length) {
wx.showToast({ title: '请至少选择一台清洗设备', icon: 'none' })
return
}
const deviceNos = this.data.pendingDeviceNos
this.setData({
[`items[${index}].deviceNos`]: deviceNos,
[`items[${index}].deviceNo`]: deviceNos.join('、'),
deviceModalVisible: false,
activeDeviceIndex: -1,
deviceSelectOptions: [],
pendingDeviceNos: [],
})
},
async submit() {
if (this.data.submitting) {
return
}
const missingDevice = this.data.items.find(item => !item.deviceNo)
if (missingDevice) {
wx.showToast({ title: `${missingDevice.moldName}请选择清洗设备号`, icon: 'none' })
return
}
const invalid = this.data.items.find(item => Number(item.quantity || 0) <= 0)
if (invalid) {
wx.showToast({ title: `${invalid.moldName}请填写清洗数量`, icon: 'none' })
return
}
this.setData({ submitting: true })
wx.showLoading({ title: '提交中' })
try {
const locationResult = await locationGuard.ensureFactoryLocation(this.data.attendancePointName)
const report = await api.submitCleaningReport(this.data.items, locationResult.current)
wx.reLaunch({
url: `/pages/reportResult/reportResult?reportId=${report.id}`,
})
} catch (error) {
wx.showToast({ title: error.message || '提交失败', icon: 'none' })
} finally {
wx.hideLoading()
this.setData({ submitting: false })
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "清洗报工"
}
+94
View File
@@ -0,0 +1,94 @@
<scroll-view class="page safe-bottom" scroll-y type="list">
<view class="header">
<text class="title">清洗报工</text>
<text class="subtitle">添加本次清洗产品,只填写清洗数量,不计算工时</text>
</view>
<view class="card">
<view class="row">
<view class="row-left">
<text class="label">已添加清洗产品</text>
<text class="value">{{items.length}} 项</text>
<text wx:if="{{attendancePointName}}" class="subtitle">考勤点 {{attendancePointName}}</text>
</view>
<text class="pill primary">清洗</text>
</view>
</view>
<view wx:if="{{!items.length}}" class="list-empty">暂无清洗产品</view>
<view wx:for="{{items}}" wx:key="displayName" wx:for-index="index" class="card cleaning-card">
<view class="row">
<view class="row-left">
<text class="value">{{item.displayName || item.moldName}}</text>
<text wx:if="{{item.attendancePointName}}" class="subtitle">考勤点 {{item.attendancePointName}}</text>
<text class="subtitle">工序 {{item.processName}} · 冲压方式 {{item.stampingMethod || '-'}}</text>
</view>
<text class="pill success">已添加</text>
</view>
<view wx:if="{{filling}}" class="form-item">
<text class="label">清洗设备号</text>
<view
wx:if="{{equipmentOptions.length}}"
class="picker multi-device-picker {{item.deviceNo ? '' : 'field-error'}}"
data-index="{{index}}"
bindtap="openDeviceSelector"
>
{{item.deviceNo || '请选择清洗设备号(可多选)'}}
</view>
<view wx:else class="readonly">暂无清洗设备,请先在设备管理中维护</view>
</view>
<view wx:if="{{filling}}" class="form-item">
<text class="label">清洗数量</text>
<input
class="input number-input"
type="digit"
value="{{item.quantity}}"
placeholder="请输入清洗数量"
data-index="{{index}}"
bindinput="onQuantityInput"
/>
</view>
</view>
<view wx:if="{{!filling}}" class="cleaning-actions">
<button class="btn primary full" bindtap="startFill">开始报工</button>
<button class="btn secondary full" bindtap="continueAdd">继续添加清洗产品</button>
<button class="btn danger full" bindtap="abandon">放弃报工</button>
</view>
<view wx:else class="cleaning-actions">
<button class="btn secondary full" bindtap="continueAdd">继续添加清洗产品</button>
<button class="btn primary full" disabled="{{submitting}}" bindtap="submit">{{submitting ? '提交中' : '提交清洗报工'}}</button>
<button class="btn danger full" bindtap="abandon">放弃报工</button>
</view>
<view wx:if="{{deviceModalVisible}}" class="modal-mask" catchtap="closeDeviceSelector">
<view class="modal-panel device-modal-panel" catchtap="noop">
<view class="device-modal-head">
<view>
<text class="device-modal-title">选择清洗设备</text>
<text class="subtitle">已选择 {{pendingDeviceNos.length}} 台</text>
</view>
</view>
<checkbox-group bindchange="onDeviceSelectionChange">
<scroll-view class="device-option-list" scroll-y>
<label
wx:for="{{deviceSelectOptions}}"
wx:key="deviceNo"
class="device-option {{item.checked ? 'checked' : ''}}"
>
<checkbox value="{{item.deviceNo}}" checked="{{item.checked}}" />
<view class="device-option-text">
<text class="device-option-title">{{item.deviceNo}}</text>
<text wx:if="{{item.remark}}" class="device-option-subtitle">{{item.remark}}</text>
</view>
</label>
</scroll-view>
</checkbox-group>
<view class="device-modal-actions">
<button class="btn secondary full" bindtap="closeDeviceSelector">取消</button>
<button class="btn primary full" bindtap="confirmDeviceSelection">确定</button>
</view>
</view>
</view>
</scroll-view>
+142
View File
@@ -0,0 +1,142 @@
.cleaning-card {
border-left: 6rpx solid #12a375;
}
.cleaning-card .form-item {
margin-top: 20rpx;
}
.cleaning-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 22rpx;
margin-top: 30rpx;
padding-bottom: 20rpx;
}
.cleaning-actions .btn {
width: 88%;
margin: 0;
}
.multi-device-picker {
min-height: 84rpx;
line-height: 1.35;
padding-top: 22rpx;
padding-bottom: 22rpx;
white-space: normal;
}
.multi-device-picker.field-error {
border-color: #e5484d;
background: linear-gradient(180deg, #fff7f7 0%, #fff1f1 100%);
color: #b42318;
box-shadow:
inset 0 4rpx 8rpx rgba(180, 35, 24, 0.08),
0 0 0 4rpx rgba(229, 72, 77, 0.12),
0 6rpx 0 rgba(255, 197, 197, 0.65),
0 10rpx 18rpx rgba(180, 35, 24, 0.08);
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 30rpx 22rpx calc(env(safe-area-inset-bottom) + 30rpx);
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.device-modal-panel {
width: 100%;
max-height: 86vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.device-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.device-modal-title {
display: block;
color: #162232;
font-size: 30rpx;
font-weight: 850;
line-height: 1.3;
}
.device-option-list {
height: 56vh;
margin-bottom: 22rpx;
border: 2rpx solid #b8c6dc;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 10rpx 0 rgba(174, 188, 208, 0.46),
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.device-option {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #e6edf6;
box-sizing: border-box;
}
.device-option.checked {
background: #eef6ff;
}
.device-option:last-child {
border-bottom: 0;
}
.device-option-text {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.device-option-title {
color: #162232;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.device-option-subtitle {
color: #667085;
font-size: 24rpx;
font-weight: 650;
line-height: 1.3;
}
.device-modal-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
+612
View File
@@ -0,0 +1,612 @@
const api = require('../../utils/api')
const locationGuard = require('../../utils/locationGuard')
Page({
data: {
user: null,
moldName: '',
processName: '',
attendancePointName: '',
stampingMethod: '',
isCleaning: false,
isMisc: false,
moldDisplayName: '',
sceneToken: '',
state: null,
roleChoices: [],
selectionToken: '',
toastVisible: false,
toastMessage: '',
locationChecking: true,
locationAllowed: false,
locationMessage: '正在校验当前位置',
currentLocation: null,
occupiedDetail: null,
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
},
toastTimer: null,
onLoad(options) {
this.resolveOpenMold(options)
},
async resolveOpenMold(options) {
const scene = this.decodeRepeated(options.scene || '')
let moldName = this.extractMoldName(options, scene)
let processName = this.extractProcessName(options, scene)
let attendancePointName = this.decodeRepeated(options.attendancePointName || options.attendance_point_name || '')
let stampingMethod = this.decodeRepeated(options.stampingMethod || options.stamping_method || '')
let isCleaning = false
let isMisc = false
let moldDisplayName = api.moldDisplayName(moldName, processName, stampingMethod)
const sceneToken = this.extractSceneToken(scene || options.moldName || '')
if (!moldName && sceneToken && api.getCurrentUser()) {
try {
const resolved = await api.resolveMoldScene(sceneToken)
moldName = resolved.moldName
processName = resolved.processName
attendancePointName = resolved.attendancePointName || ''
stampingMethod = resolved.stampingMethod || ''
isCleaning = !!resolved.isCleaning
isMisc = !!resolved.isMisc
moldDisplayName = resolved.displayName
} catch (error) {
this.showTip(error.message || '未找到模具二维码')
}
}
this.setData({ moldName, processName, attendancePointName, stampingMethod, isCleaning, isMisc, moldDisplayName, sceneToken })
this.verifyOpenLocation()
},
async resolveSceneAfterLogin() {
if (this.data.moldName || !this.data.sceneToken) {
return true
}
try {
const resolved = await api.resolveMoldScene(this.data.sceneToken)
this.setData({
moldName: resolved.moldName,
processName: resolved.processName,
attendancePointName: resolved.attendancePointName || '',
stampingMethod: resolved.stampingMethod || '',
isCleaning: !!resolved.isCleaning,
isMisc: !!resolved.isMisc,
moldDisplayName: resolved.displayName,
})
await this.verifyOpenLocation()
return false
} catch (error) {
this.showTip(error.message || '未找到模具二维码')
return false
}
},
extractMoldName(options, scene) {
const explicit = options.moldName || options.deviceNo
if (explicit) {
const value = this.decodeRepeated(explicit)
return value.startsWith('mold=') ? '' : value
}
const raw = this.decodeRepeated(scene)
const moldMatched = raw.match(/(?:^|[?&])?moldName=([^&]+)/)
if (moldMatched && moldMatched[1]) {
return this.decodeRepeated(moldMatched[1])
}
const oldMatched = raw.match(/(?:^|[?&])?deviceNo=([^&]+)/)
if (oldMatched && oldMatched[1]) {
return this.decodeRepeated(oldMatched[1])
}
if (raw.startsWith('mold=')) {
return ''
}
return raw
},
extractProcessName(options, scene) {
const explicit = options.processName || options.process_name
if (explicit) {
return this.decodeRepeated(explicit)
}
const raw = this.decodeRepeated(scene)
const matched = raw.match(/(?:^|[?&])?(?:processName|process_name)=([^&]+)/)
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
},
extractSceneToken(scene) {
const raw = this.decodeRepeated(scene)
const matched = raw.match(/(?:^|[?&])?mold=([^&]+)/)
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
},
decodeRepeated(value) {
let text = String(value || '').trim()
for (let index = 0; index < 3; index += 1) {
try {
const decoded = decodeURIComponent(text)
if (decoded === text) {
break
}
text = decoded
} catch (error) {
break
}
}
return text
},
isCleaningProcess() {
return !!this.data.isCleaning
|| String(this.data.stampingMethod || '').trim() === '清洗'
},
openCleaningReport() {
const moldName = encodeURIComponent(this.data.moldName || '')
const processName = encodeURIComponent(this.data.processName || '')
const stampingMethod = encodeURIComponent(this.data.stampingMethod || '')
const displayName = encodeURIComponent(this.data.moldDisplayName || '')
const attendancePointName = encodeURIComponent(this.data.attendancePointName || '')
wx.redirectTo({
url: `/pages/cleaningReport/cleaningReport?moldName=${moldName}&processName=${processName}&attendancePointName=${attendancePointName}&stampingMethod=${stampingMethod}&isCleaning=1&displayName=${displayName}`,
})
},
onShow() {
if (this.data.locationAllowed) {
this.load()
}
},
onUnload() {
if (this.toastTimer) {
clearTimeout(this.toastTimer)
this.toastTimer = null
}
},
showTip(message, duration = 5000) {
if (this.toastTimer) {
clearTimeout(this.toastTimer)
}
this.setData({
toastVisible: true,
toastMessage: String(message || ''),
})
this.toastTimer = setTimeout(() => {
this.setData({ toastVisible: false, toastMessage: '' })
this.toastTimer = null
}, duration)
},
async verifyOpenLocation() {
this.setData({
locationChecking: true,
locationAllowed: false,
locationMessage: '正在校验当前位置',
})
try {
const locationScope = api.getCurrentUser() ? 'accessible' : 'public'
const locationResult = await locationGuard.ensureFactoryLocation(this.data.attendancePointName, locationScope)
this.setData({
locationChecking: false,
locationAllowed: true,
locationMessage: '',
currentLocation: locationResult.current,
})
await this.load()
} catch (error) {
this.setData({
locationChecking: false,
locationAllowed: false,
locationMessage: error.message || '当前位置不在允许范围内,不能扫码报工',
currentLocation: null,
user: null,
state: null,
})
}
},
async load() {
let user = api.getCurrentUser()
let state = null
this.setData({ occupiedDetail: null })
if (user && !this.data.moldName && this.data.sceneToken) {
const shouldContinue = await this.resolveSceneAfterLogin()
if (!shouldContinue) {
return
}
}
if (user && this.data.moldName) {
try {
user = await api.refreshCurrentUser() || user
if (this.isCleaningProcess()) {
this.setData({ user, state: null }, () => {
this.openCleaningReport()
})
return
}
state = await api.getClockState(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
if (state && state.isCleaning) {
this.setData({
user,
state: null,
stampingMethod: state.stampingMethod || '',
isCleaning: true,
isMisc: false,
}, () => {
this.openCleaningReport()
})
return
}
if (state && state.isMisc) {
this.setData({
stampingMethod: state.stampingMethod || '',
isMisc: true,
moldDisplayName: state.displayName || this.data.moldDisplayName,
})
}
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
user = api.getCurrentUser()
this.setData({ user, state: null })
return
}
if (await this.handleUnfinishedReportError(error)) {
user = api.getCurrentUser()
this.setData({ user, state: null })
return
}
this.showTip(error.message)
user = api.getCurrentUser()
}
}
this.setData({ user, state })
},
async wechatLogin(e) {
const phoneCode = e.detail && e.detail.code
if (!phoneCode) {
this.showTip('未获得手机号授权', 3000)
return
}
wx.showLoading({ title: '登录中' })
try {
const result = await api.loginWithPhoneCode(phoneCode)
if (result.needsTemporaryWorker) {
wx.hideLoading()
await this.confirmTemporaryWorker(result)
return
}
if (result.needsSelection) {
this.setData({
roleChoices: result.matchedPeople,
selectionToken: result.selectionToken,
})
return
}
await this.load()
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
}
},
confirmTemporaryWorker(result) {
return new Promise(resolve => {
this.temporaryWorkerResult = result
this.temporaryWorkerResolve = resolve
this.setData({
temporaryWorkerModalVisible: true,
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
temporaryWorkerName: '',
})
})
},
onTemporaryWorkerNameInput(e) {
this.setData({ temporaryWorkerName: e.detail.value })
},
closeTemporaryWorkerModal() {
this.setData({
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
})
this.temporaryWorkerResult = null
if (this.temporaryWorkerResolve) {
this.temporaryWorkerResolve()
this.temporaryWorkerResolve = null
}
},
async submitTemporaryWorker() {
const result = this.temporaryWorkerResult
const name = String(this.data.temporaryWorkerName || '').trim()
if (!name) {
this.showTip('请输入姓名', 3000)
return
}
if (!result || !result.temporaryToken) {
this.showTip('临时工注册信息已失效,请重新登录')
this.closeTemporaryWorkerModal()
return
}
this.setData({ temporaryWorkerModalVisible: false })
wx.showLoading({ title: '定位中' })
try {
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
wx.showLoading({ title: '创建中' })
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
await this.load()
wx.showToast({ title: '临时工已生效', icon: 'success' })
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
this.closeTemporaryWorkerModal()
}
},
async chooseRole(e) {
const role = e.currentTarget.dataset.role
wx.showLoading({ title: '登录中' })
try {
await api.selectRole(this.data.selectionToken, role)
this.setData({ roleChoices: [], selectionToken: '' })
await this.load()
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
}
},
canOperate() {
const { user, moldName, state } = this.data
if (!user) {
this.showTip('请先登录', 3000)
return false
}
if (user.role !== 'worker') {
this.showTip('只有冲压工人可以报工', 3000)
return false
}
if (!moldName) {
this.showTip('缺少模具名称', 3000)
return false
}
if (!state) {
this.showTip('当前模具码不可用', 3000)
return false
}
return true
},
formatAutoSubmitHours(value) {
const hours = Number(value || 15)
if (!Number.isFinite(hours)) {
return '15'
}
return Number.isInteger(hours) ? String(hours) : String(Math.round(hours * 100) / 100)
},
confirmStartWork() {
const hoursText = this.formatAutoSubmitHours(this.data.state && this.data.state.autoSubmitHours)
return new Promise(resolve => {
wx.showModal({
title: '开始上班',
content: `该班次时长最多为${hoursText}个小时。${hoursText}小时后未下班报工,系统将自动报工`,
confirmText: '开始上班',
cancelText: '取消',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
},
async switchMold() {
if (!this.canOperate()) {
return
}
try {
await api.switchDevice(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
wx.showToast({ title: '已记录换模具', icon: 'success' })
await this.load()
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
async finishWork() {
if (!this.canOperate()) {
return
}
try {
const session = await api.finishWork(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${session.id}`,
})
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
async handleAction() {
if (!this.canOperate()) {
return
}
const { moldName, state } = this.data
try {
if (state.action === 'start') {
const confirmed = await this.confirmStartWork()
if (!confirmed) {
return
}
await api.startWork(moldName, this.data.processName, this.data.attendancePointName, this.data.currentLocation)
wx.showToast({ title: '已开始上班', icon: 'success' })
await this.load()
return
}
if (state.action === 'switch') {
await this.switchMold()
return
}
if (state.action === 'report' || (state.action === 'finish' && state.endAtText && state.session)) {
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${state.session.id}`,
})
return
}
if (state.action === 'refinish') {
await this.finishWork()
return
}
await this.finishWork()
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
resolveUnfinishedReportDetail(error) {
if (!error || error.statusCode !== 400) {
return null
}
let detail = error.detail
if (typeof detail === 'string') {
try {
detail = JSON.parse(detail)
} catch (parseError) {
detail = null
}
}
return detail && detail.code === 'unfinished_report_mold_mismatch' ? detail : null
},
async openUnfinishedReport(detail) {
let sessionId = detail.session_id
if (detail.reporting_expired) {
wx.showLoading({ title: '处理中' })
try {
const session = await api.finishWork(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
sessionId = session.id
} finally {
wx.hideLoading()
}
}
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${sessionId}`,
})
},
handleUnfinishedReportError(error) {
const detail = this.resolveUnfinishedReportDetail(error)
if (!detail || !detail.session_id) {
return Promise.resolve(false)
}
const message = detail.message || error.message || '当前有未完成的报工'
return new Promise(resolve => {
wx.showModal({
title: '未完成报工',
content: `${message}\n是否立刻前往未完成的报工?`,
cancelText: '否',
confirmText: '是',
confirmColor: '#1463ff',
success: async res => {
if (!res.confirm) {
resolve(true)
return
}
try {
await this.openUnfinishedReport(detail)
} catch (openError) {
this.showTip(openError.message || '打开未完成报工失败')
} finally {
resolve(true)
}
},
fail: () => resolve(true),
})
})
},
resolveMoldOccupiedDetail(error) {
if (!error || error.statusCode !== 423) {
return null
}
let detail = error.detail
if (typeof detail === 'string') {
try {
detail = JSON.parse(detail)
} catch (parseError) {
detail = null
}
}
return detail && detail.code === 'mold_occupied' ? detail : null
},
handleMoldOccupiedError(error) {
const detail = this.resolveMoldOccupiedDetail(error)
if (!detail) {
return Promise.resolve(false)
}
this.setData({ occupiedDetail: detail })
return new Promise(resolve => {
wx.showModal({
title: '模具已被占用',
content: detail.message || `已经被${detail.occupied_name || detail.occupied_phone || '其他员工'}占用,请联系管理员解除占用`,
confirmText: '反馈给管理员',
cancelText: '确定',
confirmColor: '#1463ff',
success: async res => {
if (!res.confirm) {
resolve(true)
return
}
wx.showLoading({ title: '反馈中' })
try {
await api.createMoldLockFeedback(detail.lock_id)
wx.showToast({ title: '反馈成功', icon: 'success' })
} catch (feedbackError) {
this.showTip(feedbackError.message || '反馈失败')
} finally {
wx.hideLoading()
resolve(true)
}
},
fail: () => resolve(true),
})
})
},
async feedbackMoldOccupied() {
const detail = this.data.occupiedDetail
if (!detail || !detail.lock_id) {
this.showTip('占用信息缺失,无法反馈')
return
}
wx.showLoading({ title: '反馈中' })
try {
await api.createMoldLockFeedback(detail.lock_id)
wx.showToast({ title: '反馈成功', icon: 'success' })
} catch (error) {
this.showTip(error.message || '反馈失败')
} finally {
wx.hideLoading()
}
},
noop() {},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "报工打卡"
}
+108
View File
@@ -0,0 +1,108 @@
<view class="page safe-bottom">
<view class="header">
<text class="title">报工打卡</text>
<view class="device-badge">
<text class="device-label">考勤点</text>
<text class="subtitle">{{attendancePointName || '-'}}</text>
<text class="device-label">当前模具</text>
<text class="device-no">{{moldDisplayName || moldName || '-'}}</text>
</view>
</view>
<block wx:if="{{locationAllowed}}">
<view class="card">
<view class="row">
<view>
<text class="label">上班打卡时间</text>
<text class="value">{{state.startAtText || '-'}}</text>
</view>
<view class="text-right">
<text class="label">下班打卡时间</text>
<text class="value">{{state.endAtText || '-'}}</text>
</view>
</view>
</view>
<view class="card">
<view class="row">
<view class="row-left">
<text class="label">当前人员</text>
<block wx:if="{{user}}">
<text class="value">{{user.name}} · {{user.phone}}</text>
<text class="subtitle">{{user.role === 'worker' ? '冲压工人' : user.role === 'admin' ? '管理员' : '经理'}}</text>
</block>
<text wx:else class="value">未登录</text>
</view>
</view>
</view>
<view wx:if="{{!user}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<view wx:if="{{state && user && state.action === 'switch_or_finish'}}" class="clock-action-row">
<button class="btn secondary clock-action-btn" bindtap="switchMold">换模具</button>
<button class="btn primary clock-action-btn" bindtap="finishWork">打卡下班</button>
</view>
<button
wx:elif="{{state && user}}"
class="btn primary full clock-btn"
bindtap="handleAction"
>
{{state.actionText}}
</button>
<view wx:elif="{{occupiedDetail}}" class="card occupied-card">
<text class="value">模具已被占用</text>
<text class="subtitle">{{occupiedDetail.message || '该模具已被其他员工占用,请联系管理员解除占用'}}</text>
<view class="button-row">
<button class="btn secondary full" bindtap="feedbackMoldOccupied">反馈给管理员</button>
</view>
</view>
<view wx:else class="list-empty">当前模具码不可用</view>
</block>
<view wx:else class="card">
<text class="value">{{locationChecking ? '正在校验位置' : '无法打开报工页面'}}</text>
<text class="subtitle">{{locationMessage}}</text>
</view>
<view wx:if="{{toastVisible}}" class="clock-toast">
<text>{{toastMessage}}</text>
</view>
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
<view class="temp-worker-panel" catchtap="noop">
<text class="temp-worker-title">创建临时工</text>
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
<view class="temp-worker-field">
<text class="temp-worker-label">姓名</text>
<input
class="input temp-worker-input"
value="{{temporaryWorkerName}}"
placeholder="请输入姓名"
bindinput="onTemporaryWorkerNameInput"
confirm-type="done"
cursor-spacing="24"
/>
</view>
<view class="button-row temp-worker-actions">
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
</view>
</view>
</view>
</view>
+105
View File
@@ -0,0 +1,105 @@
.clock-btn {
margin-top: 36rpx;
height: 104rpx;
line-height: 104rpx;
font-size: 32rpx;
border-radius: 18rpx;
}
.clock-action-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-top: 36rpx;
}
.clock-action-btn {
width: 100%;
height: 104rpx;
margin: 0;
padding: 0;
line-height: 104rpx;
border-radius: 18rpx;
font-size: 32rpx;
box-sizing: border-box;
}
.occupied-card {
margin-top: 30rpx;
border-color: #d92d20;
background: linear-gradient(180deg, #fffafa 0%, #fff1f1 100%);
}
.device-badge {
display: inline-flex;
align-items: center;
gap: 16rpx;
margin-top: 18rpx;
padding: 14rpx 22rpx;
border: 1rpx solid #b8c4d4;
border-radius: 18rpx;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(225, 232, 241, 0.92)),
#edf2f7;
box-shadow:
0 9rpx 0 rgba(173, 185, 201, 0.72),
0 16rpx 22rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
}
.device-label {
color: #667085;
font-size: 24rpx;
font-weight: 700;
}
.device-no {
color: #162232;
font-size: 46rpx;
font-weight: 800;
line-height: 1;
}
.role-list {
margin-top: 22rpx;
}
.role-btn {
margin-top: 12rpx;
height: 72rpx;
line-height: 72rpx;
border-radius: 14rpx;
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
color: #1456c2;
font-size: 26rpx;
font-weight: 600;
box-shadow:
0 7rpx 0 rgba(144, 178, 235, 0.62),
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
}
.role-btn::after {
border: 0;
}
.clock-toast {
position: fixed;
left: 72rpx;
right: 72rpx;
top: 48%;
z-index: 120;
padding: 24rpx 28rpx;
border-radius: 18rpx;
background: rgba(32, 37, 45, 0.9);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
line-height: 1.5;
text-align: center;
word-break: break-all;
box-shadow:
0 12rpx 0 rgba(15, 23, 42, 0.2),
0 22rpx 34rpx rgba(15, 23, 42, 0.2);
box-sizing: border-box;
}
+134
View File
@@ -0,0 +1,134 @@
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
startDate: '',
endDate: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
refreshing: false,
filterVoided: true,
},
onLoad(options) {
const defaultDate = util.today()
this.setData({
startDate: options.startDate || defaultDate,
endDate: options.endDate || defaultDate,
})
},
onShow() {
this.load()
},
async load(page = this.data.page) {
try {
const result = await api.listDashboard({
startDate: this.data.startDate,
endDate: this.data.endDate,
page,
pageSize: 8,
includeVoided: !this.data.filterVoided,
})
this.setData({
rows: result.rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onStartChange(e) {
this.setData({ startDate: e.detail.value, page: 1 })
this.load(1)
},
onEndChange(e) {
this.setData({ endDate: e.detail.value, page: 1 })
this.load(1)
},
shiftRangeDate(field, days) {
const otherField = field === 'startDate' ? 'endDate' : 'startDate'
const current = this.data[field]
const other = this.data[otherField]
const base = current || other || util.today()
const updates = {
[field]: util.addDays(base, days),
page: 1,
}
if (!current && !other) {
updates[otherField] = base
}
this.setData(updates)
this.load(1)
},
decreaseStartDate() {
this.shiftRangeDate('startDate', -1)
},
increaseEndDate() {
this.shiftRangeDate('endDate', 1)
},
toggleFilterVoided() {
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
this.load(1)
},
onFilterVoidedChange(e) {
this.setData({ filterVoided: !!e.detail.value, page: 1 })
this.load(1)
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
async exportFile() {
wx.showLoading({ title: '导出中' })
try {
const filePath = await api.exportDashboard({
startDate: this.data.startDate,
endDate: this.data.endDate,
includeVoided: !this.data.filterVoided,
})
wx.openDocument({
filePath,
fileType: 'xlsx',
showMenu: true,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "报工看板"
}
+136
View File
@@ -0,0 +1,136 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<view class="dashboard-header-row">
<view class="dashboard-header-main">
<text class="title">报工看板</text>
<text class="subtitle">同人同日同项目、产品、工序、冲压方式的已审核报工自动合并</text>
</view>
<view class="void-filter-switch">
<text>过滤作废</text>
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
</view>
</view>
</view>
<view class="filter-bar date-filter-bar">
<view class="date-filter-cell">
<button class="date-step-btn" bindtap="decreaseStartDate">-</button>
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
<view class="picker">{{startDate || '开始日期'}}</view>
</picker>
</view>
<view class="date-filter-cell end">
<button class="date-step-btn" bindtap="increaseEndDate">+</button>
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
<view class="picker">{{endDate || '结束日期'}}</view>
</picker>
</view>
</view>
<button class="btn primary full export-btn" bindtap="exportFile">导出看板数据</button>
<view wx:if="{{!rows.length}}" class="list-empty">暂无已审核报工</view>
<view wx:for="{{rows}}" wx:key="id" class="card dashboard-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}}">
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
<text wx:elif="{{item.correctionPairs.length}}" class="modified-watermark">更改</text>
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">超时-系统自动提交</text>
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length ? 'lower-watermark' : ''}}">清洗</text>
<text wx:if="{{item.isMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length ? 'lower-watermark' : ''}}">包含杂活</text>
<text wx:if="{{item.isMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning || item.isMultiPerson ? 'lower-watermark' : ''}} {{item.isMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.correctionPairs.length || item.isMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
<view class="row">
<view class="row-left">
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.employeePhone}} · {{item.projectNo}} / {{item.productName}}</text>
<text class="subtitle">{{item.processDisplayName || item.productName || '-'}}</text>
<text class="subtitle">{{item.isMisc ? '杂活事项' : '工序'}} {{item.processName || '-'}} · 冲压方式 {{item.stampingMethod || '-'}} · {{item.reportCount}} 条报工</text>
<text wx:if="{{!item.isCleaning && !item.isMisc}}" class="subtitle">材料库存批次号 {{item.rawMaterialBatchNo || '-'}}</text>
<text wx:if="{{item.shiftDistributionText}}" class="subtitle">{{item.shiftDistributionText}}</text>
<text wx:else class="subtitle">有效工时 {{item.effectiveMinutes}} 分钟</text>
<view wx:if="{{item.correctionPairs.length}}" class="correction-list">
<view wx:for="{{item.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
<text>{{pair.label}} </text>
<text class="old-value">{{pair.oldValue}}</text>
<text> {{pair.newValue}}</text>
</view>
</view>
</view>
<text class="pill success">已合并</text>
</view>
<view class="divider"></view>
<view class="board-grid">
<view>
<text class="label">{{item.isMisc ? '处理杂活' : '成品数量'}}</text>
<text class="value {{item.workloadCompareClass}}">
{{item.isMisc ? '不计产量' : item.totalGood}}
<text wx:if="{{item.workloadReason}}" class="metric-reason">{{item.workloadReason}}</text>
</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">不良数量</text>
<text class="value">{{item.totalDefect}}</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">标准工作量</text>
<text class="value">{{item.expectedWorkload}}</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">实际节拍</text>
<text class="value {{item.beatCompareClass}}">
{{item.actualBeat}} 秒
<text wx:if="{{item.beatReason}}" class="metric-reason">{{item.beatReason}}</text>
</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">标准节拍</text>
<text class="value">{{item.standardBeat}} 秒</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">节拍对比</text>
<text class="value {{item.beatCompareClass}}">{{item.paceText}}</text>
</view>
<view wx:if="{{!item.isMisc}}">
<text class="label">工作量对比</text>
<text class="value {{item.workloadCompareClass}}">{{item.workloadText}}</text>
</view>
<view wx:if="{{item.isMisc}}">
<text class="label">工时</text>
<text class="value">{{item.effectiveMinutes}} 分钟</text>
</view>
<view wx:if="{{!item.isCleaning && !item.isMisc}}">
<text class="label">材料库存批次号</text>
<text class="value">{{item.rawMaterialBatchNo || '-'}}</text>
</view>
<view wx:if="{{item.isContinuousDie}}">
<text class="label">换料次数</text>
<text class="value">{{item.changeoverCount || 0}}</text>
</view>
</view>
<view wx:if="{{item.reviewRemark}}" class="dashboard-remark">
<text class="label">备注</text>
<text class="value">{{item.reviewRemark}}</text>
</view>
<view class="salary-divider"></view>
<view class="salary-row">
<text class="label">参考工资</text>
<text class="salary-value">¥{{item.referenceWage || 0}}</text>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
+79
View File
@@ -0,0 +1,79 @@
.dashboard-header-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
}
.dashboard-header-main {
min-width: 0;
width: 100%;
flex: none;
}
.dashboard-header-row .void-filter-switch {
align-self: flex-end;
width: 240rpx;
}
.dashboard-card {
position: relative;
overflow: hidden;
}
.cleaning-watermark {
position: absolute;
right: 28rpx;
top: 18rpx;
z-index: 1;
padding: 8rpx 18rpx;
border: 2rpx solid rgba(18, 163, 117, 0.55);
border-radius: 12rpx;
color: rgba(8, 116, 67, 0.72);
background: rgba(226, 255, 243, 0.82);
font-size: 30rpx;
font-weight: 900;
transform: rotate(10deg);
pointer-events: none;
}
.board-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
padding-top: 4rpx;
}
.salary-divider {
height: 2rpx;
margin: 20rpx 0 16rpx;
background: #e1e8f2;
}
.dashboard-remark {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-top: 18rpx;
padding: 16rpx;
border: 1rpx solid #dfe7f2;
border-radius: 12rpx;
background: #f8fbff;
}
.dashboard-remark .value {
word-break: break-all;
}
.salary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.salary-value {
color: #c2410c;
font-size: 34rpx;
font-weight: 900;
}
+489
View File
@@ -0,0 +1,489 @@
const api = require('../../utils/api')
Page({
data: {
keyword: '',
selectedMold: null,
moldOptions: [],
filteredMoldOptions: [],
batchMoldOptions: [],
batchKeyword: '',
dropdownVisible: false,
selectedMoldKeys: [],
selectedMoldCount: 0,
allFilteredSelected: false,
batchModalVisible: false,
batchGenerating: false,
generatedMoldName: '',
qrPath: '',
qrUrl: '',
qrImageUrl: '',
qrLoadError: '',
taskRows: [],
taskPage: 1,
taskPageInput: '1',
taskPageSize: 5,
taskTotal: 0,
taskTotalPages: 1,
taskLoading: false,
swipedTaskId: null,
},
onShow() {
this.loadMolds()
this.loadBatchTasks()
this.startTaskPolling()
},
onHide() {
this.stopTaskPolling()
},
onUnload() {
this.stopTaskPolling()
},
startTaskPolling() {
this.stopTaskPolling()
this.taskTimer = setInterval(() => {
this.loadBatchTasks({ silent: true })
}, 5000)
},
stopTaskPolling() {
if (this.taskTimer) {
clearInterval(this.taskTimer)
this.taskTimer = null
}
},
async loadMolds() {
try {
const moldOptions = await api.listMoldNames({ limit: 500 })
const availableKeys = new Set(moldOptions.map(item => item.uniqueKey))
const selectedMoldKeys = this.data.selectedMoldKeys.filter(key => availableKeys.has(key))
this.setData({
moldOptions,
selectedMoldKeys,
selectedMoldCount: selectedMoldKeys.length,
...this.buildFilteredState(this.data.keyword, moldOptions, selectedMoldKeys),
})
} catch (error) {
wx.showToast({ title: error.message || '加载模具失败', icon: 'none' })
}
},
async loadBatchTasks(options = {}) {
if (this.data.taskLoading && options.silent) {
return
}
if (!options.silent) {
this.setData({ taskLoading: true })
}
try {
const result = await api.listMoldQrBatchTasks({
page: this.data.taskPage,
pageSize: this.data.taskPageSize,
})
this.setData({
taskRows: result.rows,
taskPage: result.page,
taskPageInput: String(result.page || 1),
taskTotal: result.total,
taskTotalPages: result.totalPages,
swipedTaskId: result.rows.some(row => row.id === this.data.swipedTaskId) ? this.data.swipedTaskId : null,
})
} catch (error) {
if (!options.silent) {
wx.showToast({ title: error.message || '加载任务失败', icon: 'none' })
}
} finally {
if (!options.silent) {
this.setData({ taskLoading: false })
}
}
},
filterMolds(keyword, source = this.data.moldOptions) {
const text = String(keyword || '').trim().toLowerCase()
if (!text) {
return source.slice(0, 500)
}
return source.filter(item => (
String(item.displayName || '').toLowerCase().includes(text)
|| String(item.moldName || item.name || '').toLowerCase().includes(text)
|| String(item.processName || '').toLowerCase().includes(text)
|| String(item.stampingMethod || '').toLowerCase().includes(text)
|| String(item.attendancePointName || '').toLowerCase().includes(text)
)).slice(0, 500)
},
buildFilteredState(keyword, source = this.data.moldOptions, selectedKeys = this.data.selectedMoldKeys) {
const selectedSet = new Set(selectedKeys || [])
const filteredMoldOptions = this.filterMolds(keyword, source).map(item => ({
...item,
checked: selectedSet.has(item.uniqueKey),
}))
return {
filteredMoldOptions,
allFilteredSelected: filteredMoldOptions.length > 0 && filteredMoldOptions.every(item => item.checked),
}
},
buildBatchState(selectedKeys = this.data.selectedMoldKeys, keyword = this.data.batchKeyword) {
const selectedSet = new Set(selectedKeys || [])
const batchMoldOptions = this.filterMolds(keyword, this.data.moldOptions).map(item => ({
...item,
checked: selectedSet.has(item.uniqueKey),
}))
return {
batchMoldOptions,
allFilteredSelected: batchMoldOptions.length > 0 && batchMoldOptions.every(item => item.checked),
}
},
setBatchSelection(selectedMoldKeys) {
this.setData({
selectedMoldKeys,
selectedMoldCount: selectedMoldKeys.length,
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, selectedMoldKeys),
...this.buildBatchState(selectedMoldKeys),
})
},
onInput(e) {
const keyword = e.detail.value
this.setData({
keyword,
selectedMold: null,
...this.buildFilteredState(keyword),
dropdownVisible: true,
})
},
showDropdown() {
this.setData({
dropdownVisible: true,
...this.buildFilteredState(this.data.keyword),
})
},
selectMold(e) {
const index = Number(e.currentTarget.dataset.index)
const mold = this.data.filteredMoldOptions[index]
if (!mold) {
return
}
this.setData({
selectedMold: mold,
keyword: mold.displayName,
...this.buildFilteredState(mold.displayName),
dropdownVisible: false,
})
},
noop() {},
openBatchModal() {
this.setData({
batchModalVisible: true,
dropdownVisible: false,
batchKeyword: '',
...this.buildBatchState(this.data.selectedMoldKeys, ''),
})
},
closeBatchModal() {
if (this.data.batchGenerating) {
return
}
this.setData({ batchModalVisible: false })
},
onBatchSelectionChange(e) {
const visibleKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
const selectedVisibleKeys = e.detail.value || []
const selectedSet = new Set(this.data.selectedMoldKeys.filter(key => !visibleKeys.includes(key)))
selectedVisibleKeys.forEach(key => selectedSet.add(key))
this.setBatchSelection(Array.from(selectedSet))
},
onBatchInput(e) {
const batchKeyword = e.detail.value
this.setData({
batchKeyword,
...this.buildBatchState(this.data.selectedMoldKeys, batchKeyword),
})
},
toggleSelectAll() {
const batchKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
if (!batchKeys.length) {
wx.showToast({ title: '当前没有可选模具', icon: 'none' })
return
}
let selectedKeys = []
const selectedSet = new Set(this.data.selectedMoldKeys)
if (this.data.allFilteredSelected) {
batchKeys.forEach(key => selectedSet.delete(key))
} else {
batchKeys.forEach(key => selectedSet.add(key))
}
selectedKeys = Array.from(selectedSet)
this.setBatchSelection(selectedKeys)
},
getSelectedMolds() {
const selectedSet = new Set(this.data.selectedMoldKeys)
return this.data.moldOptions.filter(item => selectedSet.has(item.uniqueKey))
},
batchZipFilePath(fileName) {
const safeName = String(fileName || `模具二维码_${Date.now()}.zip`).replace(/[\\/:*?"<>|]/g, '_')
return `${wx.env.USER_DATA_PATH}/${safeName}`
},
shareZipFile(filePath, fileName) {
return new Promise((resolve, reject) => {
if (!wx.shareFileMessage) {
reject(new Error('当前微信版本不支持转发ZIP文件'))
return
}
wx.shareFileMessage({
filePath,
fileName,
success: resolve,
fail: reject,
})
})
},
isRequestTimeout(error) {
const message = `${error && error.message || ''} ${error && error.errMsg || ''}`.toLowerCase()
return message.includes('timeout') || message.includes('超时')
},
async createBatchTask() {
const molds = this.getSelectedMolds()
if (!molds.length) {
wx.showToast({ title: '请先勾选模具', icon: 'none' })
return
}
this.setData({ batchGenerating: true })
wx.showLoading({ title: '创建任务中' })
try {
const task = await api.createMoldQrBatchTask(molds)
wx.hideLoading()
wx.showToast({ title: `已创建${task.itemCount || molds.length}个二维码任务`, icon: 'success' })
this.setData({
batchModalVisible: false,
selectedMoldKeys: [],
selectedMoldCount: 0,
taskPage: 1,
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, []),
...this.buildBatchState([], this.data.batchKeyword),
})
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
if (this.isRequestTimeout(error)) {
this.setData({
batchModalVisible: false,
taskPage: 1,
})
this.loadBatchTasks()
wx.showModal({
title: '创建结果待确认',
content: '请求超时,但任务可能已经创建。已刷新任务列表,请先查看列表状态,避免重复创建。',
showCancel: false,
})
} else {
wx.showToast({ title: error.message || '创建任务失败', icon: 'none' })
}
} finally {
this.setData({ batchGenerating: false })
}
},
changeTaskPage(e) {
const direction = Number(e.currentTarget.dataset.direction || 0)
const nextPage = Math.max(1, Math.min(this.data.taskTotalPages, this.data.taskPage + direction))
if (nextPage === this.data.taskPage) {
return
}
this.setData({ taskPage: nextPage }, () => this.loadBatchTasks())
},
onTaskPageInput(e) {
this.setData({ taskPageInput: e.detail.value })
},
jumpTaskPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.taskPageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.taskPage, this.data.taskTotalPages))
if (target === this.data.taskPage) {
this.setData({ taskPageInput: String(this.data.taskPage || 1) })
return
}
this.setData({ taskPage: target }, () => this.loadBatchTasks())
},
refreshBatchTasks() {
this.loadBatchTasks()
},
onTaskTouchStart(e) {
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
this.taskTouchStartX = touch.clientX
this.taskTouchStartY = touch.clientY
},
onTaskTouchMove(e) {
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
const deltaX = touch.clientX - this.taskTouchStartX
const deltaY = touch.clientY - this.taskTouchStartY
if (Math.abs(deltaX) < 45 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) {
return
}
const id = Number(e.currentTarget.dataset.id)
if (deltaX < 0) {
this.setData({ swipedTaskId: id })
} else if (this.data.swipedTaskId === id) {
this.setData({ swipedTaskId: null })
}
},
onTaskTouchEnd() {
this.taskTouchStartX = 0
this.taskTouchStartY = 0
},
async stopBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showLoading({ title: '停止中' })
try {
await api.stopMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已停止', icon: 'success' })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '停止失败', icon: 'none' })
}
},
async resumeBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showLoading({ title: '继续中' })
try {
await api.resumeMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已继续生成', icon: 'success' })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '继续失败', icon: 'none' })
}
},
async shareBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task || !task.zipUrl) {
wx.showToast({ title: 'ZIP还未生成完成', icon: 'none' })
return
}
wx.showLoading({ title: '准备转发' })
try {
const fileName = task.fileName || `模具二维码_${Date.now()}.zip`
const filePath = await api.downloadFileByUrl(task.zipUrl, this.batchZipFilePath(fileName), 120000)
wx.hideLoading()
await this.shareZipFile(filePath, fileName)
} catch (error) {
wx.hideLoading()
const message = error && error.errMsg && error.errMsg.includes('cancel')
? '已取消转发'
: (error.message || '转发失败')
wx.showToast({ title: message, icon: 'none' })
}
},
async deleteBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showModal({
title: '删除ZIP任务',
content: '删除后会同时删除这条记录及对应ZIP包,是否确定删除?',
cancelText: '否',
confirmText: '删除',
confirmColor: '#d92d20',
success: async res => {
if (!res.confirm) {
return
}
wx.showLoading({ title: '删除中' })
try {
await api.deleteMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已删除', icon: 'success' })
this.setData({ swipedTaskId: null })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
}
},
})
},
async generate() {
const mold = this.data.selectedMold
if (!mold || !mold.moldName || !mold.processName) {
wx.showToast({ title: '请选择产品名称、工序和冲压方式', icon: 'none' })
return
}
wx.showLoading({ title: '生成中' })
try {
const result = await api.generateMoldQr(mold)
const qrUrl = this.withCacheBuster(result.qrUrl)
this.setData({
generatedMoldName: result.displayName || result.moldName || result.deviceNo,
qrPath: result.qrPath,
qrUrl,
qrImageUrl: '',
qrLoadError: '',
})
if (!result.qrUrl) {
wx.showToast({ title: '微信配置未生成图片', icon: 'none' })
} else {
this.prepareQrImage(qrUrl)
}
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
withCacheBuster(url) {
if (!url) {
return ''
}
const separator = url.indexOf('?') === -1 ? '?' : '&'
return `${url}${separator}_t=${Date.now()}`
},
prepareQrImage(url) {
wx.downloadFile({
url,
success: res => {
if (res.statusCode === 200 && res.tempFilePath) {
this.setData({ qrImageUrl: res.tempFilePath, qrLoadError: '' })
return
}
this.setData({ qrImageUrl: url })
},
fail: () => {
this.setData({
qrImageUrl: url,
qrLoadError: '二维码图片下载失败,已切换为直接显示',
})
},
})
},
onQrLoad() {
this.setData({ qrLoadError: '' })
},
onQrError() {
this.setData({ qrLoadError: '二维码图片加载失败,点击空白区域可放大查看' })
},
preview() {
if (!this.data.qrUrl) {
return
}
wx.previewImage({
urls: [this.data.qrUrl],
current: this.data.qrUrl,
})
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "模具二维码"
}
+191
View File
@@ -0,0 +1,191 @@
<view class="page safe-bottom">
<view class="header">
<text class="title">模具二维码</text>
<text class="subtitle">选择产品名称、工序和冲压方式生成扫码报工入口</text>
</view>
<view class="card qr-work-card">
<view class="form-item">
<view class="field-head">
<text class="label">产品名称 / 工序 / 冲压方式</text>
<button class="btn mini secondary batch-entry-btn" bindtap="openBatchModal">批量生成</button>
</view>
<input
class="input"
value="{{keyword}}"
placeholder="输入产品、工序或冲压方式搜索模具"
bindfocus="showDropdown"
bindinput="onInput"
/>
<view wx:if="{{dropdownVisible}}" class="mold-dropdown">
<view
wx:for="{{filteredMoldOptions}}"
wx:key="uniqueKey"
class="mold-option"
data-index="{{index}}"
bindtap="selectMold"
>
<text class="mold-option-name">{{item.moldName || item.name}}</text>
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
</view>
<view wx:if="{{!filteredMoldOptions.length}}" class="mold-empty">未找到模具</view>
</view>
</view>
<button class="btn primary full" bindtap="generate">生成二维码</button>
<view wx:if="{{qrUrl}}" class="qr-result">
<view class="qr-frame" bindtap="preview">
<image
class="qr-image"
src="{{qrImageUrl || qrUrl}}"
mode="widthFix"
show-menu-by-longpress="{{true}}"
bindload="onQrLoad"
binderror="onQrError"
></image>
</view>
<text wx:if="{{qrLoadError}}" class="qr-error">{{qrLoadError}}</text>
<text class="value">模具 {{generatedMoldName}}</text>
<text wx:if="{{selectedMold && selectedMold.attendancePointName}}" class="subtitle">考勤点 {{selectedMold.attendancePointName}}</text>
<text class="subtitle">{{qrPath}}</text>
</view>
</view>
<view class="card batch-task-card">
<view class="task-head">
<view>
<text class="task-title">ZIP任务列表</text>
<text class="subtitle">批量二维码后台生成,完成后可转发ZIP</text>
</view>
<button class="btn mini secondary" loading="{{taskLoading}}" bindtap="refreshBatchTasks">刷新</button>
</view>
<view wx:if="{{!taskRows.length}}" class="mold-empty">暂无ZIP任务</view>
<view
wx:for="{{taskRows}}"
wx:key="id"
wx:for-index="taskIndex"
class="task-swipe-wrap"
data-id="{{item.id}}"
bindtouchstart="onTaskTouchStart"
bindtouchmove="onTaskTouchMove"
bindtouchend="onTaskTouchEnd"
>
<view
class="task-delete-btn"
data-index="{{taskIndex}}"
catchtap="deleteBatchTask"
>
<text>删除</text>
</view>
<view class="task-item {{swipedTaskId == item.id ? 'swiped' : ''}}">
<view class="task-row">
<view class="task-main">
<text class="task-file">{{item.fileName}}</text>
<text class="task-meta">二维码数量 {{item.itemCount}} · {{item.statusName}}</text>
</view>
<view class="task-side">
<text class="task-status {{item.isCompleted ? 'success' : ''}} {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'danger' : ''}}">{{item.progressPercent}}%</text>
<button
wx:if="{{item.isPending || item.isRunning}}"
class="btn mini secondary task-small-btn"
data-index="{{taskIndex}}"
catchtap="stopBatchTask"
>停止</button>
<button
wx:if="{{item.isPaused}}"
class="btn mini primary task-small-btn"
data-index="{{taskIndex}}"
catchtap="resumeBatchTask"
>继续</button>
</view>
</view>
<view class="progress-track">
<view class="progress-fill {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'failed' : ''}}" style="width: {{item.progressPercent}}%;"></view>
</view>
<view class="task-time-grid">
<view>
<text class="label">开始时间</text>
<text class="value">{{item.startedAtText || '-'}}</text>
</view>
<view>
<text class="label">完成时间</text>
<text class="value">{{item.finishedAtText || '-'}}</text>
</view>
</view>
<text wx:if="{{item.errorMessage}}" class="task-error">{{item.errorMessage}}</text>
<view class="task-actions-row">
<button
wx:if="{{item.isCompleted && item.zipUrl}}"
class="btn primary full task-action-btn"
data-index="{{taskIndex}}"
catchtap="shareBatchTask"
>转发ZIP</button>
</view>
</view>
</view>
<view wx:if="{{taskRows.length}}" class="button-row pager-row task-pager">
<button class="btn secondary pager-nav" data-direction="-1" bindtap="changeTaskPage" disabled="{{taskPage <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{taskPageInput}}" bindinput="onTaskPageInput" bindconfirm="jumpTaskPage" />
<text class="page-total-label">/ {{taskTotalPages}}</text>
<text class="page-jump-action" bindtap="jumpTaskPage">跳</text>
</view>
<button class="btn secondary pager-nav" data-direction="1" bindtap="changeTaskPage" disabled="{{taskPage >= taskTotalPages}}">下一页</button>
</view>
</view>
<view wx:if="{{batchModalVisible}}" class="modal-mask batch-modal-mask" catchtap="closeBatchModal">
<view class="modal-panel batch-modal-panel" catchtap="noop">
<view class="batch-head">
<view>
<text class="batch-title">批量生成二维码</text>
<text class="subtitle">已选择 {{selectedMoldCount}} 个模具</text>
</view>
<button class="btn mini secondary batch-select-btn" bindtap="toggleSelectAll">
{{allFilteredSelected ? '取消全选' : '全选'}}
</button>
</view>
<view class="batch-search">
<input
class="input"
value="{{batchKeyword}}"
placeholder="筛选产品、工序、冲压方式或考勤点"
bindinput="onBatchInput"
/>
</view>
<checkbox-group bindchange="onBatchSelectionChange">
<scroll-view class="batch-list" scroll-y>
<label
wx:for="{{batchMoldOptions}}"
wx:key="uniqueKey"
class="batch-option {{item.checked ? 'checked' : ''}}"
>
<checkbox value="{{item.uniqueKey}}" checked="{{item.checked}}" />
<view class="batch-option-text">
<text class="mold-option-name">{{item.moldName || item.name}}</text>
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
</view>
</label>
<view wx:if="{{!batchMoldOptions.length}}" class="mold-empty">未找到模具</view>
</scroll-view>
</checkbox-group>
<view class="batch-actions">
<button class="btn secondary full" bindtap="closeBatchModal">取消</button>
<button
class="btn primary full"
loading="{{batchGenerating}}"
bindtap="createBatchTask"
>生成ZIP任务</button>
</view>
</view>
</view>
</view>
+440
View File
@@ -0,0 +1,440 @@
.qr-work-card {
border-left: 6rpx solid #12a375;
}
.field-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 10rpx;
}
.field-head .label {
margin-bottom: 0;
}
.batch-entry-btn {
flex: 0 0 168rpx;
margin: 0;
}
.mold-dropdown {
overflow-y: auto;
max-height: 420rpx;
margin-top: 12rpx;
border: 2rpx solid #b8c6dc;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 10rpx 0 rgba(174, 188, 208, 0.46),
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.mold-option {
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #e6edf6;
color: #162232;
font-size: 27rpx;
font-weight: 650;
line-height: 1.35;
}
.mold-option-name {
color: #162232;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.mold-option-process {
color: #667085;
font-size: 24rpx;
font-weight: 650;
line-height: 1.3;
}
.mold-option-tag {
align-self: flex-start;
padding: 6rpx 12rpx;
border: 2rpx solid rgba(217, 119, 6, 0.58);
border-radius: 10rpx;
color: #9a4a05;
background: #fff7e8;
font-size: 22rpx;
font-weight: 900;
line-height: 1;
}
.mold-option-tag.continuous-tag {
color: #b45f00;
border-color: rgba(217, 119, 6, 0.58);
background: #fff4e0;
}
.mold-option:last-child {
border-bottom: 0;
}
.mold-option:active {
background: #eef5ff;
}
.mold-empty {
padding: 32rpx 24rpx;
color: #667085;
text-align: center;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.batch-modal-mask {
align-items: flex-start;
padding: 28rpx 20rpx calc(env(safe-area-inset-bottom) + 28rpx);
}
.batch-modal-panel {
max-height: 92vh;
}
.batch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 18rpx;
}
.batch-title {
display: block;
color: #162232;
font-size: 30rpx;
font-weight: 850;
line-height: 1.3;
}
.batch-select-btn {
flex: 0 0 156rpx;
margin: 0;
}
.batch-search {
margin-bottom: 18rpx;
}
.batch-list {
height: 64vh;
margin-bottom: 22rpx;
border: 2rpx solid #b8c6dc;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 10rpx 0 rgba(174, 188, 208, 0.46),
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.batch-option {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #e6edf6;
box-sizing: border-box;
}
.batch-option.checked {
background: #eef6ff;
}
.batch-option:last-child {
border-bottom: 0;
}
.batch-option-text {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.batch-actions {
display: grid;
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.24fr);
gap: 16rpx;
}
.batch-task-card {
border-left: 6rpx solid #1463ff;
}
.task-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 18rpx;
}
.task-title {
display: block;
color: #162232;
font-size: 30rpx;
font-weight: 850;
line-height: 1.3;
}
.task-swipe-wrap {
position: relative;
overflow: hidden;
margin-bottom: 22rpx;
}
.task-delete-btn {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 100%;
margin: 0;
padding: 0;
border-radius: 0 16rpx 16rpx 0;
color: #ffffff;
background: #d92d20;
font-size: 28rpx;
font-weight: 900;
line-height: normal;
box-sizing: border-box;
}
.task-delete-btn text {
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
line-height: 1;
}
.task-item {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 0;
padding: 22rpx;
border: 2rpx solid #c8d3e3;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
box-shadow:
0 9rpx 0 rgba(180, 193, 213, 0.48),
0 15rpx 24rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
box-sizing: border-box;
transition: transform 180ms ease;
}
.task-item.swiped {
transform: translateX(-150rpx);
}
.task-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.task-main {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.task-file {
color: #162232;
font-size: 27rpx;
font-weight: 850;
line-height: 1.35;
word-break: break-all;
}
.task-meta {
color: #667085;
font-size: 24rpx;
font-weight: 650;
line-height: 1.35;
}
.task-status {
min-width: 96rpx;
padding: 8rpx 12rpx;
border-radius: 999rpx;
color: #1463ff;
background: #eef5ff;
font-size: 24rpx;
font-weight: 900;
text-align: center;
}
.task-side {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.task-small-btn {
min-width: 108rpx;
height: 54rpx;
line-height: 54rpx;
margin: 0;
padding: 0 14rpx;
font-size: 22rpx;
border-radius: 12rpx;
}
.task-status.success {
color: #087443;
background: #e8fff4;
}
.task-status.paused {
color: #8a5a00;
background: #fff7df;
}
.task-status.danger {
color: #b42318;
background: #fff1f0;
}
.progress-track {
overflow: hidden;
height: 18rpx;
border-radius: 999rpx;
background: #dbe4f0;
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.12);
}
.progress-fill {
height: 100%;
min-width: 8rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #12a375 100%);
}
.progress-fill.failed {
background: linear-gradient(90deg, #d92d20 0%, #f79009 100%);
}
.progress-fill.paused {
background: linear-gradient(90deg, #f79009 0%, #fdb022 100%);
}
.task-time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.task-error {
color: #b42318;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
word-break: break-all;
}
.task-actions-row {
display: flex;
gap: 14rpx;
margin-top: 2rpx;
}
.task-action-btn {
flex: 1;
margin-top: 2rpx;
}
.task-pager {
gap: 14rpx;
margin-top: 10rpx;
}
.qr-result {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
margin-top: 26rpx;
padding-top: 26rpx;
border-top: 2rpx solid #e6edf6;
}
.qr-frame {
width: 520rpx;
max-width: 100%;
min-height: 520rpx;
padding: 16rpx;
border: 1rpx solid #ccd8e6;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 12rpx 0 rgba(205, 216, 229, 0.58),
0 18rpx 28rpx rgba(22, 34, 50, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.qr-image {
display: block;
width: 100%;
height: auto;
}
.qr-error {
color: #b42318;
font-size: 24rpx;
line-height: 1.4;
text-align: center;
}
+634
View File
@@ -0,0 +1,634 @@
const api = require('../../utils/api')
const locationGuard = require('../../utils/locationGuard')
const avatarKey = phone => `jh_wrs_avatar_${phone}`
Page({
data: {
activeTab: 'home',
currentUser: null,
currentRoleName: '',
currentRolesText: '',
canSwitchRole: false,
avatarPath: '',
avatarText: '',
roleChoices: [],
selectionToken: '',
summary: {},
showOverview: false,
showWorkerTrend: false,
workerTrend: {
total: 0,
max: 0,
points: [],
segments: [],
},
notices: [],
noticeList: [],
actions: [],
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
locationInitialized: false,
loading: false,
refreshing: false,
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
},
onShow() {
this.load({ refreshLocation: !this.data.locationInitialized }).catch(error => {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
})
},
async load(options = {}) {
let currentUser = api.getCurrentUser()
let summary = {}
let showOverview = false
let showWorkerTrend = false
let workerTrend = { total: 0, max: 0, points: [], segments: [] }
let notices = []
let locationView = {
currentAttendancePointName: this.data.currentAttendancePointName,
locationStatusText: this.data.locationStatusText,
locationEntered: this.data.locationEntered,
}
let locationInitialized = this.data.locationInitialized
if (currentUser) {
try {
currentUser = await api.refreshCurrentUser() || currentUser
showOverview = currentUser.role !== 'worker'
showWorkerTrend = currentUser.role === 'worker'
if (showOverview) {
summary = await api.getHomeSummary(currentUser)
}
if (showWorkerTrend) {
workerTrend = await this.loadWorkerTrend()
}
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
currentUser = api.getCurrentUser()
showOverview = !!currentUser && currentUser.role !== 'worker'
showWorkerTrend = !!currentUser && currentUser.role === 'worker'
}
if (currentUser) {
notices = await this.loadNotices()
if (options.refreshLocation || !locationInitialized) {
locationView = await this.refreshLocationStatus()
locationInitialized = true
}
}
} else {
locationInitialized = false
locationView = {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
}
const actions = this.buildActions(currentUser)
const overviewItems = this.buildOverviewItems(currentUser, summary)
const userView = this.buildUserView(currentUser)
this.setData({
currentUser,
summary,
overviewItems,
showOverview,
showWorkerTrend,
workerTrend,
notices,
noticeList: notices.slice(0, 3),
actions,
...locationView,
locationInitialized,
...userView,
})
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load({ refreshLocation: true })
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
async loadNotices() {
try {
const result = await api.listNotices({ page: 1, pageSize: 6 })
return result.rows || []
} catch (error) {
return []
}
},
async refreshLocationStatus() {
try {
const result = await locationGuard.getCurrentAttendancePoint()
if (result && result.factory) {
return {
currentAttendancePointName: result.factory.name,
locationStatusText: `当前位置:已进入 ${result.factory.name} 考勤点`,
locationEntered: true,
}
}
} catch (error) {
return {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
}
return {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
},
buildUserView(user) {
if (!user) {
return {
currentRoleName: '',
currentRolesText: '',
canSwitchRole: false,
avatarPath: '',
avatarText: '',
}
}
return {
currentRoleName: user.roleName || api.roleNames[user.role] || user.role,
currentRolesText: (user.roleNames || []).join('、') || user.roleName || '',
canSwitchRole: !!(user.rolesDisplay && user.rolesDisplay.length > 1),
avatarPath: wx.getStorageSync(avatarKey(user.phone)) || '',
avatarText: this.buildDefaultAvatarText(user.name),
}
},
buildOverviewItems(user, summary = {}) {
if (!user || user.role === 'worker') {
return []
}
const today = this.formatDate(new Date())
return [
{
key: 'products',
label: '产品',
value: summary.productCount || 0,
url: '/pages/manageProducts/manageProducts?readonly=1',
},
{
key: 'people',
label: '人员',
value: summary.peopleCount || 0,
url: '/pages/managePeople/managePeople?readonly=1',
},
...(user.role === 'admin' ? [{
key: 'pending',
label: '待审核',
value: summary.pendingCount || 0,
alert: (summary.pendingCount || 0) > 0,
url: '/pages/records/records?mode=pending&status=pending',
}] : []),
...(user.role === 'admin' ? [{
key: 'approved',
label: '今日通过',
value: summary.approvedToday || 0,
url: `/pages/records/records?mode=audit&status=approved&startDate=${today}&endDate=${today}`,
}] : []),
]
},
buildDefaultAvatarText(name) {
const text = String(name || '').trim()
if (!text) {
return '嘉'
}
const givenName = text.length > 1 ? text.slice(1) : text
return givenName.slice(0, 2)
},
switchTab(e) {
const activeTab = e.currentTarget.dataset.tab
this.setData({ activeTab })
},
buildRecentDates(days = 7) {
const dates = []
const today = new Date()
for (let index = days - 1; index >= 0; index -= 1) {
const date = new Date(today)
date.setDate(today.getDate() - index)
const value = this.formatDate(date)
dates.push({
value,
label: value.slice(5),
})
}
return dates
},
formatDate(date) {
const year = date.getFullYear()
const monthValue = date.getMonth() + 1
const dayValue = date.getDate()
const month = monthValue < 10 ? `0${monthValue}` : `${monthValue}`
const day = dayValue < 10 ? `0${dayValue}` : `${dayValue}`
return `${year}-${month}-${day}`
},
async loadWorkerTrend() {
const dates = this.buildRecentDates(7)
const result = await api.listReports({
startDate: dates[0].value,
endDate: dates[dates.length - 1].value,
page: 1,
pageSize: 100,
})
const totals = dates.reduce((map, item) => {
map[item.value] = 0
return map
}, {})
;(result.rows || []).forEach(report => {
if (totals[report.reportDate] === undefined) {
return
}
totals[report.reportDate] += Number(report.metrics && report.metrics.totalGood || 0)
})
const points = dates.map(item => ({
date: item.value,
label: item.label,
value: totals[item.value] || 0,
}))
const total = points.reduce((sum, item) => sum + item.value, 0)
const max = points.reduce((largest, item) => Math.max(largest, item.value), 0)
return this.buildTrendView(total, max, points)
},
buildTrendView(total, max, points) {
const maxValue = Math.max(1, max || 0)
const lastIndex = Math.max(1, points.length - 1)
const chartInset = 8
const chartSpan = 100 - chartInset * 2
const plottedPoints = points.map((item, index) => {
const left = chartInset + (index * chartSpan / lastIndex)
const top = 82 - (Number(item.value || 0) / maxValue) * 68
return {
...item,
left: left.toFixed(2),
top: top.toFixed(2),
edgeClass: index === 0 ? 'first' : (index === points.length - 1 ? 'last' : ''),
valueText: String(item.value || 0),
}
})
const segments = []
for (let index = 0; index < plottedPoints.length - 1; index += 1) {
const start = plottedPoints[index]
const end = plottedPoints[index + 1]
const dx = Number(end.left) - Number(start.left)
const dy = Number(end.top) - Number(start.top)
const dxRpx = dx * 5.6
const dyRpx = dy * 2.2
const width = Math.sqrt(dxRpx * dxRpx + dyRpx * dyRpx) / 5.6
const angle = Math.atan2(dyRpx, dxRpx) * 180 / Math.PI
segments.push({
left: start.left,
top: start.top,
width: width.toFixed(2),
angle: angle.toFixed(2),
})
}
return { total, max, points: plottedPoints, segments }
},
buildActions(user) {
if (!user) {
return []
}
if (user.role === 'worker') {
return [
{ key: 'records', icon: '记', title: '我的报工记录', desc: '按日期查看提交和审核状态', url: '/pages/records/records' },
]
}
if (user.role === 'admin') {
return [
{ key: 'products', icon: '产', title: '产品清单', desc: '导入、添加、修改产品', url: '/pages/manageProducts/manageProducts' },
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
{ key: 'review', icon: '审', title: '报工审核', desc: '处理待审核报工', url: '/pages/review/review' },
{ key: 'audit', icon: '核', title: '审核记录', desc: '查看已审核报工', url: '/pages/records/records?mode=audit' },
{ key: 'monitor', icon: '监', title: '智能监控', desc: '查看模具占用和反馈', url: '/pages/smartMonitor/smartMonitor' },
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
{ key: 'moldQr', icon: '码', title: '模具二维码', desc: '生成模具扫码入口', url: '/pages/deviceQr/deviceQr' },
{ key: 'equipment', icon: '设', title: '设备管理', desc: '维护设备号和备注', url: '/pages/manageEquipment/manageEquipment' },
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
]
}
return [
{ key: 'products', icon: '产', title: '产品清单', desc: '导入或添加新产品', url: '/pages/manageProducts/manageProducts' },
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
{ key: 'schedule', icon: '勤', title: '考勤设置', desc: '维护作息时间和考勤范围', url: '/pages/workSchedule/workSchedule' },
{ key: 'ledger', icon: '账', title: '对账小账本', desc: '按月核对最后工序成品数量', url: '/pages/reconciliationLedger/reconciliationLedger' },
{ key: 'dashboard', icon: '看', title: '报工看板', desc: '按日期查看工人日报', url: '/pages/dashboard/dashboard' },
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
]
},
decodeRepeated(value) {
let text = String(value || '').trim()
for (let index = 0; index < 3; index += 1) {
try {
const decoded = decodeURIComponent(text)
if (decoded === text) {
break
}
text = decoded
} catch (error) {
break
}
}
return text
},
extractMoldName(scanResult) {
const raw = this.decodeRepeated(scanResult)
if (!raw) {
return ''
}
const patterns = [
/(?:^|[?&])moldName=([^&]+)/i,
/(?:^|[?&])mold_name=([^&]+)/i,
/(?:^|[?&])deviceNo=([^&]+)/i,
/(?:^|[?&])device_no=([^&]+)/i,
/(?:^|[?&])scene=([^&]+)/i,
/moldName=([^&]+)/i,
/deviceNo=([^&]+)/i,
]
for (let index = 0; index < patterns.length; index += 1) {
const matched = raw.match(patterns[index])
if (matched && matched[1]) {
const value = this.decodeRepeated(matched[1])
if (value.includes('moldName=') || value.includes('deviceNo=')) {
return this.extractMoldName(value)
}
return String(value || '').trim()
}
}
return raw
},
async scanDevice() {
wx.showLoading({ title: '定位中' })
try {
await locationGuard.ensureFactoryLocation()
} catch (error) {
wx.hideLoading()
wx.showModal({
title: '无法扫码报工',
content: error.message || '当前位置不在允许范围内',
showCancel: false,
})
return
}
wx.hideLoading()
wx.scanCode({
onlyFromCamera: false,
success: async res => {
const candidates = [res.path, res.result, res.rawData]
const moldName = candidates.reduce((found, item) => (
found || this.extractMoldName(item)
), '')
if (!moldName) {
wx.showToast({ title: '未识别模具名称', icon: 'none' })
return
}
const paramName = moldName.startsWith('mold=') ? 'scene' : 'moldName'
if (paramName === 'scene') {
try {
wx.showLoading({ title: '校验中' })
const resolved = await api.resolveMoldScene(moldName)
await locationGuard.ensureFactoryLocation(resolved.attendancePointName)
} catch (error) {
wx.showModal({
title: '无法扫码报工',
content: error.message || '当前位置不在该考勤点范围内',
showCancel: false,
})
return
} finally {
wx.hideLoading()
}
}
wx.navigateTo({
url: `/pages/clock/clock?${paramName}=${encodeURIComponent(moldName)}`,
})
},
fail: error => {
if (error.errMsg && error.errMsg.includes('cancel')) {
return
}
wx.showToast({ title: '扫码失败', icon: 'none' })
},
})
},
async wechatLogin(e) {
const phoneCode = e.detail && e.detail.code
if (!phoneCode) {
wx.showToast({ title: '未获得手机号授权', icon: 'none' })
return
}
wx.showLoading({ title: '登录中' })
try {
const result = await api.loginWithPhoneCode(phoneCode)
if (result.needsTemporaryWorker) {
wx.hideLoading()
await this.confirmTemporaryWorker(result)
return
}
if (result.needsSelection) {
this.setData({
roleChoices: result.matchedPeople,
selectionToken: result.selectionToken,
})
return
}
await this.load({ refreshLocation: true })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
confirmTemporaryWorker(result) {
return new Promise(resolve => {
this.temporaryWorkerResult = result
this.temporaryWorkerResolve = resolve
this.setData({
temporaryWorkerModalVisible: true,
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
temporaryWorkerName: '',
})
})
},
onTemporaryWorkerNameInput(e) {
this.setData({ temporaryWorkerName: e.detail.value })
},
closeTemporaryWorkerModal() {
this.setData({
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
})
this.temporaryWorkerResult = null
if (this.temporaryWorkerResolve) {
this.temporaryWorkerResolve()
this.temporaryWorkerResolve = null
}
},
async submitTemporaryWorker() {
const result = this.temporaryWorkerResult
const name = String(this.data.temporaryWorkerName || '').trim()
if (!name) {
wx.showToast({ title: '请输入姓名', icon: 'none' })
return
}
if (!result || !result.temporaryToken) {
wx.showToast({ title: '临时工注册信息已失效,请重新登录', icon: 'none' })
this.closeTemporaryWorkerModal()
return
}
this.setData({ temporaryWorkerModalVisible: false })
wx.showLoading({ title: '定位中' })
try {
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
wx.showLoading({ title: '创建中' })
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
await this.load({ refreshLocation: true })
wx.showToast({ title: '临时工已生效', icon: 'success' })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
this.closeTemporaryWorkerModal()
}
},
logout() {
api.logout()
this.setData({ locationInitialized: false })
this.load()
},
chooseAvatar() {
const user = this.data.currentUser
if (!user) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const save = tempFilePath => {
const saveToStorage = path => {
wx.setStorageSync(avatarKey(user.phone), path)
this.setData({ avatarPath: path })
}
if (wx.saveFile) {
wx.saveFile({
tempFilePath,
success: res => saveToStorage(res.savedFilePath),
fail: () => saveToStorage(tempFilePath),
})
return
}
saveToStorage(tempFilePath)
}
if (wx.chooseMedia) {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: res => {
const file = res.tempFiles && res.tempFiles[0]
if (file && file.tempFilePath) {
save(file.tempFilePath)
}
},
})
return
}
wx.chooseImage({
count: 1,
sourceType: ['album', 'camera'],
success: res => {
const path = res.tempFilePaths && res.tempFilePaths[0]
if (path) {
save(path)
}
},
})
},
switchCurrentRole() {
const user = this.data.currentUser
const choices = (user && user.rolesDisplay ? user.rolesDisplay : [])
.filter(item => item.role !== user.role)
if (!choices.length) {
wx.showToast({ title: '没有可切换的角色', icon: 'none' })
return
}
wx.showActionSheet({
itemList: choices.map(item => item.roleName),
success: async res => {
const target = choices[res.tapIndex]
if (!target) {
return
}
wx.showLoading({ title: '切换中' })
try {
await api.switchRole(target.role)
await this.load({ refreshLocation: true })
wx.showToast({ title: '已切换角色', icon: 'success' })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
},
async chooseRole(e) {
const role = e.currentTarget.dataset.role
wx.showLoading({ title: '登录中' })
try {
await api.selectRole(this.data.selectionToken, role)
this.setData({ roleChoices: [], selectionToken: '' })
await this.load({ refreshLocation: true })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
openAction(e) {
wx.navigateTo({
url: e.currentTarget.dataset.url,
})
},
openOverview(e) {
const url = e.currentTarget.dataset.url
const disabledText = e.currentTarget.dataset.disabledText
if (!url) {
wx.showToast({ title: disabledText || '当前角色无权限', icon: 'none' })
return
}
wx.navigateTo({ url })
},
noop() {},
resetDemo() {
wx.showModal({
title: '退出登录',
content: '退出当前账号后需要重新进行手机号快捷登录。',
success: res => {
if (res.confirm) {
api.logout()
this.setData({ roleChoices: [], selectionToken: '', locationInitialized: false })
this.load()
}
},
})
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {
}
}
+268
View File
@@ -0,0 +1,268 @@
<view class="index-shell">
<scroll-view
class="page index-page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<view class="header-row">
<view class="brand-block">
<text class="title metallic-title">嘉恒智能报工</text>
<text class="subtitle">宁波嘉恒智能科技有限公司</text>
<view wx:if="{{currentUser}}" class="location-status">
<text>当前位置:</text>
<block wx:if="{{locationEntered}}">
<text>已进入 </text>
<text class="location-point-name">{{currentAttendancePointName}}</text>
<text> 考勤点</text>
</block>
<text wx:else class="location-not-entered">未进入考勤点</text>
</view>
<text wx:if="{{currentUser}}" class="location-refresh-tip">下拉刷新位置</text>
</view>
<view wx:if="{{currentUser}}" class="header-user" bindtap="switchCurrentRole">
<view class="avatar small-avatar">
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
<text wx:else class="avatar-text">{{avatarText}}</text>
</view>
<view class="header-user-text">
<text class="header-name">{{currentUser.name}}</text>
<view class="role-line">
<text>{{currentRoleName}}</text>
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
</view>
</view>
</view>
</view>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'home'}}">
<button class="scan-btn" bindtap="scanDevice">扫一扫</button>
<view wx:if="{{currentUser}}" class="notice-card">
<view class="notice-hero">
<view class="notice-badge">通知</view>
<swiper wx:if="{{notices.length}}" class="notice-swiper" autoplay circular vertical interval="3200" duration="420">
<swiper-item wx:for="{{notices}}" wx:key="id">
<view class="notice-slide">
<text class="notice-title">{{item.title}}</text>
<text class="notice-content">{{item.content}}</text>
</view>
</swiper-item>
</swiper>
<view wx:else class="notice-empty">
<text class="notice-title">暂无通知</text>
<text class="notice-content">今天暂无新的通知。</text>
</view>
</view>
<view wx:if="{{noticeList.length}}" class="notice-list">
<view wx:for="{{noticeList}}" wx:key="id" class="notice-list-row">
<text class="notice-dot"></text>
<text class="notice-list-title">{{item.title}}</text>
</view>
</view>
</view>
<view wx:if="{{!currentUser}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<block wx:if="{{showOverview}}">
<view class="section-title">概览</view>
<view class="grid">
<view
wx:for="{{overviewItems}}"
wx:key="key"
class="stat tappable-stat stat-{{item.key}} {{item.alert ? 'stat-alert' : ''}}"
data-url="{{item.url}}"
data-disabled-text="{{item.disabledText}}"
bindtap="openOverview"
>
<text class="label">{{item.label}}</text>
<text class="stat-number">{{item.value || 0}}</text>
<text class="stat-link">查看清单</text>
</view>
</view>
</block>
<block wx:if="{{showWorkerTrend}}">
<view class="section-title">最近7天成品数量</view>
<view class="card trend-card">
<view class="row trend-head">
<view>
<text class="label">7天合计</text>
<text class="value">{{workerTrend.total || 0}}</text>
</view>
<view class="text-right">
<text class="label">单日最高</text>
<text class="value">{{workerTrend.max || 0}}</text>
</view>
</view>
<view class="trend-plot">
<view class="trend-axis trend-axis-top">{{workerTrend.max || 0}}</view>
<view class="trend-axis trend-axis-bottom">0</view>
<view class="trend-plot-body">
<view class="trend-grid-line top"></view>
<view class="trend-grid-line middle"></view>
<view class="trend-grid-line bottom"></view>
<view
wx:for="{{workerTrend.segments}}"
wx:key="index"
class="trend-segment"
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.width}}%; transform: rotate({{item.angle}}deg);"
></view>
<view
wx:for="{{workerTrend.points}}"
wx:key="date"
class="trend-point-wrap {{item.edgeClass}}"
style="left: {{item.left}}%; top: {{item.top}}%;"
>
<text wx:if="{{item.value}}" class="trend-point-value">{{item.valueText}}</text>
<text class="trend-point"></text>
</view>
</view>
<view class="trend-label-row">
<text
wx:for="{{workerTrend.points}}"
wx:key="date"
class="trend-date-label {{item.edgeClass}}"
style="left: {{item.left}}%;"
>{{item.label}}</text>
</view>
</view>
</view>
</block>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'workbench'}}">
<view class="section-title">工作台</view>
<view wx:if="{{!actions.length}}" class="list-empty">请选择账号</view>
<view wx:else class="workbench-panel">
<view class="app-grid">
<view
wx:for="{{actions}}"
wx:key="key"
class="app-item"
data-url="{{item.url}}"
data-action="{{item.action}}"
bindtap="openAction"
>
<view class="app-icon app-icon-{{item.key}}">{{item.icon}}</view>
<text class="app-title">{{item.title}}</text>
<text class="app-desc">{{item.desc}}</text>
</view>
</view>
</view>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'mine'}}">
<view class="section-title">我的</view>
<view wx:if="{{!currentUser}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<view wx:else class="card profile-card">
<view class="profile-head">
<view class="avatar large-avatar">
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
<text wx:else class="avatar-text large-avatar-text">{{avatarText}}</text>
</view>
<view class="profile-main">
<text class="profile-name">{{currentUser.name}}</text>
<text class="subtitle">{{currentUser.phone}}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row" bindtap="switchCurrentRole">
<text class="label inline-label">当前角色</text>
<view class="info-value">
<text>{{currentRoleName}}</text>
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
</view>
</view>
<view class="info-row">
<text class="label inline-label">全部角色</text>
<text class="value">{{currentRolesText}}</text>
</view>
<view wx:if="{{currentUser.role !== 'manager'}}" class="info-row">
<text class="label inline-label">考勤点</text>
<text class="value">{{currentUser.attendancePointsText || '-'}}</text>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="chooseAvatar">更换头像</button>
<button class="btn danger full" bindtap="resetDemo">退出登录</button>
</view>
</view>
</view>
</scroll-view>
<view class="bottom-bar">
<view class="bottom-item {{activeTab === 'home' ? 'active' : ''}}" data-tab="home" bindtap="switchTab">
<text class="bottom-icon">⌂</text>
<text>首页</text>
</view>
<view class="bottom-item {{activeTab === 'workbench' ? 'active' : ''}}" data-tab="workbench" bindtap="switchTab">
<text class="bottom-icon">▦</text>
<text>工作台</text>
</view>
<view class="bottom-item {{activeTab === 'mine' ? 'active' : ''}}" data-tab="mine" bindtap="switchTab">
<text class="bottom-icon">○</text>
<text>我的</text>
</view>
</view>
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
<view class="temp-worker-panel" catchtap="noop">
<text class="temp-worker-title">创建临时工</text>
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
<view class="temp-worker-field">
<text class="temp-worker-label">姓名</text>
<input
class="input temp-worker-input"
value="{{temporaryWorkerName}}"
placeholder="请输入姓名"
bindinput="onTemporaryWorkerNameInput"
confirm-type="done"
cursor-spacing="24"
/>
</view>
<view class="button-row temp-worker-actions">
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
</view>
</view>
</view>
</view>
+806
View File
@@ -0,0 +1,806 @@
.index-shell {
min-height: 100vh;
background: #f2f5f9;
}
.index-page {
padding-bottom: calc(env(safe-area-inset-bottom) + 150rpx);
}
.header-row {
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
padding-top: 8rpx;
}
.index-page .header::after {
left: 18rpx;
right: auto;
top: 128rpx;
width: 150rpx;
height: 46rpx;
opacity: 0.06;
transform: rotate(14deg);
}
.brand-block {
min-width: 0;
flex: 1;
}
.location-status {
display: block;
margin-top: 8rpx;
color: #1f2937;
font-size: 23rpx;
font-weight: 800;
line-height: 1.35;
}
.location-refresh-tip {
display: block;
margin-top: 2rpx;
color: #7a8798;
font-size: 20rpx;
line-height: 1.3;
}
.location-not-entered {
color: #c5221f;
font-weight: 900;
}
.location-point-name {
color: #087443;
font-weight: 900;
}
.metallic-title {
display: inline-block;
color: #4b5563;
font-weight: 900;
background:
linear-gradient(90deg, #38414d 0%, #dfe6ec 7%, #7f8b97 14%, #f8fbfd 22%, #59636f 31%, #eef3f6 41%, #3f4853 52%, #c4cdd6 64%, #ffffff 73%, #626c78 84%, #2f3741 100%),
linear-gradient(180deg, #f9fbfc 0%, #a7b0bb 18%, #ffffff 34%, #5b6470 54%, #d3dae2 74%, #3b4450 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-text-stroke: 1rpx rgba(47, 55, 65, 0.42);
text-shadow:
0 1rpx 0 rgba(255, 255, 255, 0.92),
0 3rpx 0 rgba(87, 96, 108, 0.78),
0 6rpx 0 rgba(38, 45, 54, 0.62),
0 12rpx 16rpx rgba(15, 23, 42, 0.28),
0 20rpx 28rpx rgba(15, 23, 42, 0.16);
}
.header-user {
display: flex;
align-items: center;
gap: 12rpx;
flex: 0 0 auto;
max-width: 300rpx;
padding: 10rpx 12rpx;
border: 1rpx solid #e1e7f0;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 8rpx 0 rgba(205, 216, 229, 0.54),
0 14rpx 22rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.header-user-text {
min-width: 0;
text-align: right;
}
.header-name {
display: block;
color: #1f2933;
font-size: 26rpx;
font-weight: 700;
line-height: 1.25;
}
.role-line {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4rpx;
margin-top: 6rpx;
color: #667085;
font-size: 22rpx;
line-height: 1.2;
}
.role-arrow {
color: #1463ff;
font-size: 24rpx;
line-height: 1;
}
.avatar {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 50%;
background: #e8f1ff;
color: #1456c2;
font-weight: 700;
}
.small-avatar {
width: 66rpx;
height: 66rpx;
font-size: 24rpx;
}
.large-avatar {
width: 112rpx;
height: 112rpx;
font-size: 34rpx;
}
.avatar-image {
width: 100%;
height: 100%;
}
.avatar-text {
display: block;
line-height: 1;
}
.large-avatar-text {
font-size: 34rpx;
}
.scan-btn {
width: 100%;
height: 112rpx;
line-height: 112rpx;
margin: 0 0 28rpx;
padding: 0;
border-radius: 18rpx;
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
color: #ffffff;
font-size: 36rpx;
font-weight: 700;
box-shadow:
0 12rpx 0 #0d3f93,
0 20rpx 30rpx rgba(20, 86, 194, 0.28),
inset 0 2rpx 0 rgba(255, 255, 255, 0.26);
}
.scan-btn:active {
transform: translateY(5rpx);
box-shadow:
0 7rpx 0 #0d3f93,
0 14rpx 22rpx rgba(20, 86, 194, 0.22),
inset 0 2rpx 0 rgba(255, 255, 255, 0.2);
}
.scan-btn::after {
border: 0;
}
.notice-card {
position: relative;
overflow: hidden;
margin-bottom: 28rpx;
border: 1rpx solid #dce5f2;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 14rpx 0 rgba(205, 216, 229, 0.62),
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.notice-card::before,
.workbench-panel::before {
content: "";
position: absolute;
right: 18rpx;
top: 18rpx;
z-index: 1;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
opacity: 0.54;
pointer-events: none;
background:
linear-gradient(90deg, transparent 42%, rgba(47, 55, 65, 0.78) 43%, rgba(47, 55, 65, 0.78) 57%, transparent 58%),
radial-gradient(circle at 32% 28%, #ffffff 0, #dfe6ec 19%, #97a3af 58%, #475467 100%);
box-shadow:
0 3rpx 0 rgba(72, 83, 97, 0.38),
0 6rpx 10rpx rgba(22, 34, 50, 0.14),
inset 0 2rpx 0 rgba(255, 255, 255, 0.85);
transform: rotate(28deg);
}
.notice-hero {
position: relative;
min-height: 178rpx;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #1456c2 0%, #15847c 100%);
box-sizing: border-box;
}
.notice-hero::after {
content: "";
position: absolute;
right: -14rpx;
bottom: 14rpx;
width: 180rpx;
height: 48rpx;
opacity: 0.14;
transform: rotate(-20deg);
pointer-events: none;
background:
radial-gradient(circle at 18% 50%, transparent 0rpx, transparent 12rpx, #ffffff 13rpx, #ffffff 20rpx, transparent 21rpx),
radial-gradient(circle at 86% 50%, #ffffff 0rpx, #ffffff 16rpx, transparent 17rpx),
linear-gradient(90deg, transparent 0rpx, transparent 30rpx, #ffffff 31rpx, #ffffff 142rpx, transparent 143rpx);
}
.notice-badge {
display: inline-flex;
align-items: center;
height: 42rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
font-size: 22rpx;
font-weight: 700;
}
.notice-swiper {
height: 106rpx;
margin-top: 12rpx;
}
.notice-slide,
.notice-empty {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
.notice-title {
display: block;
overflow: hidden;
color: #ffffff;
font-size: 32rpx;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-content {
display: -webkit-box;
overflow: hidden;
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.86);
font-size: 24rpx;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.notice-list {
padding: 16rpx 26rpx 18rpx;
}
.notice-list-row {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 46rpx;
}
.notice-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #12a375;
}
.notice-list-title {
overflow: hidden;
min-width: 0;
color: #344054;
font-size: 24rpx;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.trend-card {
padding-bottom: 14rpx;
border-left: 6rpx solid #12a375;
}
.trend-head {
margin-bottom: 12rpx;
}
.trend-plot {
position: relative;
height: 360rpx;
padding: 18rpx 8rpx 0 58rpx;
box-sizing: border-box;
}
.trend-axis {
position: absolute;
left: 0;
width: 48rpx;
color: #667085;
font-size: 20rpx;
line-height: 1;
text-align: right;
}
.trend-axis-top {
top: 24rpx;
}
.trend-axis-bottom {
bottom: 56rpx;
}
.trend-plot-body {
position: relative;
height: 244rpx;
margin-right: 10rpx;
}
.trend-grid-line {
position: absolute;
left: 0;
right: 0;
height: 1rpx;
background: #e6eaf0;
}
.trend-grid-line.top {
top: 14%;
}
.trend-grid-line.middle {
top: 48%;
}
.trend-grid-line.bottom {
top: 82%;
}
.trend-segment {
position: absolute;
z-index: 2;
height: 6rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #6aa1ff 0%, #1463ff 100%);
box-shadow: 0 5rpx 10rpx rgba(20, 99, 255, 0.22);
transform-origin: 0 50%;
}
.trend-point-wrap {
position: absolute;
z-index: 3;
width: 1rpx;
height: 1rpx;
}
.trend-point {
position: absolute;
left: -11rpx;
top: -11rpx;
width: 22rpx;
height: 22rpx;
border: 5rpx solid #1463ff;
border-radius: 50%;
background: #ffffff;
box-shadow:
0 7rpx 12rpx rgba(20, 99, 255, 0.2),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
box-sizing: border-box;
}
.trend-point-value {
position: absolute;
left: -44rpx;
bottom: 18rpx;
width: 88rpx;
color: #1f2933;
font-size: 21rpx;
font-weight: 700;
line-height: 1;
text-align: center;
}
.trend-label-row {
position: relative;
height: 40rpx;
margin-top: 24rpx;
margin-right: 10rpx;
}
.trend-date-label {
position: absolute;
top: 0;
width: 78rpx;
color: #667085;
font-size: 20rpx;
line-height: 1;
text-align: center;
transform: translateX(-50%);
}
.tappable-stat {
position: relative;
transition: background 0.15s ease;
}
.tappable-stat:active {
background: #f3f6fb;
}
.stat-products {
border-left-color: #1463ff;
}
.stat-people {
border-left-color: #12a375;
}
.stat-pending {
border-left-color: #d9822b;
}
.stat-alert {
border-color: #ffb4a8;
border-left-color: #d92d20;
background:
linear-gradient(135deg, rgba(217, 45, 32, 0.13), rgba(255, 255, 255, 0.92) 48%),
#fff7f5;
box-shadow:
0 14rpx 0 rgba(244, 179, 169, 0.7),
0 22rpx 34rpx rgba(180, 35, 24, 0.14),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.stat-alert .stat-number,
.stat-alert .stat-link {
color: #b42318;
}
.stat-approved {
border-left-color: #7c3aed;
}
.stat-link {
display: block;
margin-top: 12rpx;
color: #6b778c;
font-size: 22rpx;
font-weight: 600;
}
.profile-card {
padding-bottom: 28rpx;
border-left: 6rpx solid #12a375;
}
.profile-head {
display: flex;
align-items: center;
gap: 20rpx;
}
.profile-main {
min-width: 0;
flex: 1;
}
.profile-name {
display: block;
color: #1f2933;
font-size: 36rpx;
font-weight: 700;
line-height: 1.3;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72rpx;
gap: 20rpx;
}
.inline-label {
margin-bottom: 0;
}
.info-value {
display: flex;
align-items: center;
gap: 6rpx;
color: #1f2933;
font-weight: 600;
}
.role-list {
margin-top: 22rpx;
}
.role-btn {
margin-top: 12rpx;
height: 72rpx;
line-height: 72rpx;
border-radius: 14rpx;
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
color: #1463ff;
font-size: 26rpx;
box-shadow:
0 7rpx 0 rgba(144, 178, 235, 0.62),
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
}
.role-btn::after {
border: 0;
}
.workbench-panel {
position: relative;
overflow: hidden;
padding: 24rpx 18rpx 10rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 14rpx 0 rgba(205, 216, 229, 0.62),
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.app-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18rpx 12rpx;
}
.app-item {
display: flex;
flex-direction: column;
align-items: center;
min-height: 178rpx;
padding: 16rpx 8rpx;
border-radius: 16rpx;
box-sizing: border-box;
}
.app-item:active {
background: #f3f6fb;
}
.app-item:active .app-icon {
transform: translateY(4rpx);
box-shadow:
0 5rpx 0 rgba(74, 91, 115, 0.42),
0 8rpx 14rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.56),
inset 0 -8rpx 12rpx rgba(15, 23, 42, 0.12);
}
.app-icon {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
width: 78rpx;
height: 78rpx;
border-radius: 22rpx;
border: 1rpx solid rgba(255, 255, 255, 0.62);
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
color: #1456c2;
font-size: 34rpx;
font-weight: 800;
line-height: 1;
text-shadow:
0 2rpx 0 rgba(255, 255, 255, 0.82),
0 4rpx 8rpx rgba(15, 23, 42, 0.18);
box-shadow:
0 10rpx 0 rgba(77, 103, 145, 0.42),
0 16rpx 22rpx rgba(22, 34, 50, 0.16),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(15, 23, 42, 0.14);
}
.app-icon::before,
.app-icon::after {
content: "";
position: absolute;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
pointer-events: none;
background:
linear-gradient(90deg, transparent 40%, rgba(47, 55, 65, 0.72) 41%, rgba(47, 55, 65, 0.72) 59%, transparent 60%),
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.96) 0, rgba(226, 232, 240, 0.96) 20%, rgba(132, 146, 164, 0.88) 62%, rgba(59, 70, 84, 0.86) 100%);
box-shadow:
0 2rpx 0 rgba(49, 61, 77, 0.36),
inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
}
.app-icon::before {
left: 10rpx;
top: 10rpx;
transform: rotate(24deg);
}
.app-icon::after {
right: 10rpx;
bottom: 10rpx;
transform: rotate(-28deg);
}
.app-icon-products,
.app-icon-device,
.app-icon-moldQr,
.app-icon-equipment {
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
color: #1456c2;
}
.app-icon-review,
.app-icon-audit {
background: linear-gradient(160deg, #fffdf7 0%, #ffe2aa 34%, #f0a23a 68%, #a95605 100%);
color: #a95605;
box-shadow:
0 10rpx 0 rgba(143, 84, 16, 0.38),
0 16rpx 22rpx rgba(104, 62, 17, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(94, 47, 0, 0.14);
}
.app-icon-people,
.app-icon-dashboard,
.app-icon-schedule {
background: linear-gradient(160deg, #ffffff 0%, #c9f2df 32%, #5fd19d 64%, #087443 100%);
color: #087443;
box-shadow:
0 10rpx 0 rgba(24, 117, 77, 0.38),
0 16rpx 22rpx rgba(20, 92, 63, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(0, 81, 49, 0.14);
}
.app-icon-records,
.app-icon-notices {
background: linear-gradient(160deg, #ffffff 0%, #e6d8ff 32%, #b68dff 64%, #6d35c7 100%);
color: #6d35c7;
box-shadow:
0 10rpx 0 rgba(98, 67, 151, 0.38),
0 16rpx 22rpx rgba(81, 54, 125, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(65, 29, 122, 0.14);
}
.app-title {
display: block;
margin-top: 12rpx;
color: #162232;
font-size: 25rpx;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.app-desc {
display: -webkit-box;
overflow: hidden;
margin-top: 6rpx;
color: #7a8699;
font-size: 20rpx;
line-height: 1.35;
text-align: center;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 10rpx 22rpx calc(env(safe-area-inset-bottom) + 10rpx);
border-top: 1rpx solid #e4e9f1;
background: linear-gradient(180deg, #ffffff 0%, #f6f9fd 100%);
box-shadow:
0 -8rpx 0 rgba(205, 216, 229, 0.42),
0 -14rpx 28rpx rgba(22, 34, 50, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.bottom-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 94rpx;
color: #8a94a6;
font-size: 22rpx;
font-weight: 600;
border-radius: 18rpx;
}
.bottom-item.active {
background: linear-gradient(180deg, #eef5ff 0%, #dceaff 100%);
color: #1463ff;
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.95),
0 7rpx 0 rgba(144, 178, 235, 0.46),
0 12rpx 20rpx rgba(20, 86, 194, 0.14);
}
.bottom-item.active text:not(.bottom-icon) {
color: #1463ff;
font-size: 24rpx;
font-weight: 800;
}
.bottom-item text:not(.bottom-icon) {
color: #7a8699;
}
.bottom-icon {
display: flex;
align-items: center;
justify-content: center;
width: 46rpx;
height: 46rpx;
margin-bottom: 6rpx;
border: 1rpx solid #c8d2df;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #e7edf5 100%);
color: #8b96a8;
font-size: 28rpx;
font-weight: 800;
line-height: 1;
box-shadow:
0 5rpx 0 rgba(157, 170, 188, 0.4),
0 8rpx 12rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.88);
}
.bottom-item.active .bottom-icon {
width: 58rpx;
height: 58rpx;
margin-bottom: 4rpx;
border-color: rgba(255, 255, 255, 0.72);
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #72a9ff 58%, #1456c2 100%);
color: #ffffff;
font-size: 34rpx;
text-shadow: 0 2rpx 4rpx rgba(15, 23, 42, 0.28);
box-shadow:
0 8rpx 0 #0d3f93,
0 14rpx 20rpx rgba(20, 86, 194, 0.24),
inset 0 3rpx 0 rgba(255, 255, 255, 0.78),
inset 0 -8rpx 14rpx rgba(15, 23, 42, 0.16);
}
+18
View File
@@ -0,0 +1,18 @@
// logs.js
const util = require('../../utils/util.js')
Page({
data: {
logs: []
},
onLoad() {
this.setData({
logs: (wx.getStorageSync('logs') || []).map(log => {
return {
date: util.formatTime(new Date(log)),
timeStamp: log
}
})
})
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {
}
}
+6
View File
@@ -0,0 +1,6 @@
<!--logs.wxml-->
<scroll-view class="scrollarea" scroll-y type="list">
<block wx:for="{{logs}}" wx:key="timeStamp" wx:for-item="log">
<view class="log-item">{{index + 1}}. {{log.date}}</view>
</block>
</scroll-view>
+16
View File
@@ -0,0 +1,16 @@
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.log-item {
margin-top: 20rpx;
text-align: center;
}
.log-item:last-child {
padding-bottom: env(safe-area-inset-bottom);
}
+216
View File
@@ -0,0 +1,216 @@
const api = require('../../utils/api')
const emptyForm = {
attendancePointName: '',
deviceNo: '',
deviceType: '冲压设备',
remark: '',
}
const deviceTypeOptions = ['冲压设备', '清洗设备']
Page({
data: {
keyword: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
form: { ...emptyForm },
formVisible: false,
formTitle: '新增设备',
attendancePoints: [],
attendancePointLabels: [],
attendancePointIndex: 0,
filterAttendancePointLabels: ['全部考勤点'],
filterAttendancePointIndex: 0,
filterAttendancePointName: '',
deviceTypeOptions,
refreshing: false,
},
onShow() {
this.init()
},
async init() {
await this.loadAttendancePoints()
await this.load()
},
async loadAttendancePoints() {
try {
const points = await api.listAccessibleAttendancePoints()
const attendancePoints = points || []
this.setData({
attendancePoints,
attendancePointLabels: attendancePoints.map(item => item.name),
filterAttendancePointLabels: ['全部考勤点'].concat(attendancePoints.map(item => item.name)),
})
if (!this.data.form.attendancePointName && attendancePoints.length) {
this.setData({
'form.attendancePointName': attendancePoints[0].name,
attendancePointIndex: 0,
})
}
} catch (error) {
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
}
},
async load(page = this.data.page) {
try {
const result = await api.listEquipment({
keyword: this.data.keyword,
attendancePointName: this.data.filterAttendancePointName,
page,
pageSize: 8,
})
this.setData({
rows: result.rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
})
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onKeywordInput(e) {
this.setData({ keyword: e.detail.value, page: 1 })
this.load(1)
},
onFilterAttendancePointChange(e) {
const filterAttendancePointIndex = Number(e.detail.value)
const point = this.data.attendancePoints[filterAttendancePointIndex - 1]
this.setData({
filterAttendancePointIndex,
filterAttendancePointName: point ? point.name : '',
page: 1,
})
this.load(1)
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({
[`form.${field}`]: e.detail.value,
})
},
onDeviceTypeChange(e) {
const index = Number(e.detail.value)
this.setData({
'form.deviceType': deviceTypeOptions[index] || '冲压设备',
})
},
onAttendancePointChange(e) {
const attendancePointIndex = Number(e.detail.value)
const point = this.data.attendancePoints[attendancePointIndex]
this.setData({
attendancePointIndex,
'form.attendancePointName': point ? point.name : '',
})
},
noop() {},
openCreate() {
const firstPoint = this.data.attendancePoints[0]
this.setData({
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
attendancePointIndex: 0,
formTitle: '新增设备',
formVisible: true,
})
},
edit(e) {
const index = e.currentTarget.dataset.index
const equipment = this.data.rows[index]
const attendancePointIndex = Math.max(0, this.data.attendancePoints.findIndex(item => item.name === equipment.attendancePointName))
this.setData({
form: {
...equipment,
originalAttendancePointName: equipment.attendancePointName,
originalDeviceNo: equipment.deviceNo,
},
attendancePointIndex,
formTitle: '编辑设备',
formVisible: true,
})
},
closeForm() {
this.setData({
formVisible: false,
form: { ...emptyForm, attendancePointName: this.data.attendancePoints[0] ? this.data.attendancePoints[0].name : '' },
})
},
clearForm() {
const firstPoint = this.data.attendancePoints[0]
this.setData({
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
attendancePointIndex: 0,
})
},
async save() {
try {
if (!this.data.form.attendancePointName) {
wx.showToast({ title: '请选择考勤点', icon: 'none' })
return
}
await api.saveEquipment(this.data.form)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ form: { ...emptyForm }, formVisible: false })
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
}
},
remove(e) {
const index = e.currentTarget.dataset.index
const equipment = this.data.rows[index]
wx.showModal({
title: '删除设备',
content: equipment.deviceNo,
confirmText: '删除',
confirmColor: '#d92d20',
success: async res => {
if (!res.confirm) {
return
}
try {
await api.deleteEquipment(equipment)
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
}
},
})
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "设备管理"
}
@@ -0,0 +1,93 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">设备管理</text>
<text class="subtitle">维护员工报工可选择的设备号和设备类型</text>
</view>
<view class="card">
<button class="btn primary full add-btn" bindtap="openCreate">新增设备</button>
</view>
<view class="equipment-filter-card">
<input class="input search" placeholder="搜索考勤点、设备号、类型、备注" value="{{keyword}}" bindinput="onKeywordInput" />
<picker
range="{{filterAttendancePointLabels}}"
value="{{filterAttendancePointIndex}}"
bindchange="onFilterAttendancePointChange"
>
<view class="picker filter-picker">{{filterAttendancePointLabels[filterAttendancePointIndex] || '全部考勤点'}}</view>
</picker>
</view>
<view wx:if="{{!rows.length}}" class="list-empty">暂无设备</view>
<view wx:for="{{rows}}" wx:key="uniqueKey" wx:for-index="index" class="card equipment-card">
<view class="row">
<view class="row-left">
<text class="value">{{item.deviceNo}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.remark || '暂无备注'}}</text>
</view>
<text class="pill primary">{{item.deviceType || '冲压设备'}}</text>
</view>
<view class="button-row">
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
<view class="modal-panel" catchtap="noop">
<view class="modal-head">
<text class="modal-title">{{formTitle}}</text>
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
</view>
<view class="form-item">
<text class="label">考勤点</text>
<picker
wx:if="{{attendancePoints.length}}"
range="{{attendancePointLabels}}"
value="{{attendancePointIndex}}"
bindchange="onAttendancePointChange"
>
<view class="picker">{{form.attendancePointName || '请选择考勤点'}}</view>
</picker>
<view wx:else class="readonly">暂无可选考勤点</view>
</view>
<view class="form-item">
<text class="label">设备号</text>
<input class="input" value="{{form.deviceNo}}" data-field="deviceNo" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">设备类型</text>
<picker range="{{deviceTypeOptions}}" value="{{form.deviceType === '清洗设备' ? 1 : 0}}" bindchange="onDeviceTypeChange">
<view class="picker">{{form.deviceType || '冲压设备'}}</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea class="textarea" value="{{form.remark}}" data-field="remark" bindinput="onInput"></textarea>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="closeForm">关闭</button>
<button class="btn primary full" bindtap="save">保存设备</button>
</view>
</view>
</view>
@@ -0,0 +1,72 @@
.search {
margin-bottom: 0;
}
.equipment-filter-card {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 20rpx;
}
.filter-picker {
min-height: 82rpx;
line-height: 82rpx;
}
.add-btn {
margin-top: 0;
}
.equipment-card {
border-left: 6rpx solid #1463ff;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
max-height: 82vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 24rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
+195
View File
@@ -0,0 +1,195 @@
const api = require('../../utils/api')
const emptyForm = {
id: '',
title: '',
content: '',
attendancePointNames: [],
sortOrder: 0,
isActive: true,
}
Page({
data: {
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
form: { ...emptyForm },
formVisible: false,
formTitle: '新增通知',
attendancePoints: [],
refreshing: false,
},
onShow() {
this.init()
},
async init() {
await this.loadAttendancePoints()
await this.load()
},
async loadAttendancePoints() {
try {
const attendancePoints = await api.listAccessibleAttendancePoints()
this.setData({ attendancePoints: attendancePoints || [] })
} catch (error) {
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
}
},
async load(page = this.data.page) {
try {
const result = await api.listNotices({
includeInactive: true,
page,
pageSize: 8,
})
this.setData({
rows: result.rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
noop() {},
checkedPointMap(names = []) {
const selected = new Set(names || [])
return this.data.attendancePoints.map(point => ({
...point,
checked: selected.has(point.name),
}))
},
defaultPointNames() {
return this.data.attendancePoints.map(point => point.name)
},
openCreate() {
const pointNames = this.defaultPointNames()
this.setData({
form: { ...emptyForm, attendancePointNames: pointNames },
attendancePoints: this.checkedPointMap(pointNames),
formTitle: '新增通知',
formVisible: true,
})
},
edit(e) {
const index = e.currentTarget.dataset.index
const notice = this.data.rows[index]
this.setData({
form: { ...notice },
attendancePoints: this.checkedPointMap(notice.attendancePointNames || []),
formTitle: '编辑通知',
formVisible: true,
})
},
closeForm() {
this.setData({
formVisible: false,
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
})
},
clearForm() {
this.setData({
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
})
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({
[`form.${field}`]: e.detail.value,
})
},
onActiveChange(e) {
this.setData({
'form.isActive': e.detail.value,
})
},
onPointCheckboxChange(e) {
const names = e.detail.value || []
this.setData({
'form.attendancePointNames': names,
attendancePoints: this.checkedPointMap(names),
})
},
async save() {
const form = {
...this.data.form,
sortOrder: Number(this.data.form.sortOrder || 0),
}
if (!(form.attendancePointNames || []).length) {
wx.showToast({ title: '请选择通知考勤点', icon: 'none' })
return
}
wx.showLoading({ title: '保存中' })
try {
await api.saveNotice(form)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
formVisible: false,
})
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
remove(e) {
const index = e.currentTarget.dataset.index
const notice = this.data.rows[index]
wx.showModal({
title: '删除通知',
content: notice.title,
success: async res => {
if (!res.confirm) {
return
}
try {
await api.deleteNotice(notice.id)
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
})
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "通知配置"
}
+91
View File
@@ -0,0 +1,91 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">通知配置</text>
<text class="subtitle">首页通知内容维护</text>
</view>
<view class="card action-panel">
<button class="btn primary full add-btn" bindtap="openCreate">新增通知</button>
</view>
<view wx:if="{{!rows.length}}" class="list-empty">暂无通知</view>
<view wx:for="{{rows}}" wx:key="id" wx:for-index="index" class="card notice-row">
<view class="row">
<view class="row-left">
<text class="value">{{item.title}}</text>
<text class="subtitle">考勤点 {{item.attendancePointsText || '-'}}</text>
<text class="subtitle">排序 {{item.sortOrder}} · {{item.updatedAtText}}</text>
</view>
<text class="pill {{item.isActive ? 'success' : 'warn'}}">{{item.isActive ? '展示中' : '已停用'}}</text>
</view>
<text class="notice-content">{{item.content}}</text>
<view class="button-row">
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
<view class="modal-panel" catchtap="noop">
<view class="modal-head">
<text class="modal-title">{{formTitle}}</text>
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
</view>
<view class="form-item">
<text class="label">标题</text>
<input class="input" value="{{form.title}}" data-field="title" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">内容</text>
<textarea class="textarea notice-textarea" value="{{form.content}}" data-field="content" bindinput="onInput"></textarea>
</view>
<view class="form-item">
<text class="label">通知考勤点</text>
<checkbox-group class="checkbox-grid" bindchange="onPointCheckboxChange">
<label
wx:for="{{attendancePoints}}"
wx:key="name"
class="checkbox-chip {{item.checked ? 'checked' : ''}}"
>
<checkbox value="{{item.name}}" checked="{{item.checked}}" color="#1463ff"></checkbox>
<text>{{item.name}}</text>
</label>
</checkbox-group>
</view>
<view class="form-grid">
<view class="form-item">
<text class="label">排序</text>
<input class="input number-input" type="number" value="{{form.sortOrder}}" data-field="sortOrder" bindinput="onInput" />
</view>
<view class="form-item status-form-item">
<text class="label">展示状态</text>
<view class="switch-row">
<text class="value">{{form.isActive ? '展示中' : '停用'}}</text>
<switch checked="{{form.isActive}}" bindchange="onActiveChange" color="#1463ff"></switch>
</view>
</view>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="closeForm">关闭</button>
<button class="btn primary full" bindtap="save">保存通知</button>
</view>
</view>
</view>
+131
View File
@@ -0,0 +1,131 @@
.action-panel {
padding: 22rpx;
}
.notice-row {
border-left: 6rpx solid #1463ff;
}
.notice-content {
display: -webkit-box;
overflow: hidden;
margin-top: 18rpx;
color: #344054;
font-size: 26rpx;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.notice-textarea {
min-height: 180rpx;
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 78rpx;
padding: 0 6rpx 0 20rpx;
border: 1rpx solid #ccd8e6;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
box-shadow:
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
0 6rpx 0 rgba(205, 216, 229, 0.48),
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
box-sizing: border-box;
}
.status-form-item {
margin-bottom: 0;
}
.add-btn {
margin-top: 0;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
max-height: 82vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 24rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
.checkbox-grid {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.checkbox-chip {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 72rpx;
padding: 0 18rpx;
border: 2rpx solid #cbd6e6;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);
color: #162232;
font-size: 25rpx;
font-weight: 700;
box-shadow:
0 6rpx 0 rgba(166, 181, 203, 0.42),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
box-sizing: border-box;
}
.checkbox-chip.checked {
border-color: #7aa7f5;
background: linear-gradient(180deg, #eef5ff 0%, #ddeaff 100%);
color: #1456c2;
}
+342
View File
@@ -0,0 +1,342 @@
const api = require('../../utils/api')
const emptyForm = {
phone: '',
name: '',
role: 'worker',
attendancePointNames: [],
}
const allRoleOptions = [
{ label: '冲压工人', value: 'worker' },
{ label: '管理员', value: 'admin' },
{ label: '经理', value: 'manager' },
]
const workerRoleOptions = [
{ label: '冲压工人', value: 'worker' },
]
Page({
data: {
readonly: false,
keyword: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
form: { ...emptyForm },
formVisible: false,
formTitle: '新增人员',
importFileName: '',
refreshing: false,
currentUser: null,
attendancePoints: [],
roleOptions: allRoleOptions,
roleIndex: 0,
},
onLoad(options) {
this.setData({
readonly: options.readonly === '1',
})
},
onShow() {
this.init()
},
async init() {
const currentUser = api.getCurrentUser()
const roleOptions = this.roleOptionsForUser(currentUser)
this.setData({ currentUser, roleOptions })
await this.loadAttendancePoints()
await this.load()
},
roleOptionsForUser(user = this.data.currentUser) {
return user && user.role === 'admin' ? workerRoleOptions : allRoleOptions
},
async loadAttendancePoints() {
try {
const attendancePoints = await api.listAccessibleAttendancePoints()
this.setData({ attendancePoints: attendancePoints || [] })
} catch (error) {
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
}
},
async load(page = this.data.page) {
try {
const result = await api.listPeople({
keyword: this.data.keyword,
page,
pageSize: 8,
})
this.setData({
rows: result.rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onKeywordInput(e) {
this.setData({ keyword: e.detail.value, page: 1 })
this.load(1)
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({
[`form.${field}`]: e.detail.value,
})
},
onRoleChange(e) {
const roleIndex = Number(e.detail.value)
const role = this.data.roleOptions[roleIndex].value
const updates = {
roleIndex,
'form.role': role,
}
if (role === 'manager') {
if (!this.data.form.originalPhone) {
updates['form.attendancePointNames'] = []
updates.attendancePoints = this.checkedPointMap([])
}
} else if (!(this.data.form.attendancePointNames || []).length && this.data.attendancePoints[0]) {
const defaultPointNames = [this.data.attendancePoints[0].name]
updates['form.attendancePointNames'] = defaultPointNames
updates.attendancePoints = this.checkedPointMap(defaultPointNames)
}
this.setData({
...updates,
})
},
onPointCheckboxChange(e) {
const names = e.detail.value || []
this.setData({
'form.attendancePointNames': names,
attendancePoints: this.checkedPointMap(names),
})
},
checkedPointMap(names = []) {
const selected = new Set(names || [])
return this.data.attendancePoints.map(point => ({
...point,
checked: selected.has(point.name),
}))
},
noop() {},
openCreate() {
const defaultPointNames = this.data.attendancePoints[0] ? [this.data.attendancePoints[0].name] : []
this.setData({
form: { ...emptyForm, attendancePointNames: defaultPointNames },
attendancePoints: this.checkedPointMap(defaultPointNames),
roleIndex: 0,
formTitle: '新增人员',
formVisible: true,
})
},
closeForm() {
this.setData({
formVisible: false,
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
roleOptions: this.roleOptionsForUser(),
roleIndex: 0,
})
},
async save() {
try {
const form = { ...this.data.form }
if (form.role !== 'manager' && !(form.attendancePointNames || []).length) {
wx.showToast({ title: '请选择考勤点', icon: 'none' })
return
}
await api.savePerson(form)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
roleOptions: this.roleOptionsForUser(),
roleIndex: 0,
formVisible: false,
})
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
edit(e) {
const index = e.currentTarget.dataset.index
const person = this.data.rows[index]
const roleIndex = Math.max(0, this.data.roleOptions.findIndex(item => item.value === person.role))
const pointNames = person.attendancePointNames || []
this.setData({
form: {
phone: person.phone,
originalPhone: person.phone,
name: person.name,
role: person.role,
attendancePointNames: pointNames,
},
attendancePoints: this.checkedPointMap(pointNames),
roleIndex,
roleOptions: this.roleOptionsForUser(),
formTitle: '编辑人员',
formVisible: true,
})
},
addRole(e) {
const index = e.currentTarget.dataset.index
const person = this.data.rows[index]
if (!person) {
return
}
const existingRoles = new Set(person.roles || [])
const roleOptions = this.roleOptionsForUser().filter(item => !existingRoles.has(item.value))
if (!roleOptions.length) {
wx.showToast({ title: '该人员已拥有全部可添加角色', icon: 'none' })
return
}
const role = roleOptions[0].value
const pointNames = person.attendancePointNames || []
this.setData({
form: {
phone: person.phone,
originalPhone: person.phone,
name: person.name,
role,
attendancePointNames: role === 'manager' ? pointNames : pointNames,
},
attendancePoints: this.checkedPointMap(pointNames),
roleOptions,
roleIndex: 0,
formTitle: '添加角色',
formVisible: true,
})
},
removeRole(e) {
const phone = e.currentTarget.dataset.phone
const role = e.currentTarget.dataset.role
const roleName = e.currentTarget.dataset.roleName
wx.showModal({
title: '删除角色',
content: `${phone} · ${roleName}`,
success: async res => {
if (res.confirm) {
try {
await api.deletePerson(phone, role)
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
}
},
})
},
removePerson(e) {
const index = e.currentTarget.dataset.index
const person = this.data.rows[index]
if (!person) {
return
}
const currentUser = api.getCurrentUser()
if (currentUser && currentUser.phone === person.phone) {
wx.showToast({ title: '不能删除当前登录账号', icon: 'none' })
return
}
wx.showModal({
title: '删除人员',
content: `确定删除 ${person.name}(${person.phone})?历史报工记录会保留。`,
confirmText: '删除',
confirmColor: '#d92d20',
success: async res => {
if (res.confirm) {
try {
await api.deletePerson(person.phone)
wx.showToast({ title: '已删除', icon: 'success' })
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
}
},
})
},
clearForm() {
this.setData({
form: { ...emptyForm },
attendancePoints: this.checkedPointMap([]),
roleOptions: this.roleOptionsForUser(),
roleIndex: 0,
})
},
chooseFile() {
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['xlsx', 'xls'],
success: async res => {
const file = res.tempFiles[0]
this.setData({ importFileName: file.name })
wx.showLoading({ title: '导入中' })
try {
const result = await api.importPeople(file.path)
wx.showToast({ title: `导入${result.imported}条`, icon: 'success' })
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
},
async exportFile() {
wx.showLoading({ title: '导出中' })
try {
const filePath = await api.exportPeople()
wx.openDocument({
filePath,
fileType: 'xlsx',
showMenu: true,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "人员清单"
}
+117
View File
@@ -0,0 +1,117 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">人员清单</text>
<text class="subtitle">电话号唯一,角色决定可用功能</text>
</view>
<view wx:if="{{!readonly}}" class="card">
<view class="button-row">
<button class="btn secondary full" bindtap="chooseFile">导入人员清单</button>
<button class="btn primary full" bindtap="exportFile">导出人员清单</button>
</view>
<button class="btn primary full add-btn" bindtap="openCreate">新增人员</button>
<text wx:if="{{importFileName}}" class="subtitle">已选择:{{importFileName}}</text>
</view>
<input class="input search" placeholder="搜索姓名、电话、角色" value="{{keyword}}" bindinput="onKeywordInput" />
<view wx:if="{{!rows.length}}" class="list-empty">暂无人员</view>
<view wx:for="{{rows}}" wx:key="phone" wx:for-index="index" class="card person-card {{item.temporaryExpired ? 'person-expired' : ''}}">
<view class="row">
<view class="row-left">
<text class="value">{{item.name}}</text>
<text class="subtitle">{{item.phone}}</text>
<text wx:if="{{item.role !== 'manager'}}" class="subtitle">考勤点 {{item.attendancePointsText || '-'}}</text>
<text wx:if="{{item.temporaryStatusText}}" class="temporary-status {{item.temporaryExpired ? 'expired' : ''}}">
{{item.temporaryStatusText}}
</text>
</view>
</view>
<view wx:if="{{readonly}}" class="role-row">
<view
wx:for="{{item.rolesDisplay}}"
wx:key="role"
wx:for-item="roleItem"
class="role-chip readonly-chip"
>
{{roleItem.roleName}}
</view>
</view>
<view wx:else class="role-row">
<button
wx:for="{{item.rolesDisplay}}"
wx:key="role"
wx:for-item="roleItem"
class="role-chip"
data-phone="{{item.phone}}"
data-role="{{roleItem.role}}"
data-role-name="{{roleItem.roleName}}"
bindtap="removeRole"
>
{{roleItem.roleName}} ×
</button>
</view>
<view wx:if="{{!readonly}}" class="button-row">
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
<button wx:if="{{currentUser.role === 'manager'}}" class="btn secondary full" data-index="{{index}}" bindtap="addRole">添加角色</button>
<button class="btn danger full" data-index="{{index}}" bindtap="removePerson">删除人员</button>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
<view wx:if="{{formVisible}}" class="modal-mask" catchtap="closeForm">
<view class="modal-panel" catchtap="noop">
<view class="modal-head">
<text class="modal-title">{{formTitle}}</text>
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
</view>
<view class="form-item">
<text class="label">电话号</text>
<input class="input" type="number" value="{{form.phone}}" disabled="{{!!form.originalPhone}}" data-field="phone" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">姓名</text>
<input class="input" value="{{form.name}}" data-field="name" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">角色</text>
<picker mode="selector" range="{{roleOptions}}" range-key="label" value="{{roleIndex}}" bindchange="onRoleChange">
<view class="picker">{{roleOptions[roleIndex].label}}</view>
</picker>
</view>
<view wx:if="{{form.role !== 'manager'}}" class="form-item">
<text class="label">考勤点</text>
<checkbox-group class="checkbox-grid" bindchange="onPointCheckboxChange">
<label
wx:for="{{attendancePoints}}"
wx:key="name"
class="checkbox-chip {{item.checked ? 'checked' : ''}}"
>
<checkbox value="{{item.name}}" checked="{{item.checked}}" color="#1463ff"></checkbox>
<text>{{item.name}}</text>
</label>
</checkbox-group>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="closeForm">关闭</button>
<button class="btn primary full" bindtap="save">保存人员</button>
</view>
</view>
</view>
+157
View File
@@ -0,0 +1,157 @@
.search {
margin-bottom: 20rpx;
}
.add-btn {
margin-top: 16rpx;
}
.role-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.role-chip {
min-width: 128rpx;
height: 56rpx;
line-height: 56rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
color: #1456c2;
font-size: 24rpx;
font-weight: 600;
box-shadow:
0 6rpx 0 rgba(144, 178, 235, 0.62),
0 10rpx 16rpx rgba(20, 86, 194, 0.09),
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
}
.role-chip::after {
border: 0;
}
.person-expired {
background: linear-gradient(180deg, #f2f4f7 0%, #e4e7ec 100%);
border-color: #98a2b3;
filter: grayscale(0.8);
opacity: 0.78;
}
.person-expired .value,
.person-expired .subtitle {
color: #667085;
}
.person-card .button-row .btn {
width: auto;
min-width: 0;
flex: 1 1 0;
padding: 0 10rpx;
font-size: 25rpx;
}
.temporary-status {
display: inline-flex;
margin-top: 10rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: #e7f7ef;
color: #087443;
font-size: 22rpx;
font-weight: 700;
}
.temporary-status.expired {
background: #eef0f3;
color: #667085;
}
.readonly-chip {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
box-sizing: border-box;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
max-height: 82vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 24rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
.checkbox-grid {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.checkbox-chip {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 72rpx;
padding: 0 18rpx;
border: 2rpx solid #cbd6e6;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f5f8fc 100%);
color: #162232;
font-size: 25rpx;
font-weight: 700;
box-shadow:
0 6rpx 0 rgba(166, 181, 203, 0.42),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
box-sizing: border-box;
}
.checkbox-chip.checked {
border-color: #7aa7f5;
background: linear-gradient(180deg, #eef5ff 0%, #ddeaff 100%);
color: #1456c2;
}
+325
View File
@@ -0,0 +1,325 @@
const api = require('../../utils/api')
const emptyForm = {
attendancePointName: '',
projectNo: '',
profileNo: '',
productName: '',
materialCode: '',
materialName: '',
supplier: '',
productNetWeightKg: '',
productGrossWeightKg: '',
allowedScrapRate: '',
wastePriceYuanPerKg: '',
process: '',
stampingMethod: '',
operatorCount: 1,
processUnitPriceYuan: '',
standardBeat: '',
}
Page({
data: {
readonly: false,
keyword: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
scrollTop: 0,
form: { ...emptyForm },
formVisible: false,
formTitle: '新增产品',
attendancePoints: [],
attendancePointLabels: [],
attendancePointIndex: 0,
stampingMethodSuggestions: ['清洗', '连续模'],
stampingSuggestionVisible: false,
importFileName: '',
refreshing: false,
},
onLoad(options) {
this.setData({
readonly: options.readonly === '1',
})
},
onShow() {
this.init()
},
async init() {
await this.loadAttendancePoints()
await this.load()
},
async loadAttendancePoints() {
try {
const points = await api.listAccessibleAttendancePoints()
const attendancePoints = points || []
this.setData({
attendancePoints,
attendancePointLabels: attendancePoints.map(item => item.name),
})
if (!this.data.form.attendancePointName && attendancePoints.length) {
this.setData({
'form.attendancePointName': attendancePoints[0].name,
attendancePointIndex: 0,
})
}
} catch (error) {
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
}
},
async load(page = this.data.page) {
try {
const result = await api.listProducts({
keyword: this.data.keyword,
page,
pageSize: 8,
})
this.setData({
rows: (result.rows || []).map(item => ({
...item,
processDisplay: item.processDisplay || api.processDisplayName(item.process),
})),
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
scrollTop: 0,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onKeywordInput(e) {
this.setData({ keyword: e.detail.value, page: 1 })
this.load(1)
},
onInput(e) {
const field = e.currentTarget.dataset.field
let value = e.detail.value
if (field === 'process') {
value = this.normalizeProcessInput(value)
}
if (field === 'stampingMethod') {
this.setData({
stampingSuggestionVisible: false,
[`form.${field}`]: value,
})
return
}
this.setData({
[`form.${field}`]: value,
})
},
onStampingFocus() {
this.setData({ stampingSuggestionVisible: true })
},
chooseStampingSuggestion(e) {
const value = e.currentTarget.dataset.value
this.setData({
'form.stampingMethod': value,
stampingSuggestionVisible: false,
})
},
hideStampingSuggestion() {
setTimeout(() => {
this.setData({ stampingSuggestionVisible: false })
}, 150)
},
normalizeProcessInput(value) {
const text = String(value || '').trim()
if (text.includes('杂活')) {
return '杂活'
}
return text.replace(/\D/g, '')
},
validateProcess(form) {
const process = String(form.process || '').trim()
if (!process) {
wx.showToast({ title: '请输入工序数字', icon: 'none' })
return false
}
if (process === '杂活') {
if (String(form.stampingMethod || '').trim() !== '处理杂活') {
wx.showToast({ title: '只有处理杂活的工序可以填写杂活', icon: 'none' })
return false
}
return true
}
if (!/^\d+$/.test(process) || Number(process) <= 0) {
wx.showToast({ title: '工序只能填写正整数', icon: 'none' })
return false
}
if (String(form.stampingMethod || '').trim() === '处理杂活') {
wx.showToast({ title: '处理杂活的工序必须填写杂活', icon: 'none' })
return false
}
return true
},
onAttendancePointChange(e) {
const attendancePointIndex = Number(e.detail.value)
const point = this.data.attendancePoints[attendancePointIndex]
this.setData({
attendancePointIndex,
'form.attendancePointName': point ? point.name : '',
})
},
noop() {},
openCreate() {
const firstPoint = this.data.attendancePoints[0]
this.setData({
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
attendancePointIndex: 0,
formTitle: '新增产品',
formVisible: true,
})
},
closeForm() {
this.setData({
formVisible: false,
form: { ...emptyForm, attendancePointName: this.data.attendancePoints[0] ? this.data.attendancePoints[0].name : '' },
})
},
async save() {
try {
const form = { ...this.data.form }
form.stampingMethod = String(form.stampingMethod || '').trim()
if (!form.attendancePointName) {
wx.showToast({ title: '请选择考勤点', icon: 'none' })
return
}
if (!this.validateProcess(form)) {
return
}
if (['清洗', '处理杂活'].includes(String(form.stampingMethod || '').trim())) {
form.standardBeat = 0
}
await api.saveProduct(form)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ form: { ...emptyForm }, formVisible: false })
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
edit(e) {
const index = e.currentTarget.dataset.index
const product = this.data.rows[index]
const attendancePointIndex = Math.max(0, this.data.attendancePoints.findIndex(item => item.name === product.attendancePointName))
this.setData({
form: {
...product,
originalAttendancePointName: product.attendancePointName,
originalProjectNo: product.projectNo,
originalProductName: product.productName,
originalDeviceNo: product.deviceNo || '',
originalProcess: product.rawProcess || product.process || '',
},
attendancePointIndex,
formTitle: '编辑产品',
formVisible: true,
})
},
remove(e) {
const index = e.currentTarget.dataset.index
const product = this.data.rows[index]
wx.showModal({
title: '删除产品',
content: product.productName,
success: async res => {
if (res.confirm) {
try {
await api.deleteProduct(product)
await this.load(1)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
}
},
})
},
clearForm() {
const firstPoint = this.data.attendancePoints[0]
this.setData({
form: { ...emptyForm, attendancePointName: firstPoint ? firstPoint.name : '' },
attendancePointIndex: 0,
})
},
chooseFile() {
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['xlsx', 'xls'],
success: async res => {
const file = res.tempFiles[0]
this.setData({ importFileName: file.name })
wx.showLoading({ title: '导入中' })
try {
const result = await api.importProducts(file.path)
wx.showToast({ title: `导入${result.imported}条`, icon: 'success' })
await this.load(1)
} catch (error) {
wx.showModal({
title: '导入失败',
content: error.message,
showCancel: false,
})
} finally {
wx.hideLoading()
}
},
})
},
async exportFile() {
wx.showLoading({ title: '导出中' })
try {
const filePath = await api.exportProducts()
wx.openDocument({
filePath,
fileType: 'xlsx',
showMenu: true,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
prevPage() {
if (this.data.page > 1) {
this.setData({ scrollTop: 0 })
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.setData({ scrollTop: 0 })
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.setData({ scrollTop: 0 })
this.load(target)
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "产品清单"
}
+180
View File
@@ -0,0 +1,180 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
scroll-top="{{scrollTop}}"
scroll-with-animation
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">产品清单</text>
<text class="subtitle">项目号、产品名称、工序共同维护产品信息</text>
</view>
<view wx:if="{{!readonly}}" class="card">
<view class="button-row">
<button class="btn secondary full" bindtap="chooseFile">导入产品清单</button>
<button class="btn primary full" bindtap="exportFile">导出产品清单</button>
</view>
<button class="btn primary full add-btn" bindtap="openCreate">新增产品</button>
<text wx:if="{{importFileName}}" class="subtitle">已选择:{{importFileName}}</text>
</view>
<input class="input search" placeholder="搜索考勤点、产品、物料、供应商、工序、冲压方式" value="{{keyword}}" bindinput="onKeywordInput" />
<view wx:if="{{!rows.length}}" class="list-empty">暂无产品</view>
<view wx:for="{{rows}}" wx:key="uniqueKey" wx:for-index="index" class="card">
<view class="row">
<view class="row-left">
<text class="value">{{item.productName}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.projectNo}} · {{item.materialCode}}</text>
<text class="subtitle">物料名称 {{item.materialName || '-'}}</text>
</view>
<text class="pill primary">{{item.processDisplay || item.process || '工序'}}</text>
<text wx:if="{{item.isCleaning}}" class="cleaning-stamp">清洗</text>
<text wx:if="{{item.isMisc}}" class="misc-stamp">杂活</text>
<text wx:if="{{item.isMultiPerson}}" class="multi-person-stamp {{item.isCleaning || item.isMisc ? 'lower-product-stamp' : ''}}">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-stamp {{item.isMultiPerson ? 'lower-product-stamp' : ''}}">连续模</text>
</view>
<view class="divider"></view>
<view wx:if="{{!item.isCleaning && !item.isMisc}}" class="row">
<text class="muted">标准节拍 {{item.standardBeat}} 秒</text>
<text class="muted">工序单价 {{item.processUnitPriceYuan || 0}} 元/件</text>
</view>
<view wx:if="{{!readonly}}" class="button-row">
<button class="btn secondary full" data-index="{{index}}" bindtap="edit">编辑</button>
<button class="btn danger full" data-index="{{index}}" bindtap="remove">删除</button>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
<view wx:if="{{formVisible}}" class="modal-mask product-modal-mask" catchtap="closeForm">
<view class="modal-panel product-modal-panel" catchtap="noop">
<view class="modal-head">
<text class="modal-title">{{formTitle}}</text>
<button class="btn mini secondary modal-close" bindtap="clearForm">清空</button>
</view>
<view class="form-grid product-form-grid">
<view class="form-item full-span">
<text class="label">考勤点</text>
<picker
wx:if="{{attendancePoints.length}}"
range="{{attendancePointLabels}}"
value="{{attendancePointIndex}}"
bindchange="onAttendancePointChange"
>
<view class="picker">{{form.attendancePointName || '请选择考勤点'}}</view>
</picker>
<view wx:else class="readonly">暂无可选考勤点</view>
</view>
<view class="form-item">
<text class="label">项目号</text>
<input class="input" value="{{form.projectNo}}" data-field="projectNo" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">型材号</text>
<input class="input" value="{{form.profileNo}}" data-field="profileNo" bindinput="onInput" />
</view>
<view class="form-item full-span">
<text class="label">产品名称</text>
<input class="input" value="{{form.productName}}" data-field="productName" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">物料编码</text>
<input class="input" value="{{form.materialCode}}" data-field="materialCode" bindinput="onInput" />
</view>
<view class="form-item full-span">
<text class="label">物料名称</text>
<input class="input" value="{{form.materialName}}" data-field="materialName" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">供应商(非必填)</text>
<input class="input" value="{{form.supplier}}" data-field="supplier" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">工序</text>
<view class="process-input-wrap">
<input
class="input process-input"
type="text"
value="{{form.process}}"
placeholder="请输入数字"
data-field="process"
bindinput="onInput"
/>
<text wx:if="{{form.process !== '杂活'}}" class="process-suffix">序</text>
</view>
</view>
<view class="form-item stamping-field">
<text class="label">冲压方式</text>
<input
class="input"
value="{{form.stampingMethod}}"
placeholder="可输入,或选择清洗/连续模"
data-field="stampingMethod"
bindfocus="onStampingFocus"
bindblur="hideStampingSuggestion"
bindinput="onInput"
/>
<view wx:if="{{stampingSuggestionVisible}}" class="stamping-suggestion-panel">
<view
wx:for="{{stampingMethodSuggestions}}"
wx:key="*this"
class="stamping-suggestion-option"
data-value="{{item}}"
catchtap="chooseStampingSuggestion"
>{{item}}</view>
</view>
</view>
<view class="form-item">
<text class="label">操作人数</text>
<input class="input number-input" type="number" value="{{form.operatorCount}}" data-field="operatorCount" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">工序单价(元/件)</text>
<input class="input number-input" type="digit" value="{{form.processUnitPriceYuan}}" data-field="processUnitPriceYuan" bindinput="onInput" />
</view>
<view wx:if="{{form.stampingMethod !== '清洗' && form.stampingMethod !== '处理杂活'}}" class="form-item full-span">
<text class="label">标准节拍</text>
<input class="input number-input" type="digit" value="{{form.standardBeat}}" data-field="standardBeat" bindinput="onInput" />
</view>
<view wx:else class="form-item full-span">
<text class="label">{{form.stampingMethod === '处理杂活' ? '处理杂活' : '清洗方式'}}</text>
<view class="readonly">标准节拍不需要填写</view>
</view>
<view class="form-item">
<text class="label">产品净重(kg)(非必填)</text>
<input class="input number-input" type="digit" value="{{form.productNetWeightKg}}" data-field="productNetWeightKg" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">产品毛重(kg)(非必填)</text>
<input class="input number-input" type="digit" value="{{form.productGrossWeightKg}}" data-field="productGrossWeightKg" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">允许报废率(非必填)</text>
<input class="input number-input" type="digit" value="{{form.allowedScrapRate}}" data-field="allowedScrapRate" bindinput="onInput" />
</view>
<view class="form-item">
<text class="label">废料单价(元/kg)(非必填)</text>
<input class="input number-input" type="digit" value="{{form.wastePriceYuanPerKg}}" data-field="wastePriceYuanPerKg" bindinput="onInput" />
</view>
</view>
<view class="button-row product-modal-actions">
<button class="btn secondary full" bindtap="closeForm">关闭</button>
<button class="btn primary full" bindtap="save">保存产品</button>
</view>
</view>
</view>
+268
View File
@@ -0,0 +1,268 @@
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx 18rpx;
}
.full-span {
grid-column: 1 / -1;
}
.search {
margin-bottom: 20rpx;
}
.process-input-wrap {
display: grid;
grid-template-columns: minmax(0, 1fr) 44rpx;
align-items: center;
gap: 10rpx;
}
.process-input-wrap .process-input {
min-width: 0;
}
.process-suffix {
color: #162232;
font-size: 27rpx;
font-weight: 800;
text-align: center;
line-height: 1;
}
.cleaning-stamp {
position: absolute;
top: 24rpx;
right: 26rpx;
z-index: 2;
padding: 8rpx 18rpx;
border: 3rpx solid rgba(18, 163, 117, 0.68);
border-radius: 10rpx;
color: rgba(18, 126, 89, 0.82);
font-size: 28rpx;
font-weight: 900;
line-height: 1;
transform: rotate(-16deg);
background: rgba(230, 255, 246, 0.72);
box-shadow:
0 4rpx 0 rgba(18, 126, 89, 0.18),
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
pointer-events: none;
}
.misc-stamp {
position: absolute;
top: 24rpx;
right: 26rpx;
z-index: 2;
padding: 8rpx 18rpx;
border: 3rpx solid rgba(217, 119, 6, 0.68);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.86);
font-size: 28rpx;
font-weight: 900;
line-height: 1;
transform: rotate(-16deg);
background: rgba(255, 247, 232, 0.78);
box-shadow:
0 4rpx 0 rgba(154, 74, 5, 0.18),
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
pointer-events: none;
}
.multi-person-stamp {
position: absolute;
top: 24rpx;
right: 26rpx;
z-index: 2;
padding: 8rpx 18rpx;
border: 3rpx solid rgba(217, 119, 6, 0.68);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.86);
font-size: 28rpx;
font-weight: 900;
line-height: 1;
transform: rotate(-16deg);
background: rgba(255, 247, 232, 0.78);
box-shadow:
0 4rpx 0 rgba(154, 74, 5, 0.18),
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
pointer-events: none;
}
.continuous-die-stamp {
position: absolute;
top: 24rpx;
right: 26rpx;
z-index: 2;
padding: 8rpx 18rpx;
border: 3rpx solid rgba(217, 119, 6, 0.68);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.86);
font-size: 28rpx;
font-weight: 900;
line-height: 1;
transform: rotate(-16deg);
background: rgba(255, 247, 232, 0.78);
box-shadow:
0 4rpx 0 rgba(154, 74, 5, 0.18),
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
pointer-events: none;
}
.lower-product-stamp {
top: 76rpx;
}
.stamping-field {
position: relative;
}
.stamping-suggestion-panel {
position: absolute;
left: 0;
right: 0;
top: 82rpx;
z-index: 20;
overflow: hidden;
border: 1rpx solid #d9e2f2;
border-radius: 10rpx;
background: #fff;
box-shadow: 0 12rpx 32rpx rgba(16, 35, 72, 0.12);
}
.stamping-suggestion-option {
padding: 18rpx 20rpx;
border-bottom: 1rpx solid #eef2f7;
color: #1f2a44;
font-size: 24rpx;
line-height: 1.2;
}
.stamping-suggestion-option:last-child {
border-bottom: 0;
}
.add-btn {
margin-top: 16rpx;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
max-height: 82vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 24rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
.product-modal-mask {
align-items: flex-start;
padding: 18rpx 18rpx calc(env(safe-area-inset-bottom) + 18rpx);
}
.product-modal-panel {
max-height: 94vh;
padding: 18rpx 20rpx 20rpx;
border-radius: 16rpx;
}
.product-modal-panel .modal-head {
min-height: 54rpx;
margin-bottom: 10rpx;
}
.product-modal-panel .modal-title {
font-size: 30rpx;
line-height: 1.2;
}
.product-modal-panel .modal-close {
height: 50rpx;
line-height: 50rpx;
padding: 0 18rpx;
font-size: 22rpx;
border-radius: 10rpx;
}
.product-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8rpx 12rpx;
}
.product-form-grid .full-span {
grid-column: auto;
}
.product-form-grid .form-item {
min-width: 0;
margin-bottom: 0;
}
.product-form-grid .label {
margin-bottom: 4rpx;
font-size: 20rpx;
line-height: 1.15;
}
.product-form-grid .input {
min-height: 54rpx;
height: 54rpx;
line-height: 54rpx;
padding: 0 14rpx;
border-radius: 10rpx;
font-size: 23rpx;
}
.product-modal-actions {
gap: 12rpx;
margin-top: 14rpx;
}
.product-modal-actions .btn {
height: 58rpx;
line-height: 58rpx;
border-radius: 11rpx;
font-size: 24rpx;
}
@@ -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: '' })
}
},
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "对账小账本"
}
@@ -0,0 +1,152 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">对账小账本</text>
<text class="subtitle">按产品名称汇总最后一道工序的已审核成品数量,对账数量和退货数量可手工维护</text>
</view>
<view class="card toolbar-card">
<view class="toolbar-row">
<picker mode="selector" range="{{yearLabels}}" value="{{yearIndex}}" bindchange="onYearChange">
<view class="picker year-picker">{{selectedYear}}年</view>
</picker>
<view class="ledger-summary">
<view>
<text class="label">已报工合计</text>
<text class="value">{{totalReported}}</text>
</view>
<view>
<text class="label">对账合计</text>
<text class="value">{{totalReconciled}}</text>
</view>
<view>
<text class="label">退货合计</text>
<text class="value">{{totalReturn}}</text>
</view>
</view>
</view>
<view class="hint-row">
<text>当前年份默认定位到本月,向右滑动可查看前面月份,已过月份可点击月份标题展开。</text>
</view>
</view>
<view wx:if="{{!rawRows.length && !loading}}" class="list-empty">暂无产品</view>
<view wx:if="{{rawRows.length}}" class="ledger-shell">
<view class="ledger-fixed">
<view class="fixed-head">
<text class="fixed-head-title">考勤点 / 产品名称</text>
<picker mode="selector" range="{{pointLabels}}" value="{{pointIndex}}" bindchange="onPointChange">
<view class="point-filter-picker">
<text>{{selectedPointLabel || '全部考勤点'}}</text>
</view>
</picker>
</view>
<view
wx:for="{{rows}}"
wx:key="uniqueKey"
wx:for-item="row"
class="fixed-product data-row"
>
<text class="point-name">{{row.attendancePointName || '-'}}</text>
<text class="product-name">{{row.productName}}</text>
</view>
<view wx:if="{{!rows.length && !loading}}" class="fixed-product empty-product">
<text>当前考勤点暂无产品</text>
</view>
</view>
<scroll-view
class="ledger-x"
scroll-x
enhanced
show-scrollbar
scroll-left="{{monthScrollLeft}}"
>
<view class="ledger-table" style="width: {{monthTableWidth}}rpx;">
<view class="ledger-row ledger-head">
<view
wx:for="{{months}}"
wx:key="month"
wx:for-item="month"
wx:for-index="monthIndex"
class="month-block {{month.expanded ? 'expanded' : 'collapsed'}}"
style="width: {{month.width}}rpx;"
>
<view class="month-title" data-index="{{monthIndex}}" bindtap="toggleMonth">
<text>{{month.label}}</text>
<text class="month-toggle">{{month.expanded ? '收起' : '展开'}}</text>
</view>
<view wx:if="{{month.expanded}}" class="month-subhead">
<text>已报工成品</text>
<text>对账成品</text>
<text>退货数量</text>
</view>
</view>
<view wx:if="{{monthEndSpacerWidth}}" class="month-end-spacer" style="width: {{monthEndSpacerWidth}}rpx;"></view>
</view>
<view
wx:for="{{rows}}"
wx:key="uniqueKey"
wx:for-item="row"
wx:for-index="rowIndex"
class="ledger-row data-row"
>
<view
wx:for="{{row.months}}"
wx:key="month"
wx:for-item="month"
wx:for-index="monthIndex"
class="month-block row-month {{month.expanded ? 'expanded' : 'collapsed'}}"
style="width: {{month.width}}rpx;"
>
<block wx:if="{{month.expanded}}">
<view class="qty-cell reported">
<text>{{month.reportedGoodQtyText}}</text>
</view>
<view class="qty-cell editable">
<input
class="ledger-input"
type="digit"
value="{{month.reconciledGoodQtyInput}}"
data-row-index="{{rowIndex}}"
data-month-index="{{monthIndex}}"
data-field="reconciledGoodQty"
bindinput="onQtyInput"
bindblur="saveCell"
/>
</view>
<view class="qty-cell editable return">
<input
class="ledger-input"
type="digit"
value="{{month.returnQtyInput}}"
data-row-index="{{rowIndex}}"
data-month-index="{{monthIndex}}"
data-field="returnQty"
bindinput="onQtyInput"
bindblur="saveCell"
/>
</view>
</block>
<view wx:else class="collapsed-cell" data-index="{{monthIndex}}" bindtap="toggleMonth">
<text>{{month.label}}</text>
</view>
</view>
<view wx:if="{{monthEndSpacerWidth}}" class="month-end-spacer" style="width: {{monthEndSpacerWidth}}rpx;"></view>
</view>
<view wx:if="{{!rows.length && !loading}}" class="ledger-row data-row ledger-empty-row">
<view class="ledger-empty-cell" style="width: {{monthTableWidth}}rpx;">当前考勤点暂无产品</view>
</view>
</view>
</scroll-view>
</view>
</scroll-view>
@@ -0,0 +1,345 @@
.toolbar-card {
padding: 24rpx;
}
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.year-picker {
min-width: 186rpx;
min-height: 74rpx;
justify-content: center;
color: #162232;
font-size: 30rpx;
font-weight: 800;
}
.ledger-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
flex: 1;
}
.ledger-summary > view {
padding: 14rpx 16rpx;
border: 2rpx solid #c5d2e4;
border-radius: 14rpx;
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
box-shadow:
0 7rpx 0 rgba(166, 181, 203, 0.64),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.hint-row {
margin-top: 22rpx;
padding: 14rpx 16rpx;
border-radius: 12rpx;
background: #eef6ff;
color: #475467;
font-size: 24rpx;
line-height: 1.45;
}
.ledger-shell {
display: flex;
width: 100%;
margin-top: 18rpx;
border: 3rpx solid #99aac3;
border-radius: 16rpx;
overflow: hidden;
background: #ffffff;
box-shadow:
0 12rpx 0 rgba(143, 158, 181, 0.72),
0 22rpx 34rpx rgba(22, 34, 50, 0.16),
inset 0 2rpx 0 rgba(255, 255, 255, 0.95);
}
.ledger-fixed {
width: 284rpx;
flex: 0 0 284rpx;
position: relative;
z-index: 8;
background: #ffffff;
box-shadow: 12rpx 0 18rpx rgba(22, 34, 50, 0.14);
}
.fixed-head,
.fixed-product {
display: flex;
align-items: center;
box-sizing: border-box;
}
.fixed-head {
flex-direction: column;
justify-content: center;
gap: 12rpx;
height: 150rpx;
padding: 12rpx 14rpx;
border-right: 2rpx solid #b4c2d6;
border-bottom: 2rpx solid #c4d0e1;
background: linear-gradient(180deg, #cbd8ea 0%, #b7c7dd 100%);
color: #162232;
font-size: 22rpx;
font-weight: 800;
}
.fixed-head-title {
display: block;
max-width: 100%;
line-height: 1.2;
}
.point-filter-picker {
width: 100%;
min-height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12rpx;
border: 2rpx solid rgba(20, 86, 194, 0.24);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.88);
color: #1456c2;
font-size: 21rpx;
font-weight: 800;
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.92),
0 5rpx 0 rgba(126, 146, 175, 0.36);
box-sizing: border-box;
}
.point-filter-picker text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fixed-product {
flex-direction: column;
align-items: flex-start;
justify-content: center;
height: 164rpx;
gap: 8rpx;
padding: 14rpx 16rpx;
border-right: 2rpx solid #c4d0e1;
border-bottom: 2rpx solid #d7e0ec;
}
.ledger-x {
min-width: 0;
flex: 1;
margin-top: 0;
padding-bottom: 0;
}
.ledger-table {
min-width: 100%;
overflow: hidden;
background: #ffffff;
}
.ledger-row {
display: flex;
height: 164rpx;
}
.ledger-head {
height: 150rpx;
background: linear-gradient(180deg, #dce8f8 0%, #c8d8ed 100%);
color: #162232;
font-size: 24rpx;
font-weight: 800;
}
.data-row:nth-child(odd) {
background: #f9fbfe;
}
.data-row:nth-child(even) {
background: #ffffff;
}
.ledger-fixed .data-row:nth-child(odd) {
background: #f9fbfe;
}
.ledger-fixed .data-row:nth-child(even) {
background: #ffffff;
}
.point-name {
display: -webkit-box;
max-width: 100%;
overflow: hidden;
color: #1456c2;
font-size: 20rpx;
font-weight: 800;
line-height: 1.28;
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.product-name {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: #162232;
font-size: 22rpx;
font-weight: 700;
line-height: 1.32;
word-break: break-all;
}
.month-block {
flex: 0 0 auto;
border-right: 2rpx solid #c4d0e1;
border-bottom: 2rpx solid #d7e0ec;
box-sizing: border-box;
}
.month-end-spacer {
flex: 0 0 auto;
border-bottom: 2rpx solid #d7e0ec;
background: #f8fbff;
box-sizing: border-box;
}
.month-title {
height: 74rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 0 10rpx;
background: linear-gradient(180deg, #edf4ff 0%, #d8e6f8 100%);
color: #162232;
box-sizing: border-box;
}
.month-block.collapsed .month-title {
height: 150rpx;
flex-direction: column;
}
.month-toggle {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: rgba(20, 86, 194, 0.1);
color: #1456c2;
font-size: 20rpx;
font-weight: 800;
}
.month-subhead {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
height: 76rpx;
align-items: center;
text-align: center;
color: #475467;
font-size: 21rpx;
}
.month-subhead text:not(:last-child) {
border-right: 2rpx solid #c4d0e1;
}
.row-month {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-height: 164rpx;
}
.row-month.collapsed {
display: flex;
align-items: stretch;
}
.qty-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 8rpx;
color: #162232;
font-size: 24rpx;
font-weight: 800;
box-sizing: border-box;
}
.reported {
border-right: 2rpx solid #d7e0ec;
background: rgba(235, 244, 255, 0.52);
}
.editable {
background: rgba(235, 255, 244, 0.45);
}
.editable:not(:last-child) {
border-right: 2rpx solid #d7e0ec;
}
.editable.return {
background: rgba(255, 248, 230, 0.64);
}
.ledger-input {
width: 96rpx;
height: 58rpx;
border: 2rpx solid #8fb7a0;
border-radius: 12rpx;
background: #ffffff;
color: #087443;
font-size: 25rpx;
font-weight: 800;
text-align: center;
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.95),
0 5rpx 0 rgba(130, 174, 151, 0.46);
box-sizing: border-box;
}
.collapsed-cell {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #667085;
font-size: 22rpx;
font-weight: 800;
background:
repeating-linear-gradient(135deg, rgba(148, 163, 184, 0.12) 0, rgba(148, 163, 184, 0.12) 6rpx, transparent 6rpx, transparent 14rpx),
#f2f5f9;
}
.empty-product,
.ledger-empty-cell {
align-items: center;
justify-content: center;
color: #667085;
font-size: 24rpx;
font-weight: 700;
}
.empty-product {
text-align: center;
}
.ledger-empty-cell {
height: 164rpx;
display: flex;
flex: 0 0 auto;
border-bottom: 2rpx solid #d7e0ec;
background: #f8fbff;
box-sizing: border-box;
}
+272
View File
@@ -0,0 +1,272 @@
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
user: null,
mode: 'mine',
title: '我的报工记录',
startDate: '',
endDate: '',
status: '',
showStatusFilter: true,
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
refreshing: false,
scrollTop: 0,
expandedRecordId: '',
detailExpandedAll: false,
filterVoided: true,
statusOptions: [
{ label: '全部', value: '' },
{ label: '待审核', value: 'pending' },
{ label: '已通过', value: 'approved' },
],
},
onLoad(options) {
const mode = options.mode || 'mine'
const defaultDate = util.today()
const startDate = mode === 'pending' ? (options.startDate || '') : (options.startDate || defaultDate)
const endDate = mode === 'pending' ? (options.endDate || '') : (options.endDate || defaultDate)
const titleMap = {
audit: '审核记录',
pending: '待审核清单',
mine: '我的报工记录',
}
this.setData({
mode,
title: titleMap[mode] || '我的报工记录',
startDate,
endDate,
status: mode === 'pending' ? 'pending' : (options.status || ''),
showStatusFilter: mode !== 'pending',
})
},
onShow() {
this.load()
},
toggleDetails() {
this.setData({
detailExpandedAll: !this.data.detailExpandedAll,
expandedRecordId: '',
})
},
onScroll(e) {
this.currentScrollTop = e.detail.scrollTop || 0
},
queryRecordTop(id) {
return new Promise(resolve => {
this.createSelectorQuery()
.select(`#record-${id}`)
.boundingClientRect(rect => {
resolve(rect ? rect.top : null)
})
.exec()
})
},
restoreRecordPosition(id, beforeTop) {
if (beforeTop === null || beforeTop === undefined) {
return
}
wx.nextTick(() => {
this.queryRecordTop(id).then(afterTop => {
if (afterTop === null || afterTop === undefined) {
return
}
const nextScrollTop = Math.max(
0,
(this.currentScrollTop || 0) + afterTop - beforeTop,
)
this.currentScrollTop = nextScrollTop
this.setData({ scrollTop: nextScrollTop })
})
})
},
toggleRecordDetail(e) {
const id = String(e.currentTarget.dataset.id || '')
const switchingRecord = this.data.detailExpandedAll
|| (!!this.data.expandedRecordId && this.data.expandedRecordId !== id)
this.queryRecordTop(id).then(beforeTop => {
this.setData({
detailExpandedAll: false,
expandedRecordId: this.data.expandedRecordId === id ? '' : id,
}, () => {
if (switchingRecord) {
this.restoreRecordPosition(id, beforeTop)
}
})
})
},
async load(page = this.data.page) {
const user = api.getCurrentUser()
if (!user) {
this.setData({ rows: [], user })
return
}
const filters = {
startDate: this.data.startDate,
endDate: this.data.endDate,
status: this.data.status,
page,
pageSize: 8,
includeVoided: !this.data.filterVoided,
}
if (this.data.mode === 'audit') {
filters.reviewerPhone = user.phone
} else if (this.data.mode === 'pending') {
filters.status = 'pending'
} else {
filters.employeePhone = user.phone
}
try {
const result = await api.listReports(filters)
this.setData({
user,
rows: result.rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
expandedRecordId: '',
detailExpandedAll: false,
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onStartChange(e) {
this.setData({ startDate: e.detail.value, page: 1 })
this.load(1)
},
onEndChange(e) {
this.setData({ endDate: e.detail.value, page: 1 })
this.load(1)
},
shiftRangeDate(field, days) {
const otherField = field === 'startDate' ? 'endDate' : 'startDate'
const current = this.data[field]
const other = this.data[otherField]
const base = current || other || util.today()
const updates = {
[field]: util.addDays(base, days),
page: 1,
}
if (!current && !other) {
updates[otherField] = base
}
this.setData(updates)
this.load(1)
},
decreaseStartDate() {
this.shiftRangeDate('startDate', -1)
},
increaseEndDate() {
this.shiftRangeDate('endDate', 1)
},
chooseStatus(e) {
this.setData({
status: e.currentTarget.dataset.value,
page: 1,
})
this.load(1)
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
openPendingReview(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/review/review?reportId=${id}`,
})
},
toggleFilterVoided() {
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
this.load(1)
},
onFilterVoidedChange(e) {
this.setData({ filterVoided: !!e.detail.value, page: 1 })
this.load(1)
},
async voidReport(e) {
const id = e.currentTarget.dataset.id
wx.showModal({
title: '确认作废',
content: '作废的记录将在30天后被系统彻底删除,彻底删除前可随时撤销作废,是否确定删除',
cancelText: '否',
confirmText: '是',
confirmColor: '#c5221f',
success: async res => {
if (!res.confirm) {
return
}
wx.showLoading({ title: '处理中' })
try {
await api.voidReport(id)
wx.showToast({ title: '已作废', icon: 'success' })
await this.load(this.data.page)
} catch (error) {
wx.showToast({ title: error.message || '作废失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
},
async unvoidReport(e) {
const id = e.currentTarget.dataset.id
wx.showLoading({ title: '处理中' })
try {
await api.unvoidReport(id)
wx.showToast({ title: '已撤销作废', icon: 'success' })
await this.load(this.data.page)
} catch (error) {
wx.showToast({ title: error.message || '撤销失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
openEditReview(e) {
if (this.data.mode === 'mine') {
return
}
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/review/review?reportId=${id}`,
})
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "报工记录"
}
+172
View File
@@ -0,0 +1,172 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
scroll-top="{{scrollTop}}"
scroll-anchoring="{{true}}"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindscroll="onScroll"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<view class="records-header-row">
<view class="records-header-main">
<text class="title">{{title}}</text>
<text class="subtitle">支持按日期和状态筛选,点击记录查看明细</text>
</view>
<view class="top-action-group">
<view class="void-filter-switch">
<text>过滤作废</text>
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
</view>
<button class="btn mini secondary detail-toggle" bindtap="toggleDetails">
{{detailExpandedAll ? '收起明细' : '展开明细'}}
</button>
</view>
</view>
</view>
<view class="filter-bar date-filter-bar">
<view class="date-filter-cell">
<button class="date-step-btn" bindtap="decreaseStartDate">-</button>
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
<view class="picker">{{startDate || '开始日期'}}</view>
</picker>
</view>
<view class="date-filter-cell end">
<button class="date-step-btn" bindtap="increaseEndDate">+</button>
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
<view class="picker">{{endDate || '结束日期'}}</view>
</picker>
</view>
</view>
<view wx:if="{{showStatusFilter}}" class="status-row">
<button
wx:for="{{statusOptions}}"
wx:key="value"
class="status-btn {{status === item.value ? 'active' : ''}}"
data-value="{{item.value}}"
bindtap="chooseStatus"
>
{{item.label}}
</button>
</view>
<view wx:if="{{!rows.length}}" class="list-empty">暂无记录</view>
<view
wx:for="{{rows}}"
wx:key="id"
id="record-{{item.id}}"
class="card record-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}} {{expandedRecordId == item.id || detailExpandedAll ? 'expanded' : ''}}"
data-id="{{item.id}}"
bindtap="toggleRecordDetail"
>
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
<text wx:elif="{{item.isModified}}" class="modified-watermark">更改</text>
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">{{item.autoSubmitReason || '超时-系统自动提交'}}</text>
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">清洗</text>
<text wx:if="{{item.hasMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">包含杂活</text>
<text wx:if="{{item.hasMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
<text wx:if="{{item.hasContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning || item.hasMultiPerson ? 'lower-watermark' : ''}} {{item.hasMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
<view class="row">
<view class="row-left">
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.startAtText}} 至 {{item.endAtText}}</text>
<text class="subtitle">{{item.metrics.shiftDistributionText || ''}}</text>
<view wx:if="{{item.correctionPairs.length}}" class="correction-list">
<view wx:for="{{item.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
<text>{{pair.label}} </text>
<text class="old-value">{{pair.oldValue}}</text>
<text> {{pair.newValue}}</text>
</view>
</view>
</view>
<text class="pill {{item.status === 'approved' ? 'success' : item.status === 'pending' ? 'warn' : ''}}">{{item.statusName}}</text>
</view>
<view wx:if="{{mode !== 'mine'}}" class="record-action-row">
<button wx:if="{{mode === 'pending' && !item.isVoided && item.canEdit}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="openPendingReview">进入审核</button>
<button wx:if="{{mode === 'audit' && !item.isVoided && item.canEdit}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="openEditReview">更改</button>
<button wx:if="{{!item.isVoided && item.canVoid}}" class="btn danger record-action-btn" data-id="{{item.id}}" catchtap="voidReport">作废</button>
<button wx:if="{{item.isVoided && item.canUnvoid}}" class="btn secondary record-action-btn" data-id="{{item.id}}" catchtap="unvoidReport">撤销作废</button>
</view>
<view wx:if="{{(detailExpandedAll || expandedRecordId == item.id) && item.items.length}}" class="record-detail-list">
<text class="detail-section-title">报工明细</text>
<view
wx:for="{{item.items}}"
wx:key="id"
wx:for-item="detail"
class="record-detail-item {{detail.isMultiPerson || detail.isContinuousDie ? 'marked-detail-item' : ''}} {{detail.isMultiPerson && detail.isContinuousDie ? 'stacked-detail-item' : ''}}"
>
<text wx:if="{{detail.isMultiPerson}}" class="detail-multi-stamp">多人协作</text>
<text wx:if="{{detail.isContinuousDie}}" class="detail-continuous-stamp {{detail.isMultiPerson ? 'lower-detail-stamp' : ''}}">连续模</text>
<view class="row detail-head">
<view class="row-left">
<text class="detail-product">{{detail.displayName || detail.productName || '-'}}</text>
<text class="detail-meta">考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}}</text>
<text class="detail-meta">项目 {{detail.projectNo || '-'}} · 工序 {{detail.process || '-'}}</text>
<text class="detail-meta">冲压方式 {{detail.stampingMethod || '-'}}</text>
<text class="detail-meta">材料库存批次号 {{detail.rawMaterialBatchNo || '-'}}</text>
</view>
<text class="pill primary device-pill">{{detail.deviceNo || '-'}}</text>
</view>
<view class="detail-qty-grid">
<view>
<text class="label">{{detail.isMisc ? '处理杂活' : (detail.isCleaning ? '清洗数量' : '成品数量')}}</text>
<text class="value">{{detail.isMisc ? '不计产量' : detail.goodQty}}</text>
</view>
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
<text class="label">不良数量</text>
<text class="value">{{detail.defectQty}}</text>
</view>
<view>
<text class="label">工时</text>
<text class="value">{{detail.workTimeText}}</text>
</view>
<view wx:if="{{detail.isMisc}}">
<text class="label">杂活事项</text>
<text class="value">{{detail.remark || '待备注'}}</text>
</view>
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
<text class="label">实际节拍</text>
<text class="value {{detail.beatCompareClass}}">
{{detail.actualBeat}} 秒
<text wx:if="{{detail.beatReason}}" class="metric-reason">{{detail.beatReason}}</text>
</text>
</view>
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
<text class="label">节拍对比</text>
<text class="value {{detail.beatCompareClass}}">{{detail.paceText}}</text>
</view>
<view wx:if="{{!detail.isCleaning && !detail.isMisc}}">
<text class="label">工作量对比</text>
<text class="value {{detail.workloadCompareClass}}">{{detail.workloadText}}</text>
</view>
<view wx:if="{{detail.isContinuousDie}}">
<text class="label">换料次数</text>
<text class="value">{{detail.changeoverCount || 0}}</text>
</view>
</view>
<view wx:if="{{detail.correctionPairs.length}}" class="correction-list detail-corrections">
<view wx:for="{{detail.correctionPairs}}" wx:key="key" wx:for-item="pair" class="correction-pair">
<text>{{pair.label}} </text>
<text class="old-value">{{pair.oldValue}}</text>
<text> {{pair.newValue}}</text>
</view>
</view>
</view>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
+284
View File
@@ -0,0 +1,284 @@
.records-header-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
}
.records-header-main {
min-width: 0;
width: 100%;
flex: none;
}
.detail-toggle {
position: relative;
z-index: 2;
flex: 0 0 auto;
min-width: 150rpx;
height: 62rpx;
line-height: 62rpx;
margin: 4rpx 0 0;
padding: 0 18rpx;
border-radius: 14rpx;
color: #1463ff;
font-size: 24rpx;
font-weight: 800;
}
.records-header-row .top-action-group {
width: 100%;
display: grid;
grid-template-columns: 240rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
margin-top: 0;
}
.records-header-row .void-filter-switch {
width: 240rpx;
}
.records-header-row .detail-toggle {
width: 100%;
min-width: 0;
height: 68rpx;
line-height: 68rpx;
box-sizing: border-box;
}
.status-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
margin-bottom: 20rpx;
padding: 6rpx;
border-radius: 16rpx;
background: linear-gradient(180deg, #f7faff 0%, #e4ebf5 100%);
box-shadow:
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.06),
0 8rpx 0 rgba(205, 216, 229, 0.48),
0 12rpx 18rpx rgba(22, 34, 50, 0.07);
}
.status-btn {
width: 100%;
min-width: 0;
height: 62rpx;
line-height: 62rpx;
margin: 0;
padding: 0 10rpx;
border-radius: 14rpx;
background: transparent;
color: #475467;
font-size: 24rpx;
font-weight: 600;
box-sizing: border-box;
}
.status-btn::after {
border: 0;
}
.status-btn.active {
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
color: #ffffff;
box-shadow:
0 7rpx 0 #0d3f93,
0 12rpx 18rpx rgba(20, 99, 255, 0.2),
inset 0 2rpx 0 rgba(255, 255, 255, 0.24);
}
.record-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
padding: 4rpx 0;
}
.record-card {
position: relative;
overflow: hidden;
border-color: #aebbd0;
}
.cleaning-watermark {
position: absolute;
right: 28rpx;
top: 18rpx;
z-index: 1;
padding: 8rpx 18rpx;
border: 2rpx solid rgba(18, 163, 117, 0.55);
border-radius: 12rpx;
color: rgba(8, 116, 67, 0.72);
background: rgba(226, 255, 243, 0.82);
font-size: 30rpx;
font-weight: 900;
transform: rotate(10deg);
pointer-events: none;
}
.record-card.expanded {
border-color: #1463ff;
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.9),
0 13rpx 0 rgba(20, 86, 194, 0.48),
0 24rpx 36rpx rgba(20, 86, 194, 0.16),
inset 0 0 0 1rpx rgba(20, 99, 255, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.pending-action-row,
.record-action-row {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.review-entry-btn,
.record-action-btn {
width: 172rpx;
height: 62rpx;
line-height: 62rpx;
margin: 0;
padding: 0;
font-size: 24rpx;
font-weight: 800;
}
.review-entry-btn,
.record-action-btn.secondary {
color: #1463ff;
}
.record-detail-list {
margin-top: 20rpx;
padding: 18rpx;
border: 2rpx solid #bfcadd;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
box-shadow:
inset 0 0 0 1rpx rgba(255, 255, 255, 0.86),
0 8rpx 0 rgba(177, 191, 211, 0.58),
0 14rpx 22rpx rgba(22, 34, 50, 0.08);
}
.detail-section-title {
display: block;
margin-bottom: 16rpx;
color: #162232;
font-size: 26rpx;
font-weight: 700;
}
.record-detail-item {
position: relative;
padding: 18rpx;
border: 2rpx solid #aebbd0;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.82),
0 8rpx 0 rgba(176, 190, 209, 0.64),
0 14rpx 20rpx rgba(22, 34, 50, 0.08),
inset 0 0 0 1rpx rgba(174, 187, 208, 0.34),
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
box-sizing: border-box;
}
.detail-multi-stamp {
position: absolute;
right: 18rpx;
top: 14rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
pointer-events: none;
}
.detail-continuous-stamp {
position: absolute;
right: 18rpx;
top: 14rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
pointer-events: none;
}
.detail-continuous-stamp.lower-detail-stamp {
top: 56rpx;
}
.record-detail-item:first-of-type {
margin-top: 0;
}
.record-detail-item + .record-detail-item {
margin-top: 18rpx;
}
.record-detail-item:last-child {
margin-bottom: 0;
}
.record-detail-item.multi-detail-item,
.record-detail-item.marked-detail-item {
padding-top: 56rpx;
}
.record-detail-item.stacked-detail-item {
padding-top: 96rpx;
}
.detail-head {
align-items: flex-start;
}
.device-pill {
max-width: 260rpx;
height: auto;
min-height: 44rpx;
white-space: normal;
word-break: break-all;
line-height: 1.3;
padding-top: 8rpx;
padding-bottom: 8rpx;
}
.detail-product {
display: block;
color: #162232;
font-size: 27rpx;
font-weight: 700;
line-height: 1.4;
}
.detail-meta {
display: block;
margin-top: 6rpx;
color: #667085;
font-size: 24rpx;
line-height: 1.4;
}
.detail-qty-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx 18rpx;
margin-top: 16rpx;
}
+518
View File
@@ -0,0 +1,518 @@
const api = require('../../utils/api')
const pad = value => {
const text = String(value)
return text.length > 1 ? text : `0${text}`
}
const parseDateValue = value => {
const text = String(value || '')
if (text.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(text)) {
return new Date(text)
}
return new Date(text.replace(/-/g, '/').replace('T', ' '))
}
const formatDate = value => {
const date = parseDateValue(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const formatTimeValue = value => {
const date = parseDateValue(value)
if (Number.isNaN(date.getTime())) {
return [0, 0, 0]
}
return [date.getHours(), date.getMinutes(), date.getSeconds()]
}
const formatTimeText = value => {
const parts = formatTimeValue(value)
return `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}`
}
const formatTimePartsText = parts => `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}`
const buildDateTimeValue = (date, timeValue) => (
date ? `${date}T${pad(timeValue[0])}:${pad(timeValue[1])}:${pad(timeValue[2])}` : ''
)
const timeOptions = [
Array.from({ length: 24 }, (_, index) => pad(index)),
Array.from({ length: 60 }, (_, index) => pad(index)),
Array.from({ length: 60 }, (_, index) => pad(index)),
]
const productKey = product => `${String(product.projectNo || '').trim()}||${String(product.productName || '').trim()}||${String(product.process || '').trim()}`
const blockDisplay = block => block.moldDisplayName || block.moldName || block.deviceNo
const productToItem = (product, startAt = '', availableOptions = null, equipmentOptions = [], deviceNo = '') => {
const selectedDeviceNo = deviceNo || ''
const deviceIndex = selectedDeviceNo
? Math.max(0, equipmentOptions.findIndex(item => item.deviceNo === selectedDeviceNo))
: -1
return {
productIndex: product ? 0 : -1,
attendancePointName: product ? product.attendancePointName : '',
deviceIndex,
deviceNo: selectedDeviceNo,
availableOptions: availableOptions || null,
startAt,
startAtText: startAt ? `${formatDate(startAt)} ${formatTimeText(startAt)}` : '',
projectNo: product ? product.projectNo : '',
productName: product ? product.productName : '',
materialCode: product ? product.materialCode : '',
materialName: product ? product.materialName : '',
rawMaterialBatchNo: '',
rawMaterialBatchOptions: [],
rawMaterialBatchIndex: -1,
rawMaterialBatchError: false,
process: product ? product.process : '',
stampingMethod: product ? product.stampingMethod : '',
operatorCount: product ? product.operatorCount : 1,
isMultiPerson: product ? !!product.isMultiPerson : false,
isMisc: product ? !!product.isMisc : false,
standardBeat: product ? product.standardBeat : 0,
standardWorkload: product ? product.standardWorkload : 0,
goodQty: product && product.isMisc ? 0 : '',
defectQty: '',
remark: '',
deviceError: false,
}
}
const REPORTING_LIMIT_MINUTES = 15
Page({
data: {
sessionId: '',
draft: null,
remainingText: '',
remainingSeconds: 0,
reminderShown: false,
confirmVisible: false,
confirmSummary: null,
submitting: false,
continuing: false,
addStartVisible: false,
addStartForm: null,
timeOptions,
},
onLoad(options) {
this.setData({ sessionId: options.sessionId || '' })
this.load()
},
onUnload() {
this.clearCountdown()
},
async load() {
try {
const draft = await api.buildReportDraft(this.data.sessionId)
this.setData({ draft }, () => {
this.showReportReminder()
this.startCountdown(draft)
})
} catch (error) {
if (!this.handleExpiredReport(error)) {
wx.showToast({ title: error.message, icon: 'none' })
}
}
},
handleExpiredReport(error) {
const message = error && error.message ? error.message : ''
if (message.indexOf('超过15分钟') === -1) {
return false
}
wx.showModal({
title: '报工已超时',
content: '请重新扫码点击重新报工,下班时间会更新为重新报工时间。',
showCancel: false,
success: () => {
wx.navigateBack({ delta: 1 })
},
})
return true
},
showReportReminder() {
if (this.data.reminderShown) {
return
}
this.setData({ reminderShown: true })
wx.showModal({
title: '填报提醒',
content: `报工填报时限为${REPORTING_LIMIT_MINUTES}分钟,请尽快完成填报`,
showCancel: false,
})
},
parseTime(value) {
if (!value) {
return 0
}
const text = String(value)
if (text.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(text)) {
return new Date(text).getTime()
}
return new Date(text.replace(/-/g, '/').replace('T', ' ')).getTime()
},
formatRemaining(seconds) {
const safeSeconds = Math.max(0, Number(seconds || 0))
const minutes = Math.floor(safeSeconds / 60)
const second = safeSeconds % 60
const minuteText = minutes < 10 ? `0${minutes}` : `${minutes}`
const secondText = second < 10 ? `0${second}` : `${second}`
return `${minuteText}:${secondText}`
},
clearCountdown() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer)
this.countdownTimer = null
}
},
startCountdown(draft) {
this.clearCountdown()
const endTime = this.parseTime(draft && draft.endAt)
if (!endTime) {
this.setData({ remainingText: '', remainingSeconds: 0 })
return
}
const deadline = endTime + REPORTING_LIMIT_MINUTES * 60 * 1000
const tick = () => {
const remainingSeconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
this.setData({
remainingSeconds,
remainingText: this.formatRemaining(remainingSeconds),
})
if (remainingSeconds <= 0) {
this.clearCountdown()
}
}
tick()
this.countdownTimer = setInterval(tick, 1000)
},
itemOptions(block, item) {
return item.availableOptions || block.options || []
},
onProductChange(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const productIndex = Number(e.detail.value)
const block = this.data.draft.deviceBlocks[blockIndex]
const currentItem = block.items[itemIndex] || {}
const options = this.itemOptions(block, currentItem)
const product = options[productIndex]
if (!product) {
return
}
const item = productToItem(
product,
currentItem.startAt || block.startAt || this.data.draft.startAt,
currentItem.availableOptions || null,
block.equipmentOptions || [],
currentItem.deviceNo,
)
item.productIndex = productIndex
item.deviceError = !!currentItem.deviceError && !item.deviceNo
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}]`]: item,
})
},
onDeviceChange(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const deviceIndex = Number(e.detail.value)
const equipment = this.data.draft.deviceBlocks[blockIndex].equipmentOptions[deviceIndex]
if (!equipment) {
return
}
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceIndex`]: deviceIndex,
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceNo`]: equipment.deviceNo,
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceError`]: false,
})
},
onRawMaterialBatchChange(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const batchIndex = Number(e.detail.value)
const item = this.data.draft.deviceBlocks[blockIndex].items[itemIndex]
const selected = (item.rawMaterialBatchOptions || [])[batchIndex]
if (!selected) {
return
}
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchIndex`]: batchIndex,
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchNo`]: selected,
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchError`]: false,
})
},
onQtyInput(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const field = e.currentTarget.dataset.field
const updates = {
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].${field}`]: e.detail.value,
}
if (field === 'changeoverCount') {
updates[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].changeoverCountError`] = false
}
this.setData(updates)
},
addItem(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const block = this.data.draft.deviceBlocks[blockIndex]
if (block.options.length < 2) {
return
}
const usedKeys = new Set(
(block.items || [])
.filter(item => item.productName)
.map(item => productKey(item)),
)
const availableOptions = (block.options || []).filter(option => !usedKeys.has(productKey(option)))
if (!availableOptions.length) {
wx.showToast({ title: '该模具没有可新增的产品', icon: 'none' })
return
}
const previousItem = block.items[block.items.length - 1] || {}
const defaultStartAt = previousItem.startAt || block.startAt || this.data.draft.startAt
this.setData({
addStartVisible: true,
addStartForm: {
blockIndex,
availableOptions,
previousStartAt: defaultStartAt,
date: formatDate(defaultStartAt),
timeValue: formatTimeValue(defaultStartAt),
timeText: formatTimeText(defaultStartAt),
},
})
},
onAddStartDateChange(e) {
this.setData({
'addStartForm.date': e.detail.value,
})
},
onAddStartTimeChange(e) {
const timeValue = e.detail.value.map(value => Number(value))
this.setData({
'addStartForm.timeValue': timeValue,
'addStartForm.timeText': formatTimePartsText(timeValue),
})
},
cancelAddStart() {
this.setData({
addStartVisible: false,
addStartForm: null,
})
},
confirmAddStart() {
const form = this.data.addStartForm
if (!form) {
return
}
const block = this.data.draft.deviceBlocks[form.blockIndex]
const startAt = buildDateTimeValue(form.date, form.timeValue)
const startTime = this.parseTime(startAt)
const blockStartTime = this.parseTime(block.startAt || this.data.draft.startAt)
const blockEndTime = this.parseTime(block.endAt || this.data.draft.endAt)
const previousStartTime = this.parseTime(form.previousStartAt)
if (!startTime || startTime < blockStartTime || startTime > blockEndTime) {
wx.showToast({ title: '开始时间需在本设备工作时间内', icon: 'none' })
return
}
if (previousStartTime && startTime < previousStartTime) {
wx.showToast({ title: '开始时间不能早于上一产品', icon: 'none' })
return
}
const item = productToItem(
form.availableOptions && form.availableOptions[0],
startAt,
form.availableOptions || [],
block.equipmentOptions || [],
)
const items = block.items.concat(item)
this.setData({
[`draft.deviceBlocks[${form.blockIndex}].items`]: items,
addStartVisible: false,
addStartForm: null,
})
},
removeItem(e) {
const blockIndex = e.currentTarget.dataset.blockIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const block = this.data.draft.deviceBlocks[blockIndex]
if (block.items.length <= 1) {
return
}
const items = block.items.filter((_, index) => index !== itemIndex)
this.setData({
[`draft.deviceBlocks[${blockIndex}].items`]: items,
})
},
noop() {},
validateDraft(draft) {
const blocks = draft.deviceBlocks || []
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex += 1) {
const block = blocks[blockIndex]
const items = block.items || []
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
const item = items[itemIndex]
if (item.isMisc) {
continue
}
if ((block.options || []).length > 1 && (!item.productName || item.productIndex < 0)) {
wx.showToast({
title: `${blockDisplay(block)}缺少产品信息`,
icon: 'none',
})
return false
}
if (!item.deviceNo) {
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].deviceError`]: true,
})
wx.showToast({
title: `${blockDisplay(block)}请选择设备号`,
icon: 'none',
})
return false
}
if (!item.startAt) {
wx.showToast({
title: `${blockDisplay(block)}请选择开始工作时间`,
icon: 'none',
})
return false
}
if ((item.rawMaterialBatchOptions || []).length && !item.rawMaterialBatchNo) {
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].rawMaterialBatchError`]: true,
})
wx.showToast({
title: `${blockDisplay(block)}请选择材料库存批次号`,
icon: 'none',
})
return false
}
if (item.isContinuousDie) {
const text = String(item.changeoverCount || '').trim()
const value = Number(text)
if (!text || !Number.isInteger(value) || value < 0) {
this.setData({
[`draft.deviceBlocks[${blockIndex}].items[${itemIndex}].changeoverCountError`]: true,
})
wx.showToast({
title: `${blockDisplay(block)}请填写非负整数换料次数`,
icon: 'none',
})
return false
}
}
const goodQty = Number(item.goodQty || 0)
const defectQty = Number(item.defectQty || 0)
if (!Number.isFinite(goodQty) || goodQty < 0 || !Number.isFinite(defectQty) || defectQty < 0) {
wx.showToast({
title: `${blockDisplay(block)}产出数量不能为负数`,
icon: 'none',
})
return false
}
}
}
return true
},
buildConfirmSummary(draft) {
const totalGood = draft.deviceBlocks.reduce((sum, block) => (
sum + block.items.reduce((inner, item) => inner + (item.isMisc ? 0 : Number(item.goodQty || 0)), 0)
), 0)
const totalDefect = draft.deviceBlocks.reduce((sum, block) => (
sum + block.items.reduce((inner, item) => inner + (item.isMisc ? 0 : Number(item.defectQty || 0)), 0)
), 0)
const hasMisc = draft.deviceBlocks.some(block => (block.items || []).some(item => item.isMisc))
return {
deviceText: draft.deviceBlocks.map(block => blockDisplay(block)).join('、'),
totalGood,
totalDefect,
note: hasMisc ? '包含处理杂活,提交后由管理员补充杂活事项' : '提交后进入管理员审核',
}
},
submit() {
if (this.data.continuing) {
return
}
const draft = this.data.draft
if (!this.validateDraft(draft)) {
return
}
this.setData({
confirmVisible: true,
confirmSummary: this.buildConfirmSummary(draft),
})
},
cancelConfirm() {
if (this.data.submitting) {
return
}
this.setData({ confirmVisible: false })
},
async confirmSubmit() {
if (this.data.submitting || this.data.continuing) {
return
}
this.setData({ submitting: true })
wx.showLoading({ title: '提交中' })
try {
const report = await api.submitReport(this.data.draft)
this.setData({ confirmVisible: false })
wx.reLaunch({
url: `/pages/reportResult/reportResult?reportId=${report.id}`,
})
} catch (error) {
if (!this.handleExpiredReport(error)) {
wx.showToast({ title: error.message, icon: 'none' })
}
} finally {
wx.hideLoading()
this.setData({ submitting: false })
}
},
continueWork() {
if (this.data.submitting || this.data.continuing) {
return
}
wx.showModal({
title: '继续上班',
content: '确认取消本次下班报工填报,继续保持上班状态?当前填写内容不会保存。',
confirmText: '继续上班',
cancelText: '返回填写',
success: async res => {
if (!res.confirm) {
return
}
this.setData({ continuing: true })
wx.showLoading({ title: '处理中' })
try {
await api.continueWork(this.data.sessionId)
this.clearCountdown()
wx.showToast({ title: '已继续上班', icon: 'success' })
setTimeout(() => {
wx.reLaunch({
url: '/pages/index/index',
})
}, 500)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
this.setData({ continuing: false })
}
},
})
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "下班报工"
}
+269
View File
@@ -0,0 +1,269 @@
<scroll-view class="page safe-bottom" scroll-y type="list">
<view class="header">
<view class="report-title-row">
<view class="report-title-main">
<text class="title">下班报工</text>
<text class="subtitle">按模具和设备填写产量,提交后进入审核</text>
</view>
<view wx:if="{{remainingText}}" class="countdown-pill">
<text class="countdown-label">剩余</text>
<text class="countdown-value">{{remainingText}}</text>
</view>
</view>
</view>
<block wx:if="{{draft}}">
<view class="card">
<view class="row">
<view>
<text class="label">上班打卡时间</text>
<text class="value">{{draft.startAtText}}</text>
</view>
<view class="text-right">
<text class="label">下班打卡时间</text>
<text class="value">{{draft.endAtText}}</text>
</view>
</view>
<view class="divider"></view>
<view class="row">
<text class="muted">考勤点</text>
<text class="value">{{draft.attendancePointName || '-'}}</text>
</view>
<view class="divider"></view>
<view class="row">
<text class="muted">报工时长</text>
<text class="value">{{draft.durationMinutes}} 分钟</text>
</view>
</view>
<view
wx:for="{{draft.deviceBlocks}}"
wx:key="blockKey"
wx:for-index="blockIndex"
wx:for-item="block"
class="card device-card"
>
<view class="row device-head">
<view>
<text class="label">模具名称</text>
<text class="value">{{block.moldDisplayName || block.moldName || block.deviceNo}}</text>
<text wx:if="{{block.attendancePointName}}" class="subtitle">考勤点 {{block.attendancePointName}}</text>
<text class="subtitle">累计工时 {{block.durationMinutes || 0}} 分钟</text>
</view>
<button
wx:if="{{block.canAddItem}}"
class="btn mini secondary add-product-btn"
data-block-index="{{blockIndex}}"
bindtap="addItem"
>
+
</button>
</view>
<view wx:if="{{!block.options.length}}" class="list-empty">该模具暂无产品</view>
<view
wx:for="{{block.items}}"
wx:key="index"
wx:for-index="itemIndex"
wx:for-item="reportItem"
class="item-box {{reportItem.isMultiPerson || reportItem.isContinuousDie ? 'multi-item-box' : ''}} {{reportItem.isMultiPerson && reportItem.isContinuousDie ? 'stacked-item-box' : ''}}"
>
<text wx:if="{{reportItem.isMultiPerson}}" class="multi-item-stamp">多人协作</text>
<text wx:if="{{reportItem.isContinuousDie}}" class="continuous-item-stamp {{reportItem.isMultiPerson ? 'lower-item-stamp' : ''}}">连续模</text>
<view class="row">
<text class="pill primary">报工 {{itemIndex + 1}}</text>
<button
wx:if="{{block.items.length > 1}}"
class="btn mini secondary"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
bindtap="removeItem"
>
删除
</button>
</view>
<view class="form-item">
<text class="label">开始工作时间</text>
<view class="readonly">{{reportItem.startAtText || '-'}}</view>
</view>
<view wx:if="{{reportItem.isMisc}}" class="misc-auto-box">
<text class="label">处理杂活</text>
<text class="value">系统自动生成该报工卡片,只记录本段工时,杂活事项由管理员审核时备注。</text>
<text class="subtitle">累计工时 {{block.durationMinutes || 0}} 分钟</text>
</view>
<view wx:if="{{!reportItem.isMisc}}" class="form-item">
<text class="label">设备号</text>
<picker
wx:if="{{block.equipmentOptions.length}}"
mode="selector"
range="{{block.equipmentOptions}}"
range-key="displayName"
value="{{reportItem.deviceIndex}}"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
bindchange="onDeviceChange"
>
<view class="picker {{reportItem.deviceError ? 'field-error' : ''}}">{{reportItem.deviceNo || '请选择设备号'}}</view>
</picker>
<view wx:else class="readonly {{reportItem.deviceError ? 'field-error' : ''}}">暂无可选设备</view>
</view>
<view wx:if="{{!reportItem.isMisc}}" class="form-item">
<text class="label">材料库存批次号</text>
<picker
wx:if="{{reportItem.rawMaterialBatchOptions.length}}"
mode="selector"
range="{{reportItem.rawMaterialBatchOptions}}"
value="{{reportItem.rawMaterialBatchIndex}}"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
bindchange="onRawMaterialBatchChange"
>
<view class="picker {{reportItem.rawMaterialBatchError ? 'field-error' : ''}}">{{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}}</view>
</picker>
<view wx:else class="readonly">暂无可选材料库存批次号</view>
</view>
<view wx:if="{{reportItem.isContinuousDie}}" class="form-item">
<text class="label">换料次数</text>
<input
class="input number-input {{reportItem.changeoverCountError ? 'field-error' : ''}}"
type="number"
value="{{reportItem.changeoverCount}}"
placeholder="请输入换料次数"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
data-field="changeoverCount"
bindinput="onQtyInput"
/>
</view>
<view wx:if="{{!reportItem.isMisc}}" class="readonly-grid">
<view>
<text class="label">产品名称</text>
<text class="value">{{reportItem.productName || '-'}}</text>
</view>
<view>
<text class="label">考勤点</text>
<text class="value">{{reportItem.attendancePointName || block.attendancePointName || '-'}}</text>
</view>
<view>
<text class="label">项目号</text>
<text class="value">{{reportItem.projectNo || '-'}}</text>
</view>
<view>
<text class="label">物料编码</text>
<text class="value">{{reportItem.materialCode || '-'}}</text>
</view>
<view>
<text class="label">工序</text>
<text class="value">{{reportItem.process || '-'}}</text>
</view>
<view>
<text class="label">冲压方式</text>
<text class="value">{{reportItem.stampingMethod || '-'}}</text>
</view>
<view>
<text class="label">标准节拍</text>
<text class="value">{{reportItem.standardBeat || 0}} 秒</text>
</view>
</view>
<view wx:if="{{!reportItem.isMisc}}" class="qty-grid">
<view class="form-item">
<text class="label">成品数量</text>
<input
class="input number-input"
type="number"
value="{{reportItem.goodQty}}"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
data-field="goodQty"
bindinput="onQtyInput"
/>
</view>
<view class="form-item">
<text class="label">不良数量</text>
<input
class="input number-input"
type="number"
value="{{reportItem.defectQty}}"
data-block-index="{{blockIndex}}"
data-item-index="{{itemIndex}}"
data-field="defectQty"
bindinput="onQtyInput"
/>
</view>
</view>
</view>
</view>
<button class="btn primary full submit-btn" disabled="{{continuing}}" bindtap="submit">提交报工</button>
<button
class="btn secondary full continue-work-btn"
disabled="{{submitting || continuing}}"
bindtap="continueWork"
>
{{continuing ? '处理中' : '继续上班'}}
</button>
</block>
</scroll-view>
<view wx:if="{{confirmVisible}}" class="confirm-mask" catchtap="cancelConfirm">
<view class="confirm-panel" catchtap="noop">
<text class="confirm-title">确认提交报工</text>
<view class="confirm-lines">
<view class="confirm-line">
<text class="confirm-label">模具</text>
<text class="confirm-value">{{confirmSummary.deviceText}}</text>
</view>
<view class="confirm-line">
<text class="confirm-label">成品数量</text>
<text class="confirm-value">{{confirmSummary.totalGood}}</text>
</view>
<view class="confirm-line">
<text class="confirm-label">不良数量</text>
<text class="confirm-value">{{confirmSummary.totalDefect}}</text>
</view>
<view class="confirm-line">
<text class="confirm-label">审核说明</text>
<text class="confirm-value">{{confirmSummary.note}}</text>
</view>
</view>
<view class="confirm-actions">
<button class="btn secondary confirm-btn" bindtap="cancelConfirm">取消</button>
<button class="btn primary confirm-btn" disabled="{{submitting}}" bindtap="confirmSubmit">{{submitting ? '提交中' : '确定'}}</button>
</view>
</view>
</view>
<view wx:if="{{addStartVisible}}" class="confirm-mask" catchtap="cancelAddStart">
<view class="confirm-panel add-start-panel" catchtap="noop">
<text class="confirm-title">新增产品开始时间</text>
<view class="form-item">
<text class="label">开始日期</text>
<picker mode="date" value="{{addStartForm.date}}" bindchange="onAddStartDateChange">
<view class="picker">{{addStartForm.date}}</view>
</picker>
</view>
<view class="form-item">
<text class="label">开始时间</text>
<picker
mode="multiSelector"
range="{{timeOptions}}"
value="{{addStartForm.timeValue}}"
bindchange="onAddStartTimeChange"
>
<view class="picker">{{addStartForm.timeText}}</view>
</picker>
</view>
<view class="confirm-actions">
<button class="btn secondary confirm-btn" bindtap="cancelAddStart">取消</button>
<button class="btn primary confirm-btn" bindtap="confirmAddStart">确定新增</button>
</view>
</view>
</view>
+290
View File
@@ -0,0 +1,290 @@
.report-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.report-title-main {
min-width: 0;
flex: 1;
}
.countdown-pill {
flex: 0 0 auto;
min-width: 132rpx;
padding: 12rpx 16rpx;
border: 1rpx solid #ffd9a8;
border-radius: 16rpx;
background: #fff7e8;
box-sizing: border-box;
text-align: center;
}
.countdown-label {
display: block;
color: #a95605;
font-size: 20rpx;
font-weight: 700;
line-height: 1.1;
}
.countdown-value {
display: block;
margin-top: 6rpx;
color: #7a3d00;
font-size: 30rpx;
font-weight: 800;
line-height: 1.1;
}
.device-card {
padding-bottom: 8rpx;
border-left: 6rpx solid #1463ff;
}
.device-head {
margin-bottom: 18rpx;
}
.btn.mini.add-product-btn {
display: flex;
align-items: center;
justify-content: center;
width: 78rpx;
min-width: 78rpx;
height: 78rpx;
line-height: 1;
padding: 0;
border-radius: 20rpx;
font-size: 68rpx;
font-weight: 700;
}
.item-box {
position: relative;
margin-top: 18rpx;
padding: 22rpx 18rpx 18rpx;
border: 1rpx solid #dce4ef;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
box-shadow:
0 10rpx 0 rgba(205, 216, 229, 0.5),
0 16rpx 24rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
}
.item-box.multi-item-box {
padding-top: 72rpx;
}
.item-box.stacked-item-box {
padding-top: 104rpx;
}
.multi-item-stamp {
position: absolute;
right: 18rpx;
top: 18rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
}
.continuous-item-stamp {
position: absolute;
right: 18rpx;
top: 18rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
}
.lower-item-stamp {
top: 60rpx;
}
.readonly {
min-height: 78rpx;
display: flex;
align-items: center;
padding: 0 20rpx;
border: 1rpx solid #ccd8e6;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow:
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
0 6rpx 0 rgba(205, 216, 229, 0.45),
0 10rpx 18rpx rgba(22, 34, 50, 0.06);
box-sizing: border-box;
}
.picker.field-error,
.readonly.field-error {
border-color: #e5484d;
background: linear-gradient(180deg, #fff7f7 0%, #fff1f1 100%);
color: #b42318;
box-shadow:
inset 0 4rpx 8rpx rgba(180, 35, 24, 0.08),
0 0 0 4rpx rgba(229, 72, 77, 0.12),
0 6rpx 0 rgba(255, 197, 197, 0.65),
0 10rpx 18rpx rgba(180, 35, 24, 0.08);
}
.readonly-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx 20rpx;
margin: 20rpx 0;
}
.qty-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.misc-auto-box {
display: flex;
flex-direction: column;
gap: 10rpx;
margin-top: 16rpx;
padding: 20rpx;
border: 2rpx solid #f3b35b;
border-radius: 16rpx;
background: linear-gradient(180deg, #fffaf1 0%, #fff3df 100%);
box-shadow:
0 8rpx 0 rgba(220, 139, 34, 0.32),
inset 0 2rpx 0 rgba(255, 255, 255, 0.86);
}
.submit-btn {
margin-top: 30rpx;
height: 92rpx;
line-height: 92rpx;
font-size: 32rpx;
}
.continue-work-btn {
margin: 28rpx auto 16rpx;
width: 430rpx;
height: 78rpx;
line-height: 78rpx;
color: #1463ff;
font-size: 28rpx;
font-weight: 800;
}
.add-start-panel .form-item {
margin-top: 18rpx;
}
.confirm-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 44rpx;
background: rgba(15, 23, 42, 0.52);
box-sizing: border-box;
}
.confirm-panel {
width: 100%;
overflow: hidden;
padding: 44rpx 34rpx 40rpx;
border: 1rpx solid #e4e9f1;
border-radius: 28rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.64),
0 30rpx 64rpx rgba(15, 23, 42, 0.28),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.confirm-title {
display: block;
margin-bottom: 28rpx;
color: #162232;
font-size: 36rpx;
font-weight: 800;
line-height: 1.25;
text-align: center;
}
.confirm-lines {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 22rpx;
border: 1rpx solid #dce4ef;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow:
inset 0 4rpx 8rpx rgba(22, 34, 50, 0.05),
0 8rpx 0 rgba(205, 216, 229, 0.48),
0 12rpx 18rpx rgba(22, 34, 50, 0.06);
}
.confirm-line {
display: grid;
grid-template-columns: 150rpx minmax(0, 1fr);
gap: 18rpx;
align-items: flex-start;
}
.confirm-label {
color: #667085;
font-size: 26rpx;
font-weight: 700;
line-height: 1.45;
}
.confirm-value {
min-width: 0;
color: #162232;
font-size: 28rpx;
font-weight: 700;
line-height: 1.45;
word-break: break-all;
}
.confirm-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 30rpx;
padding: 0 2rpx 12rpx;
box-sizing: border-box;
}
.confirm-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
width: 100%;
height: 80rpx;
margin: 0;
padding: 0;
line-height: 1.15;
box-sizing: border-box;
}
+110
View File
@@ -0,0 +1,110 @@
const api = require('../../utils/api')
Page({
data: {
report: null,
},
async onLoad(options) {
try {
const report = await api.getReport(options.reportId)
this.setData({
report: {
...report,
itemResults: this.buildItemResults(report),
},
})
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
round1(value) {
return Math.round(Number(value || 0) * 10) / 10
},
round2(value) {
return Math.round(Number(value || 0) * 100) / 100
},
buildCompareText(rate, fastLabel, slowLabel) {
return `${rate > 0 ? slowLabel : fastLabel}${Math.abs(this.round1(rate))}%`
},
buildWorkloadText(rate) {
return `${rate >= 0 ? '工作量大于标准工作量' : '工作量小于标准工作量'}${Math.abs(this.round1(rate))}%`
},
buildItemResults(report) {
return (report.items || []).map((item, index) => {
if (item.isMisc) {
return {
key: `${item.id || index}`,
title: '处理杂活',
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
goodQty: 0,
defectQty: 0,
workTimeText: item.workTimeText || '-',
resultText: '处理杂活工时已提交管理员审核,杂活事项由管理员备注留痕',
goodResult: true,
isMisc: true,
isMultiPerson: !!item.isMultiPerson,
isContinuousDie: !!item.isContinuousDie,
changeoverCount: item.changeoverCount || 0,
}
}
if (item.isCleaning) {
return {
key: `${item.id || index}`,
title: `清洗 · ${item.displayName || item.productName || '-'}`,
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 项目 ${item.projectNo || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
goodQty: this.round2(item.goodQty || 0),
defectQty: 0,
resultText: `清洗数量 ${this.round2(item.goodQty || 0)},已提交管理员审核`,
goodResult: true,
isCleaning: true,
isMultiPerson: !!item.isMultiPerson,
isContinuousDie: !!item.isContinuousDie,
changeoverCount: item.changeoverCount || 0,
}
}
const goodQty = Number(item.goodQty || 0)
const defectQty = Number(item.defectQty || 0)
const outputQty = goodQty + defectQty
const actualBeat = outputQty > 0 ? Number(item.allocatedMinutes || 0) * 60 / outputQty : 0
const standardBeat = Number(item.standardBeat || 0)
const standardWorkload = api.calculateStandardWorkload(item.allocatedMinutes, standardBeat) || Number(item.standardWorkload || 0)
const paceRate = standardBeat > 0 ? (actualBeat - standardBeat) / standardBeat * 100 : 0
const workloadRate = standardWorkload > 0 ? (goodQty - standardWorkload) / standardWorkload * 100 : 0
const paceText = this.buildCompareText(paceRate, '节拍快于标准节拍', '节拍慢于标准节拍')
const workloadText = this.buildWorkloadText(workloadRate)
const overStandardAbnormal = paceRate < -30 || workloadRate > 30
const underStandardAbnormal = paceRate > 0 || workloadRate < 0
const abnormal = overStandardAbnormal || underStandardAbnormal
const goodResult = !abnormal
const suffix = overStandardAbnormal
? '数据明显超出标准,请自我核对一下是不是报工时填错。'
: (goodResult ? '今天真的很棒呢!' : '后续需要加油喽!')
return {
key: `${item.id || index}`,
title: `${item.deviceNo || '-'} · ${item.displayName || item.productName || '-'}`,
subtitle: `考勤点 ${item.attendancePointName || report.attendancePointName || '-'} · 项目 ${item.projectNo || '-'} · 工序 ${item.process || '-'} · 冲压方式 ${item.stampingMethod || '-'}`,
actualBeat: this.round2(actualBeat),
standardBeat: this.round2(standardBeat),
goodQty: this.round2(goodQty),
defectQty: this.round2(defectQty),
standardWorkload: this.round1(standardWorkload),
resultText: `今日${paceText},今日${workloadText},${suffix}`,
goodResult,
overStandardAbnormal: abnormal,
isMultiPerson: !!item.isMultiPerson,
isContinuousDie: !!item.isContinuousDie,
changeoverCount: item.changeoverCount || 0,
}
})
},
goRecords() {
wx.redirectTo({
url: '/pages/records/records',
})
},
goHome() {
wx.reLaunch({
url: '/pages/index/index',
})
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationStyle": "custom",
"disableSwipeBack": true
}
+74
View File
@@ -0,0 +1,74 @@
<view class="page safe-bottom result-page">
<view class="result-nav">
<text class="result-nav-title">报工结果</text>
</view>
<view class="header">
<text class="title">报工成功</text>
<text class="subtitle">已提交管理员审核</text>
</view>
<block wx:if="{{report}}">
<view class="card">
<text wx:if="{{report.hasMisc}}" class="misc-watermark">包含杂活</text>
<text wx:if="{{report.hasMultiPerson}}" class="multi-person-watermark {{report.hasMisc ? 'lower-watermark' : ''}}">多人协作</text>
<text wx:if="{{report.hasContinuousDie}}" class="continuous-die-watermark {{report.hasMisc || report.hasMultiPerson ? 'lower-watermark' : ''}} {{report.hasMisc && report.hasMultiPerson ? 'third-watermark' : ''}}">连续模</text>
<text class="label">报工日期</text>
<text class="value">{{report.reportDate}}</text>
<text class="subtitle">考勤点 {{report.attendancePointName || '-'}}</text>
<text class="subtitle">{{report.metrics.shiftDistributionText}}</text>
<view class="divider"></view>
<view
wx:for="{{report.itemResults}}"
wx:key="key"
wx:for-item="result"
class="result-item {{result.goodResult ? 'good' : (result.overStandardAbnormal ? 'danger' : 'warn')}} {{result.isMultiPerson || result.isContinuousDie ? 'marked-result' : ''}} {{result.isMultiPerson && result.isContinuousDie ? 'stacked-result' : ''}}"
>
<text wx:if="{{result.isMultiPerson}}" class="result-multi-stamp">多人协作</text>
<text wx:if="{{result.isContinuousDie}}" class="result-continuous-stamp {{result.isMultiPerson ? 'lower-result-stamp' : ''}}">连续模</text>
<text class="result-title">{{result.title}}</text>
<text class="result-subtitle">{{result.subtitle}}</text>
<text class="result-text">{{result.resultText}}</text>
<view wx:if="{{result.isMisc}}" class="result-metrics">
<view>
<text class="label">工时</text>
<text class="value">{{result.workTimeText}}</text>
</view>
</view>
<view wx:elif="{{result.isCleaning}}" class="result-metrics">
<view>
<text class="label">清洗数量</text>
<text class="value">{{result.goodQty}}</text>
</view>
</view>
<view wx:else class="result-metrics">
<view>
<text class="label">实际节拍</text>
<text class="value">{{result.actualBeat}} 秒/件</text>
</view>
<view>
<text class="label">标准节拍</text>
<text class="value">{{result.standardBeat}} 秒/件</text>
</view>
<view>
<text class="label">成品数量</text>
<text class="value">{{result.goodQty}}</text>
</view>
<view>
<text class="label">标准工作量</text>
<text class="value">{{result.standardWorkload}}</text>
</view>
<view wx:if="{{result.isContinuousDie}}">
<text class="label">换料次数</text>
<text class="value">{{result.changeoverCount || 0}}</text>
</view>
</view>
</view>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="goRecords">查看记录</button>
<button class="btn primary full" bindtap="goHome">返回首页</button>
</view>
</block>
</view>
+125
View File
@@ -0,0 +1,125 @@
.result-page {
padding-top: 0;
}
.result-nav {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
height: 176rpx;
margin: 0 -24rpx 22rpx;
padding-top: 88rpx;
border-bottom: 1rpx solid rgba(213, 222, 235, 0.9);
background: rgba(247, 248, 250, 0.96);
box-sizing: border-box;
}
.result-nav-title {
color: #111827;
font-size: 34rpx;
font-weight: 700;
line-height: 1;
}
.result-item {
position: relative;
display: block;
margin-top: 18rpx;
padding: 20rpx;
border-radius: 16rpx;
box-sizing: border-box;
}
.result-item.marked-result {
padding-top: 62rpx;
}
.result-item.stacked-result {
padding-top: 104rpx;
}
.result-item .result-multi-stamp {
position: absolute;
right: 16rpx;
top: 14rpx;
padding: 6rpx 12rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 22rpx;
font-weight: 900;
line-height: 1;
}
.result-continuous-stamp {
position: absolute;
right: 16rpx;
top: 14rpx;
padding: 6rpx 12rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 22rpx;
font-weight: 900;
line-height: 1;
}
.result-continuous-stamp.lower-result-stamp {
top: 56rpx;
}
.result-item.good {
border: 1rpx solid #bfe8d4;
background: #eefaf4;
color: #087443;
}
.result-item.warn {
border: 1rpx solid #f7d7a8;
background: #fff7e8;
color: #9a4a05;
}
.result-item.danger {
border: 1rpx solid #f1b4b2;
background: #fff1f0;
color: #b42318;
}
.result-title {
display: block;
color: #162232;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.result-subtitle {
display: block;
margin-top: 8rpx;
color: #667085;
font-size: 24rpx;
font-weight: 600;
line-height: 1.4;
}
.result-text {
display: block;
margin-top: 16rpx;
font-size: 32rpx;
font-weight: 700;
line-height: 1.55;
}
.result-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx 18rpx;
margin-top: 18rpx;
padding-top: 18rpx;
border-top: 1rpx solid rgba(102, 112, 133, 0.16);
}
+871
View File
@@ -0,0 +1,871 @@
const api = require('../../utils/api')
const pad = value => {
const text = String(value)
return text.length > 1 ? text : `0${text}`
}
const datePart = value => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
const timePart = value => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const dateTimeValue = (date, time) => (date && time ? `${date}T${time}:00` : '')
const productChoiceKey = item => `${item.attendancePointName || ''}||${item.projectNo}||${item.productName}`
const optionKey = item => `${item.attendancePointName || ''}||${item.projectNo}||${item.productName}||${item.process || ''}||${api.normalizeDeviceNo(item.deviceNo)}`
const segmentKey = item => `${api.normalizeDeviceNo(item.moldName || item.productName || item.deviceNo)}||${String(item.process || '').trim()}`
const round2 = value => Math.round(Number(value || 0) * 100) / 100
const withCompareClasses = item => {
const standardWorkload = item.isCleaning
? 0
: (item.isMisc ? 0 : (api.calculateStandardWorkload(item.allocatedMinutes, item.standardBeat) || Number(item.standardWorkload || 0)))
const beatComparison = (item.isCleaning || item.isMisc) ? api.classifyBeat(0, 0) : api.classifyBeat(item.actualBeat, item.standardBeat)
const workloadComparison = (item.isCleaning || item.isMisc) ? api.classifyWorkload(0, 0) : api.classifyWorkload(item.goodQty, standardWorkload)
return {
...item,
standardWorkload,
beatCompareClass: beatComparison.className,
beatReason: beatComparison.reason,
workloadCompareClass: workloadComparison.className,
workloadReason: workloadComparison.reason,
}
}
const correctionOrCurrent = (corrections, key, currentValue) => (
corrections && corrections[key] !== undefined ? corrections[key] : currentValue
)
const itemActualBeat = item => {
const outputQty = Number(item.goodQty || 0) + Number(item.defectQty || 0)
return outputQty > 0 ? round2(Number(item.allocatedMinutes || 0) * 60 / outputQty) : 0
}
Page({
data: {
user: null,
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
focusReportId: '',
refreshing: false,
scrollTop: 0,
expandedReportId: '',
detailExpandedAll: false,
filterVoided: true,
productModalVisible: false,
productModalRows: [],
productModalKeyword: '',
productModalPage: 1,
productModalPageInput: '1',
productModalTotalPages: 1,
productModalContext: null,
productModalLoading: false,
},
onLoad(options) {
this.setData({ focusReportId: options.reportId || '' })
},
onShow() {
this.load()
},
noop() {},
onScroll(e) {
this.currentScrollTop = e.detail.scrollTop || 0
},
toggleDetails() {
this.setData({
detailExpandedAll: !this.data.detailExpandedAll,
expandedReportId: '',
})
},
queryReportTop(id) {
return new Promise(resolve => {
this.createSelectorQuery()
.select(`#review-report-${id}`)
.boundingClientRect(rect => {
resolve(rect ? rect.top : null)
})
.exec()
})
},
restoreReportPosition(id, beforeTop) {
if (beforeTop === null || beforeTop === undefined) {
return
}
wx.nextTick(() => {
this.queryReportTop(id).then(afterTop => {
if (afterTop === null || afterTop === undefined) {
return
}
const nextScrollTop = Math.max(
0,
(this.currentScrollTop || 0) + afterTop - beforeTop,
)
this.currentScrollTop = nextScrollTop
this.setData({ scrollTop: nextScrollTop })
})
})
},
toggleReportDetail(e) {
const id = String(e.currentTarget.dataset.id || '')
const switchingReport = this.data.detailExpandedAll
|| (!!this.data.expandedReportId && this.data.expandedReportId !== id)
this.queryReportTop(id).then(beforeTop => {
this.setData({
detailExpandedAll: false,
expandedReportId: this.data.expandedReportId === id ? '' : id,
}, () => {
if (switchingReport) {
this.restoreReportPosition(id, beforeTop)
}
})
})
},
async load(page = this.data.page) {
const user = api.getCurrentUser()
try {
let result
if (this.data.focusReportId) {
const report = await api.getReport(this.data.focusReportId)
result = {
rows: (report.status === 'pending' || report.canEdit || report.isVoided) ? [report] : [],
page: 1,
totalPages: 1,
}
} else {
result = await api.listReports({
status: 'pending',
page,
pageSize: 6,
includeVoided: !this.data.filterVoided,
})
}
const rows = await this.buildReviewRows(result.rows || [])
const defaultExpandedId = this.data.focusReportId && rows.length ? String(rows[0].id) : ''
this.setData({
user,
rows,
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
expandedReportId: defaultExpandedId,
detailExpandedAll: false,
})
} catch (error) {
this.setData({ user, rows: [] })
wx.showToast({ title: error.message, icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(this.data.page)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
async buildReviewRows(rows) {
const hydrated = []
for (const row of rows) {
const editItems = []
const editDeviceSegments = (row.deviceSegments || []).map((segment, index) => this.makeEditDeviceSegment(segment, index))
for (const detail of row.items || []) {
const editItem = await this.buildReviewItem(detail)
const itemKey = segmentKey(editItem)
editItem.deviceSegments = editDeviceSegments.filter(segment => segmentKey(segment) === itemKey)
editItems.push(editItem)
}
hydrated.push({
...row,
timeRangeText: row.isCleaning ? '清洗报工' : `${row.startAtText} 至 ${row.endAtText}`,
editStartDate: datePart(row.startAt),
editStartTime: timePart(row.startAt),
editEndDate: datePart(row.endAt),
editEndTime: timePart(row.endAt),
editReviewRemark: row.reviewRemark || '',
editItems,
})
}
return hydrated
},
async buildReviewItem(detail) {
const [products, equipmentOptions] = await Promise.all([
this.loadProductOptions(detail.productName, detail.attendancePointName),
detail.isMisc ? Promise.resolve([]) : this.loadEquipmentOptions(detail.attendancePointName, !!detail.isCleaning),
])
return this.makeEditItem(detail, products, equipmentOptions)
},
async loadProductOptions(productName, attendancePointName = '', keyword = '') {
try {
const result = await api.listProducts({
productName: keyword ? '' : productName,
keyword,
attendancePointName,
page: 1,
pageSize: 100,
})
return result.rows || []
} catch (error) {
return []
}
},
async loadEquipmentOptions(attendancePointName = '', isCleaning = false) {
try {
const result = await api.listEquipment({
attendancePointName,
deviceType: isCleaning ? '清洗设备' : '冲压设备',
page: 1,
pageSize: 100,
})
return result.rows || []
} catch (error) {
return []
}
},
makeEditItem(detail, products, equipmentOptions = []) {
const corrections = detail.corrections || {}
const current = {
attendancePointName: detail.attendancePointName || '',
projectNo: detail.projectNo,
productName: detail.productName,
process: detail.process || '',
stampingMethod: detail.stampingMethod || '',
deviceNo: detail.deviceNo,
materialCode: detail.materialCode || '',
materialName: detail.materialName || '',
rawMaterialBatchNo: detail.rawMaterialBatchNo || '',
standardBeat: detail.standardBeat || 0,
standardWorkload: detail.standardWorkload || 0,
isCleaning: !!detail.isCleaning,
isContinuousDie: !!detail.isContinuousDie,
isMisc: !!detail.isMisc,
isMultiPerson: !!detail.isMultiPerson,
operatorCount: detail.operatorCount || 1,
changeoverCount: detail.changeoverCount || 0,
remark: detail.remark || '',
}
const originalProduct = {
attendancePointName: current.attendancePointName,
projectNo: correctionOrCurrent(corrections, 'project_no', current.projectNo),
productName: correctionOrCurrent(corrections, 'product_name', current.productName),
process: correctionOrCurrent(corrections, 'process_name', current.process) || '',
materialCode: current.materialCode,
materialName: current.materialName,
stampingMethod: correctionOrCurrent(corrections, 'stamping_method', current.stampingMethod) || '',
operatorCount: current.operatorCount,
standardBeat: current.standardBeat,
isCleaning: current.isCleaning,
isContinuousDie: current.isContinuousDie,
isMisc: current.isMisc,
isMultiPerson: current.isMultiPerson,
}
const optionMap = {}
;(products || []).forEach(product => {
optionMap[optionKey(product)] = product
})
optionMap[optionKey(current)] = current
const options = Object.keys(optionMap).map(key => optionMap[key])
const productMap = {}
options.forEach(product => {
productMap[productChoiceKey(product)] = {
attendancePointName: product.attendancePointName || current.attendancePointName,
projectNo: product.projectNo,
productName: product.productName,
}
})
const productChoices = Object.keys(productMap).map(key => productMap[key])
const productLabels = productChoices.map(item => `${item.projectNo} · ${item.productName}`)
let productIndex = productChoices.findIndex(item => item.projectNo === current.projectNo && item.productName === current.productName)
if (productIndex < 0) {
productIndex = 0
}
const selectedProduct = productChoices[productIndex] || current
const processOptions = options.filter(item => (
item.attendancePointName === selectedProduct.attendancePointName
&& item.projectNo === selectedProduct.projectNo
&& item.productName === selectedProduct.productName
))
const processLabels = processOptions.map(item => item.process || '未填写工序')
let processIndex = processOptions.findIndex(item => (item.process || '') === current.process)
if (processIndex < 0) {
processIndex = 0
}
const selectedOption = processOptions[processIndex] || current
const selectedDeviceNo = api.normalizeDeviceNo(detail.deviceNo)
const deviceIndex = selectedDeviceNo
? equipmentOptions.findIndex(item => api.normalizeDeviceNo(item.deviceNo) === selectedDeviceNo)
: -1
return withCompareClasses({
id: detail.id,
attendancePointName: current.attendancePointName,
startAt: detail.startAt || '',
isCleaning: !!detail.isCleaning,
isContinuousDie: !!selectedOption.isContinuousDie,
isMisc: !!detail.isMisc,
isMultiPerson: !!selectedOption.isMultiPerson,
operatorCount: selectedOption.operatorCount || detail.operatorCount || 1,
changeoverCount: detail.changeoverCount || 0,
changeoverCountError: false,
remark: detail.remark || '',
deviceError: false,
deviceNo: detail.deviceNo,
deviceIndex,
equipmentOptions,
projectNo: selectedOption.projectNo,
productName: selectedOption.productName,
process: selectedOption.process || '',
stampingMethod: selectedOption.stampingMethod || current.stampingMethod || '',
rawMaterialBatchNo: detail.rawMaterialBatchNo || '',
productSearchKeyword: '',
productOptions: options,
productChoices,
productLabels,
productIndex,
processOptions,
processLabels,
processIndex,
originalProduct,
goodQty: detail.goodQty,
defectQty: detail.defectQty,
allocatedMinutes: detail.allocatedMinutes || 0,
workTimeText: detail.workTimeText || '0秒',
actualBeat: detail.actualBeat || 0,
standardBeat: selectedOption.standardBeat || detail.standardBeat || 0,
standardWorkload: selectedOption.standardWorkload || detail.standardWorkload || 0,
deviceSegments: detail.deviceSegments || [],
})
},
productToEditItem(item, product) {
const selectedDeviceNo = api.normalizeDeviceNo(item.deviceNo)
const deviceIndex = selectedDeviceNo
? (item.equipmentOptions || []).findIndex(equipment => api.normalizeDeviceNo(equipment.deviceNo) === selectedDeviceNo)
: -1
const nextItem = {
...item,
attendancePointName: item.attendancePointName,
projectNo: product.projectNo || item.projectNo,
productName: product.productName || item.productName,
materialCode: product.materialCode || '',
materialName: product.materialName || '',
process: product.process || '',
stampingMethod: product.stampingMethod || '',
standardBeat: Number(product.standardBeat || 0),
standardWorkload: api.calculateStandardWorkload(item.allocatedMinutes, product.standardBeat),
operatorCount: product.operatorCount || 1,
isCleaning: !!product.isCleaning,
isContinuousDie: !!product.isContinuousDie,
isMisc: !!product.isMisc,
isMultiPerson: !!product.isMultiPerson,
changeoverCount: product.isContinuousDie ? (item.changeoverCount || 0) : 0,
changeoverCountError: false,
actualBeat: itemActualBeat(item),
deviceIndex,
productOptions: [product],
productChoices: [product],
productLabels: [`${product.projectNo || ''} · ${product.productName || ''}`],
productIndex: 0,
processOptions: [product],
processLabels: [product.process || '未填写工序'],
processIndex: 0,
}
return withCompareClasses(nextItem)
},
replaceEditProduct(reportIndex, itemIndex, product) {
const item = this.data.rows[reportIndex].editItems[itemIndex]
if (!item || !product) {
return
}
this.replaceEditItem(reportIndex, itemIndex, this.productToEditItem(item, product))
},
async openProductModal(e) {
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
const row = this.data.rows[reportIndex]
const item = row && row.editItems ? row.editItems[itemIndex] : null
if (!item) {
wx.showToast({ title: '报工明细不存在', icon: 'none' })
return
}
this.setData({
productModalVisible: true,
productModalContext: { reportIndex, itemIndex },
productModalRows: [],
productModalKeyword: '',
productModalPage: 1,
productModalPageInput: '1',
productModalTotalPages: 1,
})
await this.loadProductModal(1)
},
closeProductModal() {
this.setData({
productModalVisible: false,
productModalRows: [],
productModalKeyword: '',
productModalContext: null,
})
},
onDeviceChange(e) {
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
const deviceIndex = Number(e.detail.value)
const item = this.data.rows[reportIndex].editItems[itemIndex]
const equipment = (item.equipmentOptions || [])[deviceIndex]
if (!equipment) {
return
}
this.setData({
[`rows[${reportIndex}].editItems[${itemIndex}].deviceIndex`]: deviceIndex,
[`rows[${reportIndex}].editItems[${itemIndex}].deviceNo`]: equipment.deviceNo,
[`rows[${reportIndex}].editItems[${itemIndex}].deviceError`]: false,
})
},
async loadProductModal(page = this.data.productModalPage) {
const context = this.data.productModalContext
if (!context) {
return
}
const item = this.data.rows[context.reportIndex].editItems[context.itemIndex]
this.setData({ productModalLoading: true })
try {
const result = await api.listProducts({
keyword: this.data.productModalKeyword,
attendancePointName: item.attendancePointName,
page,
pageSize: 8,
})
this.setData({
productModalRows: result.rows || [],
productModalPage: result.page,
productModalPageInput: String(result.page || 1),
productModalTotalPages: result.totalPages,
})
} catch (error) {
wx.showToast({ title: error.message || '加载产品失败', icon: 'none' })
} finally {
this.setData({ productModalLoading: false })
}
},
onProductModalKeywordInput(e) {
this.setData({ productModalKeyword: e.detail.value })
this.loadProductModal(1)
},
replaceProductFromModal(e) {
const index = Number(e.currentTarget.dataset.index)
const context = this.data.productModalContext
const product = this.data.productModalRows[index]
if (!context || !product) {
return
}
const item = this.data.rows[context.reportIndex].editItems[context.itemIndex]
if (product.isMisc) {
wx.showToast({ title: '处理杂活不能替换到普通报工', icon: 'none' })
return
}
if (!!item.isCleaning !== !!product.isCleaning) {
wx.showToast({ title: item.isCleaning ? '清洗报工只能替换清洗工序' : '普通报工不能替换为清洗工序', icon: 'none' })
return
}
this.replaceEditProduct(context.reportIndex, context.itemIndex, product)
this.closeProductModal()
},
productModalPrevPage() {
if (this.data.productModalPage > 1) {
this.loadProductModal(this.data.productModalPage - 1)
}
},
productModalNextPage() {
if (this.data.productModalPage < this.data.productModalTotalPages) {
this.loadProductModal(this.data.productModalPage + 1)
}
},
onProductModalPageInput(e) {
this.setData({ productModalPageInput: e.detail.value })
},
jumpProductModalPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.productModalPageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.productModalPage, this.data.productModalTotalPages))
if (target === this.data.productModalPage) {
this.setData({ productModalPageInput: String(this.data.productModalPage || 1) })
return
}
this.loadProductModal(target)
},
async restoreProduct(e) {
const reportIndex = Number(e.currentTarget.dataset.reportIndex)
const itemIndex = Number(e.currentTarget.dataset.itemIndex)
const item = this.data.rows[reportIndex].editItems[itemIndex]
if (!item || !item.originalProduct) {
return
}
const original = item.originalProduct
wx.showLoading({ title: '恢复中' })
try {
const products = await this.loadProductOptions(original.productName, item.attendancePointName)
const product = (products || []).find(option => (
option.projectNo === original.projectNo
&& option.productName === original.productName
&& (option.process || '') === (original.process || '')
)) || original
this.replaceEditProduct(reportIndex, itemIndex, {
...product,
projectNo: original.projectNo,
productName: original.productName,
process: original.process || '',
stampingMethod: product.stampingMethod || original.stampingMethod || '',
materialCode: product.materialCode || original.materialCode || '',
materialName: product.materialName || original.materialName || '',
operatorCount: product.operatorCount || original.operatorCount || 1,
standardBeat: product.standardBeat === undefined ? original.standardBeat : product.standardBeat,
isCleaning: product.isCleaning === undefined ? original.isCleaning : product.isCleaning,
isContinuousDie: product.isContinuousDie === undefined ? original.isContinuousDie : product.isContinuousDie,
isMisc: product.isMisc === undefined ? original.isMisc : product.isMisc,
isMultiPerson: product.isMultiPerson === undefined ? original.isMultiPerson : product.isMultiPerson,
changeoverCount: (product.isContinuousDie === undefined ? original.isContinuousDie : product.isContinuousDie) ? (item.changeoverCount || 0) : 0,
})
} catch (error) {
wx.showToast({ title: error.message || '恢复失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
makeEditDeviceSegment(segment, index) {
const editScanDate = datePart(segment.scannedAt)
const editScanTime = timePart(segment.scannedAt)
return {
...segment,
label: index === 0 ? '开始模具' : '换模具',
editScanDate,
editScanTime,
originalScanDate: editScanDate,
originalScanTime: editScanTime,
}
},
replaceEditItem(reportIndex, itemIndex, item) {
this.setData({
[`rows[${reportIndex}].editItems[${itemIndex}]`]: item,
})
},
onDeviceSegmentFieldChange(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const segmentIndex = e.currentTarget.dataset.segmentIndex
const field = e.currentTarget.dataset.field
this.setData({
[`rows[${reportIndex}].editItems[${itemIndex}].deviceSegments[${segmentIndex}].${field}`]: e.detail.value,
})
},
onReportFieldChange(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const field = e.currentTarget.dataset.field
this.setData({
[`rows[${reportIndex}].${field}`]: e.detail.value,
})
},
onItemInput(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const field = e.currentTarget.dataset.field
const value = e.detail.value
const updates = {
[`rows[${reportIndex}].editItems[${itemIndex}].${field}`]: value,
}
if (field === 'goodQty' || field === 'defectQty') {
const item = {
...this.data.rows[reportIndex].editItems[itemIndex],
[field]: value,
}
const outputQty = Number(item.goodQty || 0) + Number(item.defectQty || 0)
updates[`rows[${reportIndex}].editItems[${itemIndex}].actualBeat`] = outputQty > 0
? round2(Number(item.allocatedMinutes || 0) * 60 / outputQty)
: 0
const nextItem = {
...item,
actualBeat: updates[`rows[${reportIndex}].editItems[${itemIndex}].actualBeat`],
}
const compared = withCompareClasses(nextItem)
updates[`rows[${reportIndex}].editItems[${itemIndex}].standardWorkload`] = compared.standardWorkload
updates[`rows[${reportIndex}].editItems[${itemIndex}].beatCompareClass`] = compared.beatCompareClass
updates[`rows[${reportIndex}].editItems[${itemIndex}].beatReason`] = compared.beatReason
updates[`rows[${reportIndex}].editItems[${itemIndex}].workloadCompareClass`] = compared.workloadCompareClass
updates[`rows[${reportIndex}].editItems[${itemIndex}].workloadReason`] = compared.workloadReason
}
if (field === 'changeoverCount') {
updates[`rows[${reportIndex}].editItems[${itemIndex}].changeoverCountError`] = false
}
this.setData(updates)
},
async reloadItemOptions(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const item = this.data.rows[reportIndex].editItems[itemIndex]
wx.showLoading({ title: '匹配中' })
try {
const keyword = String(item.productSearchKeyword || '').trim()
const products = await this.loadProductOptions(item.productName, item.attendancePointName, keyword)
this.replaceEditItem(reportIndex, itemIndex, this.makeEditItem(item, products))
} finally {
wx.hideLoading()
}
},
onProductSearchInput(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
this.setData({
[`rows[${reportIndex}].editItems[${itemIndex}].productSearchKeyword`]: e.detail.value,
})
},
onProductChange(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const item = this.data.rows[reportIndex].editItems[itemIndex]
const productIndex = Number(e.detail.value)
const selectedProduct = item.productChoices[productIndex]
const processOptions = item.productOptions.filter(option => (
option.attendancePointName === selectedProduct.attendancePointName
&& option.projectNo === selectedProduct.projectNo
&& option.productName === selectedProduct.productName
))
const nextOption = processOptions[0] || selectedProduct
this.replaceEditItem(reportIndex, itemIndex, withCompareClasses({
...item,
productIndex,
attendancePointName: item.attendancePointName,
projectNo: selectedProduct.projectNo,
productName: selectedProduct.productName,
processOptions,
processLabels: processOptions.map(option => option.process || '未填写工序'),
processIndex: 0,
process: nextOption.process || '',
stampingMethod: nextOption.stampingMethod || '',
deviceNo: item.deviceNo,
standardBeat: nextOption.standardBeat || 0,
standardWorkload: nextOption.standardWorkload || 0,
operatorCount: nextOption.operatorCount || 1,
isMultiPerson: !!nextOption.isMultiPerson,
}))
},
onProcessChange(e) {
const reportIndex = e.currentTarget.dataset.reportIndex
const itemIndex = e.currentTarget.dataset.itemIndex
const item = this.data.rows[reportIndex].editItems[itemIndex]
const processIndex = Number(e.detail.value)
const selected = item.processOptions[processIndex]
if (!selected) {
return
}
this.replaceEditItem(reportIndex, itemIndex, withCompareClasses({
...item,
processIndex,
attendancePointName: item.attendancePointName,
deviceNo: item.deviceNo,
projectNo: selected.projectNo,
productName: selected.productName,
process: selected.process || '',
stampingMethod: selected.stampingMethod || '',
standardBeat: selected.standardBeat || 0,
standardWorkload: selected.standardWorkload || 0,
operatorCount: selected.operatorCount || 1,
isMultiPerson: !!selected.isMultiPerson,
}))
},
async approve(e) {
const id = e.currentTarget.dataset.id
const user = this.data.user
if (!user || user.role !== 'admin') {
wx.showToast({ title: '无审核权限', icon: 'none' })
return
}
const row = this.data.rows.find(item => String(item.id) === String(id))
if (!row) {
wx.showToast({ title: '报工记录不存在', icon: 'none' })
return
}
if (row.isVoided) {
wx.showToast({ title: '作废记录不能编辑', icon: 'none' })
return
}
if (!row.canEdit) {
wx.showToast({ title: '已超过可编辑期限', icon: 'none' })
return
}
const invalidItem = (row.editItems || []).find(item => (
Number(item.goodQty || 0) < 0
|| Number(item.defectQty || 0) < 0
|| !Number.isFinite(Number(item.goodQty || 0))
|| !Number.isFinite(Number(item.defectQty || 0))
))
if (invalidItem) {
wx.showToast({ title: '产出数量不能为负数', icon: 'none' })
return
}
const invalidChangeoverItemIndex = (row.editItems || []).findIndex(item => {
if (!item.isContinuousDie) {
return false
}
const value = Number(item.changeoverCount)
return !Number.isInteger(value) || value < 0
})
if (invalidChangeoverItemIndex >= 0) {
this.setData({
[`rows[${this.data.rows.findIndex(item => String(item.id) === String(id))}].editItems[${invalidChangeoverItemIndex}].changeoverCountError`]: true,
})
wx.showToast({ title: '请填写非负整数换料次数', icon: 'none' })
return
}
const missingDeviceItemIndex = (row.editItems || []).findIndex(item => !item.isMisc && !api.normalizeDeviceNo(item.deviceNo))
if (missingDeviceItemIndex >= 0) {
this.setData({
[`rows[${this.data.rows.findIndex(item => String(item.id) === String(id))}].editItems[${missingDeviceItemIndex}].deviceError`]: true,
})
wx.showToast({ title: '请选择设备号', icon: 'none' })
return
}
await this.submitApprove(id, row, user)
},
async submitApprove(id, row, user) {
const deviceCorrections = (row.editItems || []).flatMap(item => item.deviceSegments || [])
.filter(segment => (
segment.editScanDate !== segment.originalScanDate
|| segment.editScanTime !== segment.originalScanTime
))
.map(segment => ({
id: segment.id,
scannedAt: dateTimeValue(segment.editScanDate, segment.editScanTime),
}))
const invalidDeviceCorrection = deviceCorrections.find(item => !item.scannedAt)
if (invalidDeviceCorrection) {
wx.showToast({ title: '换模具时间不能为空', icon: 'none' })
return
}
const patch = {
startAt: row.isCleaning ? null : dateTimeValue(row.editStartDate, row.editStartTime),
endAt: row.isCleaning ? null : dateTimeValue(row.editEndDate, row.editEndTime),
deviceCorrections: row.isCleaning ? [] : deviceCorrections,
reviewRemark: row.editReviewRemark || '',
itemCorrections: (row.editItems || []).map(item => ({
id: item.id,
device_no: api.normalizeDeviceNo(item.deviceNo),
project_no: item.projectNo,
product_name: item.productName,
raw_material_batch_no: item.rawMaterialBatchNo || null,
process_name: item.process || '',
changeover_count: item.isContinuousDie ? Number(item.changeoverCount || 0) : 0,
good_qty: Number(item.goodQty || 0),
defect_qty: Number(item.defectQty || 0),
scrap_qty: 0,
remark: item.remark || null,
})),
}
try {
await api.approveReport(id, user, patch)
wx.showToast({ title: row.status === 'approved' ? '已保存更改' : '已审核通过', icon: 'success' })
if (this.data.focusReportId) {
setTimeout(() => {
wx.navigateBack({
delta: 1,
fail: () => {
wx.redirectTo({
url: '/pages/records/records?mode=pending&status=pending',
})
},
})
}, 500)
return
}
await this.load(this.data.page)
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
}
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
toggleFilterVoided() {
this.setData({ filterVoided: !this.data.filterVoided, page: 1 })
this.load(1)
},
onFilterVoidedChange(e) {
this.setData({ filterVoided: !!e.detail.value, page: 1 })
this.load(1)
},
async voidReport(e) {
const id = e.currentTarget.dataset.id
wx.showModal({
title: '确认作废',
content: '作废的记录将在30天后被系统彻底删除,彻底删除前可随时撤销作废,是否确定删除',
cancelText: '否',
confirmText: '是',
confirmColor: '#c5221f',
success: async res => {
if (!res.confirm) {
return
}
wx.showLoading({ title: '处理中' })
try {
await api.voidReport(id)
wx.showToast({ title: '已作废', icon: 'success' })
await this.load(this.data.page)
} catch (error) {
wx.showToast({ title: error.message || '作废失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
},
async unvoidReport(e) {
const id = e.currentTarget.dataset.id
wx.showLoading({ title: '处理中' })
try {
await api.unvoidReport(id)
wx.showToast({ title: '已撤销作废', icon: 'success' })
await this.load(this.data.page)
} catch (error) {
wx.showToast({ title: error.message || '撤销失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "报工审核"
}
+338
View File
@@ -0,0 +1,338 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
scroll-top="{{scrollTop}}"
scroll-anchoring="{{true}}"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindscroll="onScroll"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<view class="review-header-row">
<view class="review-header-main">
<text class="title">报工审核</text>
<text class="subtitle">复核并校正员工上报数据后通过</text>
</view>
<view class="top-action-group">
<view class="void-filter-switch">
<text>过滤作废</text>
<switch checked="{{filterVoided}}" color="#1463ff" bindchange="onFilterVoidedChange" />
</view>
<button class="btn mini secondary detail-toggle" bindtap="toggleDetails">
{{detailExpandedAll ? '收起明细' : '展开明细'}}
</button>
</view>
</view>
</view>
<view wx:if="{{!rows.length}}" class="list-empty">暂无待审核报工</view>
<view
wx:for="{{rows}}"
wx:key="id"
wx:for-index="reportIndex"
id="review-report-{{item.id}}"
class="card review-card {{item.isSystemAutoSubmitted ? 'system-auto' : ''}} {{item.isVoided ? 'voided' : ''}} {{detailExpandedAll || expandedReportId == item.id ? 'expanded' : ''}}"
data-id="{{item.id}}"
bindtap="toggleReportDetail"
>
<text wx:if="{{item.isVoided}}" class="voided-watermark">作废</text>
<text wx:elif="{{item.isModified}}" class="modified-watermark">更改</text>
<text wx:if="{{item.isSystemAutoSubmitted}}" class="timeout-watermark">{{item.autoSubmitReason || '超时-系统自动提交'}}</text>
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">清洗</text>
<text wx:if="{{item.hasMisc}}" class="misc-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified ? 'lower-watermark' : ''}}">包含杂活</text>
<text wx:if="{{item.hasMultiPerson}}" class="multi-person-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning ? 'lower-watermark' : ''}}">多人协作</text>
<text wx:if="{{item.hasContinuousDie}}" class="continuous-die-watermark {{item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning || item.hasMultiPerson ? 'lower-watermark' : ''}} {{item.hasMultiPerson && (item.isSystemAutoSubmitted || item.isVoided || item.isModified || item.hasMisc || item.isCleaning) ? 'third-watermark' : ''}}">连续模</text>
<view class="row">
<view class="row-left">
<text class="value">{{item.employeeName}} · {{item.reportDate}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.timeRangeText}}</text>
<text class="subtitle">{{item.metrics.shiftDistributionText || ''}}</text>
</view>
<text class="pill {{item.status === 'approved' ? 'success' : 'warn'}}">{{item.statusName}}</text>
</view>
<view wx:if="{{detailExpandedAll || expandedReportId == item.id}}" catchtap="noop">
<view class="divider"></view>
<view wx:if="{{!item.isCleaning && !item.isVoided && item.canEdit}}" class="review-edit-panel">
<text class="panel-title">上下班时间校正</text>
<view class="time-grid">
<picker mode="date" value="{{item.editStartDate}}" data-report-index="{{reportIndex}}" data-field="editStartDate" bindchange="onReportFieldChange">
<view class="picker compact-picker">{{item.editStartDate || '上班日期'}}</view>
</picker>
<picker mode="time" value="{{item.editStartTime}}" data-report-index="{{reportIndex}}" data-field="editStartTime" bindchange="onReportFieldChange">
<view class="picker compact-picker">{{item.editStartTime || '上班时间'}}</view>
</picker>
<picker mode="date" value="{{item.editEndDate}}" data-report-index="{{reportIndex}}" data-field="editEndDate" bindchange="onReportFieldChange">
<view class="picker compact-picker">{{item.editEndDate || '下班日期'}}</view>
</picker>
<picker mode="time" value="{{item.editEndTime}}" data-report-index="{{reportIndex}}" data-field="editEndTime" bindchange="onReportFieldChange">
<view class="picker compact-picker">{{item.editEndTime || '下班时间'}}</view>
</picker>
</view>
</view>
<view class="item-list">
<view wx:for="{{item.editItems}}" wx:key="id" wx:for-item="detail" wx:for-index="itemIndex" class="detail-line {{detail.isMultiPerson || detail.isContinuousDie ? 'marked-detail-line' : ''}} {{detail.isMultiPerson && detail.isContinuousDie ? 'stacked-detail-line' : ''}}">
<text wx:if="{{detail.isMultiPerson}}" class="detail-multi-stamp">多人协作</text>
<text wx:if="{{detail.isContinuousDie}}" class="detail-continuous-stamp {{detail.isMultiPerson ? 'lower-detail-stamp' : ''}}">连续模</text>
<text class="detail-title">{{detail.deviceNo}} · {{detail.productName}} / {{detail.process || '-'}} / {{detail.stampingMethod || '-'}}</text>
<text class="muted">考勤点 {{detail.attendancePointName || item.attendancePointName || '-'}}</text>
<text wx:if="{{detail.isMisc}}" class="muted">处理杂活 · {{detail.workTimeText}}</text>
<text wx:elif="{{detail.isCleaning}}" class="muted">工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 清洗数量 {{detail.goodQty}}</text>
<text wx:else class="muted">工序 {{detail.process || '-'}} · 冲压方式 {{detail.stampingMethod || '-'}} · 成品 {{detail.goodQty}} · 不良 {{detail.defectQty}}</text>
<text wx:if="{{!detail.isCleaning && !detail.isMisc}}" class="muted">材料库存批次号 {{detail.rawMaterialBatchNo || '-'}}</text>
<view wx:if="{{!detail.isCleaning && !detail.isMisc && detail.deviceSegments.length && !item.isVoided && item.canEdit}}" class="device-time-inline">
<text class="panel-title device-time-title">换模具时间校正</text>
<view
wx:for="{{detail.deviceSegments}}"
wx:key="id"
wx:for-item="segment"
wx:for-index="segmentIndex"
class="device-time-row"
>
<view class="device-time-main">
<text class="value">{{segment.label}} · {{segment.displayName}}</text>
<text class="subtitle">原记录 {{segment.scannedAtText}}</text>
</view>
<view class="device-time-pickers">
<picker mode="date" value="{{segment.editScanDate}}" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" data-segment-index="{{segmentIndex}}" data-field="editScanDate" bindchange="onDeviceSegmentFieldChange">
<view class="picker compact-picker">{{segment.editScanDate || '日期'}}</view>
</picker>
<picker mode="time" value="{{segment.editScanTime}}" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" data-segment-index="{{segmentIndex}}" data-field="editScanTime" bindchange="onDeviceSegmentFieldChange">
<view class="picker compact-picker">{{segment.editScanTime || '时间'}}</view>
</picker>
</view>
</view>
</view>
<view wx:if="{{detail.isMisc}}" class="detail-metric-grid">
<view>
<text class="label">工时</text>
<text class="value">{{detail.workTimeText}}</text>
</view>
<view>
<text class="label">杂活事项</text>
<text class="value">{{detail.remark || '待备注'}}</text>
</view>
</view>
<view wx:elif="{{detail.isCleaning}}" class="detail-metric-grid">
<view>
<text class="label">清洗数量</text>
<text class="value">{{detail.goodQty}}</text>
</view>
<view>
<text class="label">工时</text>
<text class="value">{{detail.workTimeText}}</text>
</view>
</view>
<view wx:else class="detail-metric-grid">
<view>
<text class="label">工时</text>
<text class="value">{{detail.workTimeText}}</text>
</view>
<view>
<text class="label">成品 / 标准工作量</text>
<text class="value {{detail.workloadCompareClass}}">
{{detail.goodQty}} / {{detail.standardWorkload}}
<text wx:if="{{detail.workloadReason}}" class="metric-reason">{{detail.workloadReason}}</text>
</text>
</view>
<view>
<text class="label">实际节拍</text>
<text class="value {{detail.beatCompareClass}}">
{{detail.actualBeat}} 秒
<text wx:if="{{detail.beatReason}}" class="metric-reason">{{detail.beatReason}}</text>
</text>
</view>
<view>
<text class="label">标准节拍</text>
<text class="value">{{detail.standardBeat}} 秒</text>
</view>
<view wx:if="{{detail.isContinuousDie}}">
<text class="label">换料次数</text>
<text class="value">{{detail.changeoverCount || 0}}</text>
</view>
</view>
<view wx:if="{{detail.isMisc && !item.isVoided && item.canEdit}}" class="review-edit-panel misc-remark-panel">
<text class="panel-title">杂活事项备注</text>
<textarea
class="input misc-remark-input"
value="{{detail.remark}}"
placeholder="请输入处理杂活事项,便于经理监督留痕"
data-report-index="{{reportIndex}}"
data-item-index="{{itemIndex}}"
data-field="remark"
bindinput="onItemInput"
/>
</view>
<view wx:if="{{!detail.isMisc && !item.isVoided && item.canEdit}}" class="form-grid review-form-grid">
<view class="form-item">
<text class="label">设备号</text>
<picker
wx:if="{{detail.equipmentOptions.length}}"
mode="selector"
range="{{detail.equipmentOptions}}"
range-key="deviceNo"
value="{{detail.deviceIndex}}"
data-report-index="{{reportIndex}}"
data-item-index="{{itemIndex}}"
bindchange="onDeviceChange"
>
<view class="device-select-control {{detail.deviceError ? 'field-error' : ''}}">
<text class="device-select-text">{{detail.deviceNo || (detail.isCleaning ? '请选择清洗设备号' : '请选择冲压设备号')}}</text>
<text class="device-select-action">选择</text>
</view>
</picker>
<view wx:else class="readonly {{detail.deviceError ? 'field-error' : ''}}">暂无可选设备</view>
</view>
<view class="form-item full-span product-correction-row">
<text class="label">产品/工序更正</text>
<view class="product-correction-actions">
<button class="btn primary full" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" bindtap="openProductModal">更正产品</button>
<button class="btn secondary full" data-report-index="{{reportIndex}}" data-item-index="{{itemIndex}}" bindtap="restoreProduct">恢复</button>
</view>
</view>
<view class="form-item full-span">
<text class="label">产品名称</text>
<view class="readonly product-readonly">{{detail.projectNo}} · {{detail.productName}}</view>
</view>
<view class="form-item">
<text class="label">工序</text>
<view class="readonly product-readonly">{{detail.process || '未填写工序'}}</view>
</view>
<view class="form-item">
<text class="label">冲压方式</text>
<view class="readonly product-readonly">{{detail.stampingMethod || '-'}}</view>
</view>
<view wx:if="{{detail.isContinuousDie}}" class="form-item">
<text class="label">换料次数</text>
<input
class="input number-input {{detail.changeoverCountError ? 'field-error' : ''}}"
type="number"
value="{{detail.changeoverCount}}"
data-report-index="{{reportIndex}}"
data-item-index="{{itemIndex}}"
data-field="changeoverCount"
bindinput="onItemInput"
/>
</view>
<view class="form-item">
<text class="label">{{detail.isCleaning ? '清洗数量' : '成品数量'}}</text>
<input
class="input number-input"
type="digit"
value="{{detail.goodQty}}"
data-report-index="{{reportIndex}}"
data-item-index="{{itemIndex}}"
data-field="goodQty"
bindinput="onItemInput"
/>
</view>
<view wx:if="{{!detail.isCleaning}}" class="form-item">
<text class="label">不良数量</text>
<input
class="input number-input"
type="digit"
value="{{detail.defectQty}}"
data-report-index="{{reportIndex}}"
data-item-index="{{itemIndex}}"
data-field="defectQty"
bindinput="onItemInput"
/>
</view>
</view>
</view>
</view>
<view wx:if="{{!item.isVoided && item.canEdit}}" class="review-edit-panel review-remark-panel">
<text class="panel-title">审核备注</text>
<textarea
class="input review-remark-input"
value="{{item.editReviewRemark}}"
placeholder="请输入本条报工的审核备注"
maxlength="500"
data-report-index="{{reportIndex}}"
data-field="editReviewRemark"
bindinput="onReportFieldChange"
/>
</view>
<view wx:elif="{{item.reviewRemark}}" class="review-edit-panel review-remark-panel">
<text class="panel-title">审核备注</text>
<text class="readonly-text">{{item.reviewRemark}}</text>
</view>
<view wx:if="{{(!item.isVoided && (item.canEdit || item.canVoid)) || (item.isVoided && item.canUnvoid)}}" class="button-row review-action-row">
<button wx:if="{{!item.isVoided && item.canEdit}}" class="btn primary review-action-btn" data-id="{{item.id}}" bindtap="approve">{{item.status === 'approved' ? '保存更改' : '校正并审核通过'}}</button>
<button wx:if="{{!item.isVoided && item.canVoid}}" class="btn danger review-action-btn" data-id="{{item.id}}" catchtap="voidReport">作废</button>
<button wx:if="{{item.isVoided && item.canUnvoid}}" class="btn secondary review-action-btn" data-id="{{item.id}}" catchtap="unvoidReport">撤销作废</button>
</view>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length && !focusReportId}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
<view wx:if="{{productModalVisible}}" class="modal-mask product-select-mask" catchtap="closeProductModal">
<view class="modal-panel product-select-panel" catchtap="noop">
<view class="modal-head">
<view>
<text class="modal-title">产品清单</text>
<text class="modal-subtitle">选择正确的产品、工序和冲压方式后替换当前报工明细</text>
</view>
<button class="btn mini secondary modal-close" bindtap="closeProductModal">关闭</button>
</view>
<input
class="input product-select-search"
value="{{productModalKeyword}}"
placeholder="搜索产品、物料、供应商、工序、冲压方式"
bindinput="onProductModalKeywordInput"
/>
<view wx:if="{{!productModalRows.length && !productModalLoading}}" class="list-empty">暂无可替换产品</view>
<view wx:for="{{productModalRows}}" wx:key="uniqueKey" wx:for-index="productIndex" class="card product-select-card">
<view class="row">
<view class="row-left">
<text class="value">{{item.productName}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.projectNo}} · {{item.materialCode || '-'}}</text>
<text class="subtitle">工序 {{item.processDisplay || item.process || '-'}} · 冲压方式 {{item.stampingMethod || '-'}}</text>
<text class="subtitle">物料名称 {{item.materialName || '-'}}</text>
</view>
<text class="pill primary">{{item.processDisplay || item.process || '工序'}}</text>
<text wx:if="{{item.isCleaning}}" class="cleaning-watermark product-select-stamp">清洗</text>
<text wx:if="{{item.isMisc}}" class="misc-watermark product-select-stamp">杂活</text>
<text wx:if="{{item.isMultiPerson}}" class="multi-person-watermark product-select-stamp">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="continuous-die-watermark product-select-stamp">连续模</text>
</view>
<view class="divider"></view>
<view wx:if="{{!item.isCleaning && !item.isMisc}}" class="row">
<text class="muted">标准节拍 {{item.standardBeat}} 秒</text>
</view>
<button class="btn primary full product-replace-btn" data-index="{{productIndex}}" bindtap="replaceProductFromModal">替换</button>
</view>
<view class="button-row pager-row product-select-pager" wx:if="{{productModalRows.length}}">
<button class="btn secondary pager-nav" bindtap="productModalPrevPage" disabled="{{productModalPage <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{productModalPageInput}}" bindinput="onProductModalPageInput" bindconfirm="jumpProductModalPage" />
<text class="page-total-label">/ {{productModalTotalPages}}</text>
<text class="page-jump-action" bindtap="jumpProductModalPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="productModalNextPage" disabled="{{productModalPage >= productModalTotalPages}}">下一页</button>
</view>
</view>
</view>
+487
View File
@@ -0,0 +1,487 @@
.review-header-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
}
.review-header-main {
min-width: 0;
width: 100%;
flex: none;
}
.detail-toggle {
position: relative;
z-index: 2;
flex: 0 0 auto;
min-width: 150rpx;
height: 62rpx;
line-height: 62rpx;
margin: 4rpx 0 0;
padding: 0 18rpx;
border-radius: 14rpx;
color: #1463ff;
font-size: 24rpx;
font-weight: 800;
}
.review-header-row .top-action-group {
width: 100%;
display: grid;
grid-template-columns: 240rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
margin-top: 0;
}
.review-header-row .void-filter-switch {
width: 240rpx;
}
.review-header-row .detail-toggle {
width: 100%;
min-width: 0;
height: 68rpx;
line-height: 68rpx;
box-sizing: border-box;
}
.review-card {
position: relative;
overflow: hidden;
border-color: #aebbd0;
}
.cleaning-watermark {
position: absolute;
right: 28rpx;
top: 18rpx;
z-index: 1;
padding: 8rpx 18rpx;
border: 2rpx solid rgba(18, 163, 117, 0.55);
border-radius: 12rpx;
color: rgba(8, 116, 67, 0.72);
background: rgba(226, 255, 243, 0.82);
font-size: 30rpx;
font-weight: 900;
transform: rotate(10deg);
pointer-events: none;
}
.misc-watermark {
position: absolute;
right: 28rpx;
top: 18rpx;
z-index: 1;
padding: 8rpx 18rpx;
border: 2rpx solid rgba(217, 119, 6, 0.55);
border-radius: 12rpx;
color: rgba(154, 74, 5, 0.82);
background: rgba(255, 247, 232, 0.88);
font-size: 30rpx;
font-weight: 900;
transform: rotate(10deg);
pointer-events: none;
}
.review-card.expanded {
border-color: #1463ff;
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.9),
0 13rpx 0 rgba(20, 86, 194, 0.48),
0 24rpx 36rpx rgba(20, 86, 194, 0.16),
inset 0 0 0 1rpx rgba(20, 99, 255, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.detail-metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx 18rpx;
margin: 12rpx 0 8rpx;
padding: 16rpx;
border: 2rpx solid #c5cfde;
border-radius: 14rpx;
background: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%);
box-shadow:
inset 0 0 0 1rpx rgba(255, 255, 255, 0.86),
0 8rpx 0 rgba(177, 191, 211, 0.52),
0 14rpx 22rpx rgba(22, 34, 50, 0.07);
}
.metric-danger {
color: #c5221f;
font-weight: 800;
}
.metric-success {
color: #087443;
font-weight: 800;
}
.review-edit-panel {
margin: 20rpx 0;
padding: 20rpx;
border: 1rpx solid #dfe7f2;
border-radius: 16rpx;
background:
linear-gradient(135deg, rgba(20, 99, 255, 0.06), transparent 46%),
#f8fbff;
box-shadow:
0 8rpx 0 rgba(207, 218, 233, 0.58),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.panel-title {
display: block;
margin-bottom: 16rpx;
color: #162232;
font-size: 26rpx;
font-weight: 700;
}
.time-grid,
.review-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.review-form-grid .full-span {
grid-column: 1 / -1;
}
.compact-picker {
min-height: 76rpx;
line-height: 76rpx;
}
.device-time-inline {
margin: 6rpx 0 8rpx;
padding: 16rpx;
border: 2rpx solid #c5cfde;
border-radius: 14rpx;
background: #f6faff;
}
.device-time-row {
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 14rpx 0;
border-top: 1rpx solid rgba(174, 188, 208, 0.72);
}
.device-time-row:first-of-type {
border-top: 0;
padding-top: 0;
}
.device-time-main {
display: flex;
flex-direction: column;
gap: 6rpx;
min-width: 0;
}
.device-time-pickers {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.device-time-title {
margin-bottom: 4rpx;
font-size: 24rpx;
}
.item-list {
margin: 18rpx 0;
padding: 18rpx;
border: 2rpx solid #c4d0e2;
border-radius: 16rpx;
background: #eef4fb;
box-shadow:
inset 0 0 0 1rpx rgba(255, 255, 255, 0.72),
0 8rpx 0 rgba(174, 188, 208, 0.34);
}
.detail-line {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 28rpx;
padding: 24rpx 22rpx 24rpx 28rpx;
border: 3rpx solid #9fb0c8;
border-left: 9rpx solid #1463ff;
border-radius: 18rpx;
background:
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.86),
0 12rpx 0 rgba(133, 152, 179, 0.55),
0 20rpx 28rpx rgba(22, 34, 50, 0.13),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.detail-line:last-child {
margin-bottom: 0;
}
.detail-line:nth-child(even) {
border-left-color: #12a375;
background:
linear-gradient(180deg, #ffffff 0%, #f5fffb 100%);
}
.detail-line.multi-detail-line,
.detail-line.marked-detail-line {
padding-top: 72rpx;
}
.detail-line.stacked-detail-line {
padding-top: 112rpx;
}
.detail-title {
color: #162232;
padding-right: 24rpx;
font-weight: 600;
line-height: 1.45;
}
.detail-multi-stamp {
position: absolute;
left: 24rpx;
top: 22rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
}
.detail-continuous-stamp {
position: absolute;
right: 24rpx;
top: 22rpx;
z-index: 2;
padding: 7rpx 14rpx;
border: 2rpx solid rgba(217, 119, 6, 0.62);
border-radius: 10rpx;
color: rgba(154, 74, 5, 0.88);
background: rgba(255, 247, 232, 0.92);
font-size: 23rpx;
font-weight: 900;
line-height: 1;
pointer-events: none;
}
.detail-continuous-stamp.lower-detail-stamp {
top: 64rpx;
}
.product-correction-row {
margin-bottom: 8rpx;
}
.product-correction-actions {
display: flex;
gap: 22rpx;
align-items: center;
padding-bottom: 10rpx;
overflow: visible;
}
.product-correction-actions .btn {
flex: 1 1 0;
width: auto;
min-width: 0;
height: 76rpx;
line-height: 76rpx;
margin: 0;
padding: 0 18rpx;
box-sizing: border-box;
font-size: 27rpx;
}
.product-correction-actions .btn.full {
width: auto;
}
.product-readonly {
min-height: 76rpx;
padding: 0 20rpx;
border: 2rpx solid #dbe4f0;
border-radius: 14rpx;
color: #162232;
background: linear-gradient(180deg, #f9fbff 0%, #eef4fb 100%);
font-size: 27rpx;
font-weight: 700;
line-height: 76rpx;
word-break: break-all;
box-sizing: border-box;
}
.device-select-control {
min-height: 76rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
padding: 0 18rpx;
border: 2rpx solid #dbe4f0;
border-radius: 14rpx;
color: #162232;
background: #fff;
box-sizing: border-box;
}
.device-select-control.field-error {
border-color: #c5221f;
background: #fff5f5;
}
.device-select-text {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
font-size: 27rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-select-action {
flex: 0 0 auto;
color: #1463ff;
font-size: 24rpx;
font-weight: 800;
}
.review-action-row {
align-items: stretch;
}
.review-action-btn {
flex: 1 1 0;
min-width: 0;
margin: 0;
}
.review-remark-panel,
.misc-remark-panel {
margin-top: 16rpx;
}
.review-remark-input,
.misc-remark-input {
min-height: 150rpx;
line-height: 1.45;
padding-top: 18rpx;
padding-bottom: 18rpx;
text-align: left;
}
.readonly-text {
display: block;
color: #334155;
font-size: 28rpx;
line-height: 1.55;
word-break: break-all;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 20rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
.product-select-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 60;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24rpx 22rpx calc(env(safe-area-inset-bottom) + 24rpx);
background: rgba(15, 23, 42, 0.5);
box-sizing: border-box;
}
.product-select-panel {
width: 100%;
max-height: 92vh;
overflow-y: auto;
padding: 24rpx;
border: 2rpx solid #c7d2e2;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-subtitle {
display: block;
margin-top: 8rpx;
color: #6b778c;
font-size: 24rpx;
font-weight: 600;
line-height: 1.35;
}
.product-select-search {
margin-bottom: 20rpx;
}
.product-select-card {
position: relative;
overflow: hidden;
padding: 22rpx;
}
.product-select-card .row-left {
padding-right: 100rpx;
}
.product-select-stamp {
right: 22rpx;
top: 72rpx;
font-size: 25rpx;
}
.product-replace-btn {
margin-top: 18rpx;
}
.product-select-pager {
margin-top: 12rpx;
}
+193
View File
@@ -0,0 +1,193 @@
const api = require('../../utils/api')
Page({
data: {
locks: [],
unreadRows: [],
readRows: [],
unreadPage: 1,
readPage: 1,
unreadPageInput: '1',
readPageInput: '1',
unreadTotal: 0,
readTotal: 0,
unreadTotalPages: 1,
readTotalPages: 1,
loading: false,
refreshing: false,
detailVisible: false,
detailType: '',
selectedLock: null,
selectedFeedback: null,
},
onShow() {
this.load().catch(error => {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
})
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load()
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
async load() {
this.setData({ loading: true })
try {
await Promise.all([
this.loadLocks(),
this.loadFeedbacks('unread', this.data.unreadPage),
this.loadFeedbacks('read', this.data.readPage),
])
} finally {
this.setData({ loading: false })
}
},
async loadLocks() {
const locks = await api.listMonitorLocks()
this.setData({ locks })
},
async loadFeedbacks(box, page = 1) {
const result = await api.listMoldLockFeedbacks({ box, page, pageSize: 10 })
if (box === 'read') {
this.setData({
readRows: result.rows || [],
readPage: result.page || 1,
readPageInput: String(result.page || 1),
readTotal: result.total || 0,
readTotalPages: result.totalPages || 1,
})
return
}
this.setData({
unreadRows: result.rows || [],
unreadPage: result.page || 1,
unreadPageInput: String(result.page || 1),
unreadTotal: result.total || 0,
unreadTotalPages: result.totalPages || 1,
})
},
async changeFeedbackPage(e) {
const box = e.currentTarget.dataset.box
const direction = Number(e.currentTarget.dataset.direction || 0)
const pageKey = box === 'read' ? 'readPage' : 'unreadPage'
const totalPagesKey = box === 'read' ? 'readTotalPages' : 'unreadTotalPages'
const nextPage = Math.max(1, Math.min(this.data[totalPagesKey], this.data[pageKey] + direction))
await this.loadFeedbacks(box, nextPage)
},
onFeedbackPageInput(e) {
const box = e.currentTarget.dataset.box
const inputKey = box === 'read' ? 'readPageInput' : 'unreadPageInput'
this.setData({ [inputKey]: e.detail.value })
},
async jumpFeedbackPage(e) {
wx.hideKeyboard()
const box = e.currentTarget.dataset.box
const pageKey = box === 'read' ? 'readPage' : 'unreadPage'
const inputKey = box === 'read' ? 'readPageInput' : 'unreadPageInput'
const totalPagesKey = box === 'read' ? 'readTotalPages' : 'unreadTotalPages'
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data[inputKey]
const target = Math.max(1, Math.min(Number(inputValue) || this.data[pageKey], this.data[totalPagesKey]))
if (target === this.data[pageKey]) {
this.setData({ [inputKey]: String(this.data[pageKey] || 1) })
return
}
await this.loadFeedbacks(box, target)
},
openLock(e) {
const index = Number(e.currentTarget.dataset.index)
const selectedLock = this.data.locks[index]
if (!selectedLock) {
return
}
this.setData({
detailVisible: true,
detailType: 'lock',
selectedLock,
selectedFeedback: null,
})
},
async openFeedback(e) {
const box = e.currentTarget.dataset.box
const index = Number(e.currentTarget.dataset.index)
const rows = box === 'read' ? this.data.readRows : this.data.unreadRows
const feedback = rows[index]
if (!feedback) {
return
}
wx.showLoading({ title: '加载中' })
try {
const selectedFeedback = box === 'unread'
? await api.readMoldLockFeedback(feedback.id)
: feedback
this.setData({
detailVisible: true,
detailType: 'feedback',
selectedLock: null,
selectedFeedback,
})
if (box === 'unread') {
await Promise.all([
this.loadFeedbacks('unread', this.data.unreadPage),
this.loadFeedbacks('read', 1),
])
}
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
closeDetail() {
this.setData({
detailVisible: false,
detailType: '',
selectedLock: null,
selectedFeedback: null,
})
},
noop() {},
async releaseFromLock() {
const lock = this.data.selectedLock
if (!lock) {
return
}
wx.showLoading({ title: '解除中' })
try {
await api.releaseMoldLock(lock.id)
wx.showToast({ title: '已解除占用', icon: 'success' })
this.closeDetail()
await this.load()
} catch (error) {
wx.showToast({ title: error.message || '解除失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
async releaseFromFeedback() {
const feedback = this.data.selectedFeedback
if (!feedback || !feedback.canRelease) {
return
}
wx.showLoading({ title: '解除中' })
try {
const selectedFeedback = await api.releaseMoldLockByFeedback(feedback.id)
wx.showToast({ title: '已解除占用', icon: 'success' })
this.setData({ selectedFeedback })
await Promise.all([
this.loadLocks(),
this.loadFeedbacks('unread', this.data.unreadPage),
this.loadFeedbacks('read', this.data.readPage),
])
} catch (error) {
wx.showToast({ title: error.message || '解除失败', icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "智能监控",
"enablePullDownRefresh": true
}
+165
View File
@@ -0,0 +1,165 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">智能监控</text>
<text class="subtitle">查看当前模具占用和员工反馈</text>
</view>
<view class="section-title">反馈信箱</view>
<view class="mail-grid">
<view class="mail-card">
<view class="mail-head">
<view class="mail-title">
<text class="mail-icon unread">未</text>
<text class="value">未读信箱</text>
</view>
<text class="mail-badge">{{unreadTotal}}</text>
</view>
<view wx:if="{{!unreadRows.length}}" class="mail-empty">暂无未读反馈</view>
<view
wx:for="{{unreadRows}}"
wx:key="id"
wx:for-index="index"
class="feedback-row unread-row"
data-box="unread"
data-index="{{index}}"
bindtap="openFeedback"
>
<text class="feedback-title">{{item.moldDisplayName}}</text>
<text class="feedback-sub">{{item.reporterName || item.reporterPhone}} 反馈 {{item.occupiedName || item.occupiedPhone}}</text>
<text class="feedback-time">{{item.feedbackAtText}}</text>
</view>
<view class="pager">
<button class="btn mini secondary" data-box="unread" data-direction="-1" bindtap="changeFeedbackPage" disabled="{{unreadPage <= 1}}">上一页</button>
<view class="page-jump-control mini">
<input class="page-jump-input" type="number" confirm-type="done" value="{{unreadPageInput}}" data-box="unread" bindinput="onFeedbackPageInput" bindconfirm="jumpFeedbackPage" />
<text class="page-total-label">/ {{unreadTotalPages}}</text>
<text class="page-jump-action" data-box="unread" bindtap="jumpFeedbackPage">跳</text>
</view>
<button class="btn mini secondary" data-box="unread" data-direction="1" bindtap="changeFeedbackPage" disabled="{{unreadPage >= unreadTotalPages}}">下一页</button>
</view>
</view>
<view class="mail-card">
<view class="mail-head">
<view class="mail-title">
<text class="mail-icon read">读</text>
<text class="value">已读信箱</text>
</view>
<text class="mail-badge muted-badge">{{readTotal}}</text>
</view>
<view wx:if="{{!readRows.length}}" class="mail-empty">暂无已读反馈</view>
<view
wx:for="{{readRows}}"
wx:key="id"
wx:for-index="index"
class="feedback-row"
data-box="read"
data-index="{{index}}"
bindtap="openFeedback"
>
<view class="feedback-title-line">
<text class="feedback-title">{{item.moldDisplayName}}</text>
<text class="status-text {{item.isHandled ? 'handled' : 'pending'}}">{{item.statusName}}</text>
</view>
<text class="feedback-sub">{{item.reporterName || item.reporterPhone}} 反馈 {{item.occupiedName || item.occupiedPhone}}</text>
<text class="feedback-time">{{item.readAtText || item.feedbackAtText}}</text>
</view>
<view class="pager">
<button class="btn mini secondary" data-box="read" data-direction="-1" bindtap="changeFeedbackPage" disabled="{{readPage <= 1}}">上一页</button>
<view class="page-jump-control mini">
<input class="page-jump-input" type="number" confirm-type="done" value="{{readPageInput}}" data-box="read" bindinput="onFeedbackPageInput" bindconfirm="jumpFeedbackPage" />
<text class="page-total-label">/ {{readTotalPages}}</text>
<text class="page-jump-action" data-box="read" bindtap="jumpFeedbackPage">跳</text>
</view>
<button class="btn mini secondary" data-box="read" data-direction="1" bindtap="changeFeedbackPage" disabled="{{readPage >= readTotalPages}}">下一页</button>
</view>
</view>
</view>
<view class="section-title">生产监控</view>
<view class="card monitor-card">
<view class="monitor-head">
<view>
<text class="value">当前占用模具</text>
<text class="subtitle">只显示已经被员工打卡上班占用的模具</text>
</view>
<text class="monitor-mark">监控</text>
</view>
<view wx:if="{{!locks.length}}" class="list-empty inline-empty">暂无占用中的模具</view>
<view wx:else class="mold-grid">
<view
wx:for="{{locks}}"
wx:key="id"
wx:for-index="index"
class="mold-tile"
data-index="{{index}}"
bindtap="openLock"
>
<view class="mold-icon">模</view>
<text class="mold-name">{{item.moldDisplayName}}</text>
<text class="mold-worker">{{item.occupiedName || item.occupiedPhone}}</text>
<text class="mold-duration">{{item.occupiedDurationText}}</text>
</view>
</view>
</view>
</scroll-view>
<view wx:if="{{detailVisible}}" class="modal-mask" catchtap="closeDetail">
<view class="modal-panel" catchtap="noop">
<view class="modal-head">
<text class="modal-title">{{detailType === 'lock' ? '占用详情' : '反馈详情'}}</text>
<button class="btn mini secondary modal-close" bindtap="closeDetail">关闭</button>
</view>
<block wx:if="{{detailType === 'lock' && selectedLock}}">
<view class="detail-row">
<text class="label">模具名</text>
<text class="value">{{selectedLock.moldDisplayName}}</text>
</view>
<view class="detail-row">
<text class="label">占用人员</text>
<text class="value">{{selectedLock.occupiedName || selectedLock.occupiedPhone}}</text>
</view>
<view class="detail-row">
<text class="label">本次占用开始时间</text>
<text class="value">{{selectedLock.occupiedStartAtText}}</text>
</view>
<view class="detail-row">
<text class="label">已占用时长</text>
<text class="value">{{selectedLock.occupiedDurationText}}</text>
</view>
<button class="btn danger full detail-action" bindtap="releaseFromLock">强制解除占用</button>
</block>
<block wx:if="{{detailType === 'feedback' && selectedFeedback}}">
<view class="detail-row">
<text class="label">反馈人员姓名</text>
<text class="value">{{selectedFeedback.reporterName || selectedFeedback.reporterPhone}}</text>
</view>
<view class="detail-row">
<text class="label">占用人员姓名</text>
<text class="value">{{selectedFeedback.occupiedName || selectedFeedback.occupiedPhone}}</text>
</view>
<view class="detail-row">
<text class="label">模具名</text>
<text class="value">{{selectedFeedback.moldDisplayName}}</text>
</view>
<view class="detail-row">
<text class="label">反馈时间</text>
<text class="value">{{selectedFeedback.feedbackAtText}}</text>
</view>
<view class="detail-row">
<text class="label">处理状态</text>
<text class="status-text detail-status {{selectedFeedback.isHandled ? 'handled' : 'pending'}}">{{selectedFeedback.statusName}}</text>
</view>
<button wx:if="{{selectedFeedback.canRelease}}" class="btn danger full detail-action" bindtap="releaseFromFeedback">一键解除占用</button>
</block>
</view>
</view>
+310
View File
@@ -0,0 +1,310 @@
.mail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
}
.mail-card {
min-width: 0;
padding: 22rpx;
border: 3rpx solid #aebbd0;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 0 0 2rpx rgba(255, 255, 255, 0.9),
0 11rpx 0 rgba(147, 163, 185, 0.68),
0 20rpx 32rpx rgba(22, 34, 50, 0.15),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.mail-head,
.mail-title,
.feedback-title-line,
.monitor-head,
.pager {
display: flex;
align-items: center;
}
.mail-head,
.monitor-head {
justify-content: space-between;
gap: 14rpx;
}
.mail-title {
min-width: 0;
gap: 10rpx;
}
.mail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46rpx;
height: 46rpx;
flex: 0 0 46rpx;
border-radius: 12rpx;
color: #ffffff;
font-size: 24rpx;
font-weight: 800;
box-shadow:
0 6rpx 0 rgba(15, 23, 42, 0.16),
inset 0 2rpx 0 rgba(255, 255, 255, 0.28);
}
.mail-icon.unread {
background: linear-gradient(180deg, #ef4444 0%, #b91c1c 100%);
}
.mail-icon.read {
background: linear-gradient(180deg, #64748b 0%, #334155 100%);
}
.mail-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42rpx;
height: 42rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: #ef4444;
color: #ffffff;
font-size: 24rpx;
font-weight: 800;
box-sizing: border-box;
}
.muted-badge {
background: #64748b;
}
.mail-empty {
margin-top: 22rpx;
padding: 22rpx 12rpx;
border: 2rpx dashed #b8c4d4;
border-radius: 14rpx;
color: #667085;
font-size: 24rpx;
text-align: center;
}
.feedback-row {
margin-top: 18rpx;
padding: 18rpx;
border: 2rpx solid #c7d2e3;
border-left: 7rpx solid #64748b;
border-radius: 14rpx;
background: #f8fbff;
box-shadow: 0 7rpx 0 rgba(148, 163, 184, 0.24);
}
.unread-row {
border-left-color: #ef4444;
}
.feedback-title {
display: block;
min-width: 0;
color: #162232;
font-size: 26rpx;
font-weight: 800;
line-height: 1.35;
word-break: break-word;
}
.feedback-title-line {
justify-content: space-between;
gap: 10rpx;
}
.feedback-sub,
.feedback-time,
.mold-worker,
.mold-duration {
display: block;
margin-top: 8rpx;
color: #667085;
font-size: 22rpx;
line-height: 1.35;
}
.status-text {
flex: 0 0 auto;
font-size: 22rpx;
font-weight: 800;
white-space: nowrap;
}
.status-text.pending {
color: #d92d20;
}
.status-text.handled {
color: #087443;
}
.pager {
justify-content: space-between;
gap: 10rpx;
margin-top: 18rpx;
}
.pager .btn {
min-width: 82rpx;
padding: 0 8rpx;
margin: 0;
font-size: 22rpx;
}
.pager .page-jump-control.mini {
flex-basis: 152rpx;
gap: 4rpx;
padding: 0 6rpx;
}
.pager .page-jump-control.mini .page-jump-input {
flex-basis: 42rpx;
}
.pager .page-jump-control.mini .page-total-label {
min-width: 50rpx;
font-size: 18rpx;
}
.pager .page-jump-control.mini .page-jump-action {
width: 28rpx;
font-size: 18rpx;
}
.monitor-card {
margin-bottom: 34rpx;
}
.monitor-mark {
flex: 0 0 auto;
padding: 10rpx 16rpx;
border-radius: 12rpx;
background: #e9f1ff;
color: #1456c2;
font-size: 24rpx;
font-weight: 800;
}
.inline-empty {
margin-top: 22rpx;
margin-bottom: 0;
}
.mold-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150rpx, 1fr));
gap: 18rpx;
margin-top: 24rpx;
}
.mold-tile {
min-height: 210rpx;
padding: 18rpx 14rpx;
border: 2rpx solid #bcc8d9;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #eef5ff 100%);
text-align: center;
box-shadow:
0 8rpx 0 rgba(148, 163, 184, 0.54),
0 16rpx 26rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.mold-icon {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
margin: 0 auto 14rpx;
border-radius: 18rpx;
background: linear-gradient(180deg, #1d4ed8 0%, #153e9f 100%);
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
box-shadow:
0 7rpx 0 rgba(21, 62, 159, 0.42),
inset 0 2rpx 0 rgba(255, 255, 255, 0.28);
}
.mold-name {
display: block;
color: #162232;
font-size: 24rpx;
font-weight: 800;
line-height: 1.35;
word-break: break-word;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
max-height: 82vh;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 24rpx;
}
.modal-title {
color: #162232;
font-size: 34rpx;
font-weight: 700;
}
.modal-close {
flex: 0 0 auto;
margin: 0;
}
.detail-row {
margin-top: 22rpx;
padding-bottom: 18rpx;
border-bottom: 1rpx solid #e4e9f1;
}
.detail-status {
display: block;
font-size: 28rpx;
}
.detail-action {
margin-top: 28rpx;
}
+253
View File
@@ -0,0 +1,253 @@
const api = require('../../utils/api')
const PAGE_SIZE = 10
let usageStatsLoadSeq = 0
let keywordSearchTimer = null
const pad2 = value => String(value).padStart(2, '0')
const formatDate = date => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
const defaultDateRange = () => {
const today = new Date()
return {
startDate: `${today.getFullYear()}-${pad2(today.getMonth() + 1)}-01`,
endDate: formatDate(today),
}
}
const metricLabel = metricKind => (metricKind === 'quantity' ? '数量' : '时长')
const valueLabel = row => {
if (row.metricKind === 'quantity') {
return `${Number(row.value || 0)} 件`
}
return api.formatDurationAllUnits(row.value)
}
const openDocumentFile = filePath => new Promise((resolve, reject) => {
wx.openDocument({
filePath,
fileType: 'xlsx',
showMenu: true,
success: resolve,
fail: () => reject(new Error('打开文件失败')),
})
})
Page({
data: {
...defaultDateRange(),
category: 'device',
sortBy: 'value',
attendancePoints: [],
attendancePointLabels: ['全部考勤点'],
attendancePointIndex: 0,
attendancePointName: '',
keyword: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
loading: false,
exporting: false,
refreshing: false,
scrollTop: 0,
},
onLoad() {
this.init()
},
onUnload() {
if (keywordSearchTimer) {
clearTimeout(keywordSearchTimer)
keywordSearchTimer = null
}
},
async init() {
await this.loadAttendancePoints()
await this.load(1)
},
async loadAttendancePoints() {
try {
const points = await api.listAccessibleAttendancePoints()
const attendancePoints = points || []
this.setData({
attendancePoints,
attendancePointLabels: ['全部考勤点'].concat(attendancePoints.map(item => item.name)),
})
} catch (error) {
wx.showToast({ title: error.message || '加载考勤点失败', icon: 'none' })
}
},
async load(page = this.data.page) {
const loadSeq = usageStatsLoadSeq + 1
usageStatsLoadSeq = loadSeq
this.setData({ loading: true })
try {
const result = await api.listUsageStats({
category: this.data.category,
sortBy: this.data.sortBy,
startDate: this.data.startDate,
endDate: this.data.endDate,
attendancePointName: this.data.attendancePointName,
keyword: this.data.keyword,
page,
pageSize: PAGE_SIZE,
})
if (loadSeq !== usageStatsLoadSeq) {
return
}
const rows = result.rows || []
const maxValue = Math.max(1, ...rows.map(item => Number(item.value || 0)))
this.setData({
rows: rows.map(item => ({
...item,
metricLabel: metricLabel(item.metricKind),
valueText: valueLabel(item),
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxValue * 100)),
uniqueKey: `${item.category || ''}-${item.id || ''}-${item.metricKind || ''}-${item.name || ''}`,
})),
page: result.page || 1,
pageInput: String(result.page || 1),
totalPages: result.totalPages || 1,
scrollTop: 0,
})
} catch (error) {
if (loadSeq === usageStatsLoadSeq) {
wx.showToast({ title: error.message || '加载统计失败', icon: 'none' })
}
} finally {
if (loadSeq === usageStatsLoadSeq) {
this.setData({ loading: false })
}
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
chooseCategory(e) {
const category = e.currentTarget.dataset.category
if (category === this.data.category) {
return
}
this.setData({ category, page: 1 })
this.load(1)
},
chooseSort(e) {
const sortBy = e.currentTarget.dataset.sort
if (sortBy === this.data.sortBy) {
return
}
this.setData({ sortBy, page: 1 })
this.load(1)
},
onStartChange(e) {
this.setData({ startDate: e.detail.value, page: 1 })
this.load(1)
},
onEndChange(e) {
this.setData({ endDate: e.detail.value, page: 1 })
this.load(1)
},
onAttendancePointChange(e) {
const attendancePointIndex = Number(e.detail.value)
const point = attendancePointIndex > 0 ? this.data.attendancePoints[attendancePointIndex - 1] : null
this.setData({
attendancePointIndex,
attendancePointName: point ? point.name : '',
page: 1,
})
this.load(1)
},
onKeywordInput(e) {
this.setData({ keyword: e.detail.value, page: 1 })
if (keywordSearchTimer) {
clearTimeout(keywordSearchTimer)
}
keywordSearchTimer = setTimeout(() => {
keywordSearchTimer = null
this.load(1)
}, 350)
},
searchKeyword() {
if (keywordSearchTimer) {
clearTimeout(keywordSearchTimer)
keywordSearchTimer = null
}
this.setData({ page: 1 })
this.load(1)
},
openDetail(e) {
const row = this.data.rows[Number(e.currentTarget.dataset.index)]
if (!row) {
return
}
const attendancePointName = this.data.attendancePointName || row.attendancePointName || ''
const params = [
['category', row.category || this.data.category],
['id', row.id || ''],
['metricKind', row.metricKind || ''],
['startDate', this.data.startDate],
['endDate', this.data.endDate],
['attendancePointName', attendancePointName],
['name', row.name || ''],
['productName', row.productName || ''],
['processName', row.processName || ''],
['stampingMethod', row.stampingMethod || ''],
].map(pair => `${pair[0]}=${encodeURIComponent(pair[1])}`).join('&')
wx.navigateTo({ url: `/pages/usageStatsDetail/usageStatsDetail?${params}` })
},
async exportFile() {
if (this.data.exporting) {
return
}
this.setData({ exporting: true })
wx.showLoading({ title: '导出中' })
let filePath = ''
try {
filePath = await api.exportUsageStats({
startDate: this.data.startDate,
endDate: this.data.endDate,
attendancePointName: this.data.attendancePointName,
keyword: this.data.keyword,
})
await openDocumentFile(filePath)
} catch (error) {
wx.showToast({ title: error.message || (filePath ? '打开文件失败' : '导出失败'), icon: 'none' })
} finally {
wx.hideLoading()
this.setData({ exporting: false })
}
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.pageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.page, this.data.totalPages))
if (target === this.data.page) {
this.setData({ pageInput: String(this.data.page || 1) })
return
}
this.load(target)
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "设备模具使用统计"
}
+105
View File
@@ -0,0 +1,105 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
scroll-top="{{scrollTop}}"
scroll-with-animation
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">设备模具使用统计</text>
<text class="subtitle">默认统计审核通过且未作废数据</text>
</view>
<view class="card filter-card">
<view class="date-filter-row">
<view class="date-filter-item">
<text class="label">开始日期</text>
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
<view class="picker">{{startDate || '开始日期'}}</view>
</picker>
</view>
<view class="date-filter-item">
<text class="label">结束日期</text>
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
<view class="picker">{{endDate || '结束日期'}}</view>
</picker>
</view>
</view>
<view class="filter-section">
<text class="label">考勤点</text>
<picker range="{{attendancePointLabels}}" value="{{attendancePointIndex}}" bindchange="onAttendancePointChange">
<view class="picker ellipsis">{{attendancePointLabels[attendancePointIndex] || '全部考勤点'}}</view>
</picker>
</view>
<view class="segmented-row">
<button class="segment-btn {{category === 'device' ? 'active' : ''}}" data-category="device" bindtap="chooseCategory">设备</button>
<button class="segment-btn {{category === 'mold' ? 'active' : ''}}" data-category="mold" bindtap="chooseCategory">模具</button>
</view>
<view class="segmented-row">
<button class="segment-btn {{sortBy === 'value' ? 'active' : ''}}" data-sort="value" bindtap="chooseSort">按时长/数量</button>
<button class="segment-btn {{sortBy === 'count' ? 'active' : ''}}" data-sort="count" bindtap="chooseSort">按报工次数</button>
</view>
<button class="btn primary full export-btn" loading="{{exporting}}" bindtap="exportFile">导出统计</button>
</view>
<input
class="input search"
value="{{keyword}}"
placeholder="搜索考勤点、设备、模具、产品、工序、冲压方式"
confirm-type="search"
bindinput="onKeywordInput"
bindconfirm="searchKeyword"
/>
<view wx:if="{{loading && !rows.length}}" class="list-empty">加载中</view>
<view wx:elif="{{!rows.length}}" class="list-empty">暂无统计数据</view>
<view
wx:for="{{rows}}"
wx:key="uniqueKey"
wx:for-index="index"
class="card usage-card"
data-index="{{index}}"
bindtap="openDetail"
>
<view class="row usage-head">
<view class="row-left">
<text class="value usage-name">{{item.name || '-'}}</text>
<text class="subtitle ellipsis">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle ellipsis">{{item.objectType || '-'}} · 报工 {{item.reportCount || 0}} 次</text>
</view>
<text class="pill primary">{{item.metricLabel}}</text>
</view>
<view class="usage-bar-track">
<view class="usage-bar-fill {{item.metricKind === 'quantity' ? 'quantity' : 'duration'}}" style="width: {{item.barPercent}}%;"></view>
</view>
<view class="row usage-value-row">
<text class="label">{{item.metricKind === 'quantity' ? '统计数量' : '使用时长'}}</text>
<text class="value usage-value">{{item.valueText}}</text>
</view>
<view wx:if="{{item.productName || item.processName || item.stampingMethod}}" class="usage-tags">
<text wx:if="{{item.productName}}" class="usage-tag">{{item.productName}}</text>
<text wx:if="{{item.processName}}" class="usage-tag">{{item.processName}}</text>
<text wx:if="{{item.stampingMethod}}" class="usage-tag">{{item.stampingMethod}}</text>
</view>
</view>
<view class="button-row pager-row usage-pager" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control usage-page-jump">
<input class="page-jump-input usage-page-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label usage-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage">跳</text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
+150
View File
@@ -0,0 +1,150 @@
.filter-card {
padding-bottom: 24rpx;
}
.date-filter-row {
display: flex;
gap: 16rpx;
}
.date-filter-item {
min-width: 0;
flex: 1 1 0;
}
.filter-section {
margin-top: 20rpx;
}
.search {
margin-bottom: 20rpx;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.segmented-row {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
}
.segment-btn {
flex: 1 1 0;
min-width: 0;
height: 72rpx;
line-height: 72rpx;
padding: 0 10rpx;
border: 1rpx solid #cbd7e6;
border-radius: 14rpx;
background: #f7faff;
color: #475467;
font-size: 25rpx;
font-weight: 700;
box-sizing: border-box;
}
.segment-btn.active {
border-color: #1463ff;
background: #eaf2ff;
color: #1456c2;
}
.export-btn {
margin-top: 20rpx;
}
.usage-card {
padding-bottom: 24rpx;
}
.usage-card:active {
transform: translateY(3rpx);
}
.usage-head {
align-items: flex-start;
}
.usage-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-bar-track {
overflow: hidden;
height: 18rpx;
margin-top: 22rpx;
border-radius: 999rpx;
background: #e8eef6;
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.1);
}
.usage-bar-fill {
height: 100%;
min-width: 12rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #5b8def 100%);
}
.usage-bar-fill.quantity {
background: linear-gradient(90deg, #087443 0%, #4ab37c 100%);
}
.usage-value-row {
align-items: flex-start;
margin-top: 16rpx;
}
.usage-value {
flex: 1 1 auto;
min-width: 0;
max-width: none;
overflow: visible;
line-height: 1.45;
text-align: right;
text-overflow: clip;
white-space: normal;
word-break: break-all;
}
.usage-tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.usage-tag {
max-width: 100%;
padding: 7rpx 12rpx;
border-radius: 10rpx;
background: #eef2f7;
color: #667085;
font-size: 22rpx;
line-height: 1.3;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-pager {
gap: 12rpx;
}
.usage-page-jump {
flex-basis: 236rpx;
}
.usage-page-input {
flex-basis: 72rpx;
}
.usage-total-label {
min-width: 86rpx;
}
+101
View File
@@ -0,0 +1,101 @@
const api = require('../../utils/api')
const decodeOption = value => {
if (value === undefined || value === null) {
return ''
}
try {
return decodeURIComponent(String(value))
} catch (error) {
return String(value)
}
}
const valueText = (value, metricKind) => {
const numericValue = Number(value || 0)
if (metricKind === 'quantity') {
return `${numericValue} 件`
}
return api.formatDurationAllUnits(numericValue)
}
const metricTitle = metricKind => (metricKind === 'quantity' ? '清洗数量' : '总使用时长')
const normalizeFilters = options => ({
category: decodeOption(options.category),
id: decodeOption(options.id),
metricKind: decodeOption(options.metricKind),
startDate: decodeOption(options.startDate),
endDate: decodeOption(options.endDate),
attendancePointName: decodeOption(options.attendancePointName),
name: decodeOption(options.name),
productName: decodeOption(options.productName),
processName: decodeOption(options.processName),
stampingMethod: decodeOption(options.stampingMethod),
})
const decorateDetail = detail => {
const metricKind = detail.metricKind || ''
const dailyRows = detail.dailyRows || []
const maxDailyValue = Math.max(1, ...dailyRows.map(item => Number(item.value || 0)))
return {
...detail,
metricTitle: metricTitle(metricKind),
valueText: valueText(detail.value, metricKind),
dailyRows: dailyRows.map(item => ({
...item,
valueText: valueText(item.value, metricKind),
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxDailyValue * 100)),
})),
reportRows: (detail.reportRows || []).map(item => ({
...item,
valueText: valueText(item.value, metricKind),
employeeText: item.employeeName || item.employeePhone || '-',
})),
tags: Array.isArray(detail.tags) ? detail.tags : [],
}
}
Page({
data: {
filters: {},
detail: null,
loading: false,
loadError: false,
errorText: '',
},
onLoad(options) {
const filters = normalizeFilters(options || {})
this.setData({ filters })
this.load(filters)
},
async load(filters = this.data.filters) {
const loadSeq = (this.loadSeq || 0) + 1
this.loadSeq = loadSeq
this.setData({ loading: true, loadError: false, errorText: '' })
try {
const detail = await api.getUsageStatsDetail(filters)
if (loadSeq !== this.loadSeq) {
return
}
this.setData({ detail: decorateDetail(detail || {}), loadError: false, errorText: '' })
} catch (error) {
if (loadSeq === this.loadSeq) {
const errorText = error.message || '加载详情失败'
this.setData({ detail: null, loadError: true, errorText })
wx.showToast({ title: errorText, icon: 'none' })
}
} finally {
if (loadSeq === this.loadSeq) {
this.setData({ loading: false })
}
}
},
reload() {
this.load()
},
})
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "统计详情"
}
@@ -0,0 +1,70 @@
<scroll-view class="page safe-bottom" scroll-y type="list">
<view class="header">
<text class="title ellipsis">{{detail.name || filters.name || '-'}}</text>
<text class="subtitle ellipsis">考勤点 {{detail.attendancePointName || filters.attendancePointName || '-'}}</text>
<text class="subtitle ellipsis">{{detail.objectType || '-'}}</text>
</view>
<view wx:if="{{loading && !detail && !loadError}}" class="list-empty">加载中</view>
<view wx:elif="{{loadError}}" class="card error-card">
<text class="value">加载失败</text>
<text class="subtitle error-text">{{errorText || '加载详情失败'}}</text>
<button class="btn secondary reload-btn" loading="{{loading}}" bindtap="reload">重新加载</button>
</view>
<block wx:elif="{{detail}}">
<view class="card metric-card">
<view class="metric-grid">
<view class="metric-item">
<text class="label">{{detail.metricTitle}}</text>
<text class="value metric-value">{{detail.valueText}}</text>
</view>
<view class="metric-item">
<text class="label">报工次数</text>
<text class="value metric-value">{{detail.reportCount || 0}} 次</text>
</view>
</view>
<view wx:if="{{detail.productName || detail.processName || detail.stampingMethod || detail.tags.length}}" class="tag-list">
<text wx:if="{{detail.productName}}" class="usage-tag">{{detail.productName}}</text>
<text wx:if="{{detail.processName}}" class="usage-tag">{{detail.processName}}</text>
<text wx:if="{{detail.stampingMethod}}" class="usage-tag">{{detail.stampingMethod}}</text>
<text wx:for="{{detail.tags}}" wx:key="*this" class="usage-tag">{{item}}</text>
</view>
</view>
<text class="section-title">每日趋势</text>
<view wx:if="{{!detail.dailyRows.length}}" class="list-empty">暂无每日趋势</view>
<view wx:for="{{detail.dailyRows}}" wx:key="reportDate" class="card trend-card">
<view class="row trend-row">
<view class="row-left">
<text class="value">{{item.reportDate || '-'}}</text>
<text class="subtitle">报工 {{item.reportCount || 0}} 次</text>
</view>
<text class="value trend-value">{{item.valueText}}</text>
</view>
<view class="usage-bar-track">
<view class="usage-bar-fill {{detail.metricKind === 'quantity' ? 'quantity' : ''}}" style="width: {{item.barPercent}}%;"></view>
</view>
</view>
<text class="section-title">关联报工</text>
<view wx:if="{{!detail.reportRows.length}}" class="list-empty">暂无关联报工</view>
<view wx:for="{{detail.reportRows}}" wx:key="reportId" class="card report-card">
<view class="row report-head">
<view class="row-left">
<text class="value ellipsis">{{item.employeeText}}</text>
<text class="subtitle">{{item.reportDate || '-'}}</text>
</view>
<text class="value report-value">{{item.valueText}}</text>
</view>
<view class="report-meta">
<text class="subtitle ellipsis">{{item.displayName || '-'}}</text>
<text class="subtitle report-count">报工 {{item.reportCount || 0}} 次</text>
</view>
</view>
</block>
<view wx:else class="list-empty">暂无统计详情</view>
</scroll-view>
@@ -0,0 +1,125 @@
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-card {
padding-bottom: 24rpx;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
}
.metric-item {
min-width: 0;
padding: 18rpx;
border-radius: 12rpx;
background: #f5f8fc;
box-sizing: border-box;
}
.metric-value {
overflow: visible;
line-height: 1.45;
text-overflow: clip;
white-space: normal;
word-break: break-all;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 18rpx;
}
.usage-tag {
max-width: 100%;
padding: 7rpx 12rpx;
border-radius: 10rpx;
background: #eef2f7;
color: #667085;
font-size: 22rpx;
line-height: 1.3;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-card {
padding-bottom: 24rpx;
}
.error-text {
word-break: break-all;
}
.reload-btn {
margin-top: 20rpx;
}
.trend-card,
.report-card {
padding-bottom: 24rpx;
}
.trend-row,
.report-head {
align-items: flex-start;
}
.trend-value,
.report-value {
flex: 1 1 260rpx;
min-width: 240rpx;
max-width: 380rpx;
overflow: visible;
line-height: 1.45;
text-align: right;
text-overflow: clip;
white-space: normal;
word-break: break-all;
}
.usage-bar-track {
overflow: hidden;
height: 18rpx;
margin-top: 20rpx;
border-radius: 999rpx;
background: #e8eef6;
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.1);
}
.usage-bar-fill {
height: 100%;
min-width: 12rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #5b8def 100%);
}
.usage-bar-fill.quantity {
background: linear-gradient(90deg, #087443 0%, #4ab37c 100%);
}
.report-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 12rpx;
}
.report-meta .subtitle {
min-width: 0;
margin-top: 0;
}
.report-count {
flex: 0 0 auto;
white-space: nowrap;
}
+479
View File
@@ -0,0 +1,479 @@
const api = require('../../utils/api')
const DEFAULT_POINT_NAME = '宁波嘉恒智能科技有限公司'
const defaultForm = {
dayStart: '08:00',
dayEnd: '17:20',
lunchStart: '11:40',
lunchEnd: '12:40',
dinnerStart: '17:20',
dinnerEnd: '18:00',
overtimeStart: '18:00',
overtimeEnd: '20:00',
nightStart: '20:00',
nightEnd: '06:00',
attendanceLatitude: '',
attendanceLongitude: '',
attendanceRadiusMeters: 500,
autoSubmitHours: 15,
attendancePoints: [],
}
const scheduleFields = [
'dayStart',
'dayEnd',
'lunchStart',
'lunchEnd',
'dinnerStart',
'dinnerEnd',
'overtimeStart',
'overtimeEnd',
'nightStart',
'nightEnd',
]
const defaultScheduleValues = () => scheduleFields.reduce((result, field) => ({
...result,
[field]: defaultForm[field],
}), {})
const emptyPoint = () => ({
localKey: `new_${Date.now()}`,
name: '',
originalName: '',
latitude: '',
longitude: '',
radiusMeters: 500,
...defaultScheduleValues(),
remark: '',
isActive: true,
})
Page({
data: {
form: { ...defaultForm },
updatedAtText: '',
refreshing: false,
saving: false,
locating: false,
locationAccuracyText: '',
expandedPointKey: '',
},
onShow() {
this.load()
},
async load() {
try {
const schedule = await api.getWorkSchedule()
this.setData({
form: this.buildForm(schedule),
updatedAtText: schedule.updatedAtText || '',
expandedPointKey: '',
})
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
}
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load()
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
onTimeChange(e) {
const field = e.currentTarget.dataset.field
const pointIndex = e.currentTarget.dataset.index
if (pointIndex !== undefined && pointIndex !== '') {
this.setData({
[`form.attendancePoints[${pointIndex}].${field}`]: e.detail.value,
})
return
}
this.setData({ [`form.${field}`]: e.detail.value })
},
buildForm(schedule = {}) {
return {
dayStart: schedule.dayStart || defaultForm.dayStart,
dayEnd: schedule.dayEnd || defaultForm.dayEnd,
lunchStart: schedule.lunchStart || defaultForm.lunchStart,
lunchEnd: schedule.lunchEnd || defaultForm.lunchEnd,
dinnerStart: schedule.dinnerStart || defaultForm.dinnerStart,
dinnerEnd: schedule.dinnerEnd || defaultForm.dinnerEnd,
overtimeStart: schedule.overtimeStart || defaultForm.overtimeStart,
overtimeEnd: schedule.overtimeEnd || defaultForm.overtimeEnd,
nightStart: schedule.nightStart || defaultForm.nightStart,
nightEnd: schedule.nightEnd || defaultForm.nightEnd,
attendanceLatitude: schedule.attendanceLatitude === '' || schedule.attendanceLatitude === undefined || schedule.attendanceLatitude === null
? ''
: this.formatCoordinate(schedule.attendanceLatitude),
attendanceLongitude: schedule.attendanceLongitude === '' || schedule.attendanceLongitude === undefined || schedule.attendanceLongitude === null
? ''
: this.formatCoordinate(schedule.attendanceLongitude),
attendanceRadiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
autoSubmitHours: schedule.autoSubmitHours || defaultForm.autoSubmitHours,
attendancePoints: this.buildAttendancePoints(schedule),
}
},
buildPointSchedule(point = {}, schedule = {}) {
return scheduleFields.reduce((result, field) => ({
...result,
[field]: point[field] || schedule[field] || defaultForm[field],
}), {})
},
buildAttendancePoints(schedule = {}) {
const source = (schedule.attendancePoints || []).length
? schedule.attendancePoints
: [{
name: DEFAULT_POINT_NAME,
latitude: schedule.attendanceLatitude,
longitude: schedule.attendanceLongitude,
radiusMeters: schedule.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
remark: '',
isActive: true,
}]
return source.map((point, index) => ({
localKey: point.name || `point_${index}`,
name: point.name || '',
originalName: point.originalName || point.name || '',
latitude: point.latitude === '' || point.latitude === undefined || point.latitude === null ? '' : this.formatCoordinate(point.latitude),
longitude: point.longitude === '' || point.longitude === undefined || point.longitude === null ? '' : this.formatCoordinate(point.longitude),
radiusMeters: point.radiusMeters || defaultForm.attendanceRadiusMeters,
...this.buildPointSchedule(point, schedule),
remark: point.remark || '',
isActive: point.isActive !== false,
}))
},
onRadiusInput(e) {
this.setData({
'form.attendanceRadiusMeters': e.detail.value,
})
},
onAutoSubmitHoursInput(e) {
this.setData({
'form.autoSubmitHours': e.detail.value,
})
},
onCoordinateInput(e) {
const field = e.currentTarget.dataset.field
this.setData({
[`form.${field}`]: e.detail.value,
})
},
onPointInput(e) {
const index = e.currentTarget.dataset.index
const field = e.currentTarget.dataset.field
this.setData({
[`form.attendancePoints[${index}].${field}`]: e.detail.value,
})
},
addAttendancePoint() {
const point = emptyPoint()
this.setData({
'form.attendancePoints': this.data.form.attendancePoints.concat(point),
expandedPointKey: point.localKey,
})
},
toggleAttendancePoint(e) {
const key = e.currentTarget.dataset.key
this.setData({
expandedPointKey: this.data.expandedPointKey === key ? '' : key,
})
},
formatCoordinate(value) {
const number = Number(value)
return Number.isFinite(number) ? number.toFixed(6) : ''
},
formatAccuracy(value) {
const number = Number(value)
if (!Number.isFinite(number)) {
return ''
}
return number < 10 ? `${number.toFixed(1)}米` : `${Math.round(number)}米`
},
sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
},
requestHighAccuracyLocation() {
return new Promise((resolve, reject) => {
wx.getLocation({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 8000,
success: resolve,
fail: reject,
})
})
},
normalizeLocation(res) {
const latitude = Number(res && res.latitude)
const longitude = Number(res && res.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null
}
return {
latitude,
longitude,
accuracy: Number(res && res.accuracy),
}
},
pickBetterLocation(current, next) {
if (!next) {
return current
}
if (!current) {
return next
}
const currentAccuracy = Number(current.accuracy)
const nextAccuracy = Number(next.accuracy)
if (!Number.isFinite(currentAccuracy)) {
return next
}
if (!Number.isFinite(nextAccuracy)) {
return current
}
return nextAccuracy < currentAccuracy ? next : current
},
async getBestCurrentLocation() {
let best = null
let lastError = null
for (let index = 0; index < 3; index += 1) {
try {
const sample = this.normalizeLocation(await this.requestHighAccuracyLocation())
best = this.pickBetterLocation(best, sample)
if (sample && Number.isFinite(sample.accuracy) && sample.accuracy <= 30) {
break
}
} catch (error) {
lastError = error
}
if (index < 2) {
await this.sleep(700)
}
}
if (best) {
return best
}
throw lastError || new Error('获取位置失败')
},
async useCurrentLocation(e) {
const pointIndex = e && e.currentTarget ? e.currentTarget.dataset.index : undefined
this.setData({ locating: true })
try {
const location = await this.getBestCurrentLocation()
const accuracyText = this.formatAccuracy(location.accuracy)
const updates = {
locationAccuracyText: accuracyText ? `定位精度约 ${accuracyText}` : '',
}
if (pointIndex !== undefined && pointIndex !== '') {
updates[`form.attendancePoints[${pointIndex}].latitude`] = this.formatCoordinate(location.latitude)
updates[`form.attendancePoints[${pointIndex}].longitude`] = this.formatCoordinate(location.longitude)
} else {
updates['form.attendanceLatitude'] = this.formatCoordinate(location.latitude)
updates['form.attendanceLongitude'] = this.formatCoordinate(location.longitude)
}
this.setData(updates)
if (Number.isFinite(location.accuracy) && location.accuracy > 100) {
wx.showModal({
title: '定位精度偏低',
content: '当前只拿到模糊定位,请在系统和微信里开启精确位置,或手动修正经纬度后保存。',
showCancel: false,
})
} else {
wx.showToast({ title: '已获取当前位置', icon: 'success' })
}
} catch (error) {
const message = error && error.errMsg ? error.errMsg : ''
wx.showToast({
title: message.indexOf('auth deny') >= 0 ? '请允许获取位置' : '获取位置失败',
icon: 'none',
})
} finally {
this.setData({ locating: false })
}
},
resetDefault() {
this.setData({
form: {
...this.data.form,
...defaultScheduleValues(),
attendanceLatitude: this.data.form.attendanceLatitude,
attendanceLongitude: this.data.form.attendanceLongitude,
attendanceRadiusMeters: this.data.form.attendanceRadiusMeters || defaultForm.attendanceRadiusMeters,
autoSubmitHours: this.data.form.autoSubmitHours || defaultForm.autoSubmitHours,
attendancePoints: (this.data.form.attendancePoints || []).map(point => ({
...point,
...defaultScheduleValues(),
})),
},
})
},
validateSchedule(point, name) {
for (let index = 0; index < scheduleFields.length; index += 1) {
const field = scheduleFields[index]
const value = String(point[field] || '').trim()
if (!/^\d{1,2}:\d{2}$/.test(value)) {
wx.showToast({ title: `${name} 作息时间格式错误`, icon: 'none' })
return false
}
const parts = value.split(':')
const hour = Number(parts[0])
const minute = Number(parts[1])
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
wx.showToast({ title: `${name} 作息时间范围错误`, icon: 'none' })
return false
}
}
return true
},
validateAttendancePoints(points) {
const names = new Set()
for (let index = 0; index < points.length; index += 1) {
const point = points[index]
const name = String(point.name || '').trim()
if (!name) {
wx.showToast({ title: `第${index + 1}个考勤点缺少名称`, icon: 'none' })
return false
}
if (names.has(name)) {
wx.showToast({ title: `考勤点名称重复:${name}`, icon: 'none' })
return false
}
names.add(name)
const latitudeText = String(point.latitude || '').trim()
const longitudeText = String(point.longitude || '').trim()
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
wx.showToast({ title: `${name} 经纬度需要同时填写`, icon: 'none' })
return false
}
if (latitudeText || longitudeText) {
const latitude = Number(latitudeText)
const longitude = Number(longitudeText)
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
wx.showToast({ title: `${name} 纬度范围为-90到90`, icon: 'none' })
return false
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
wx.showToast({ title: `${name} 经度范围为-180到180`, icon: 'none' })
return false
}
}
const radius = Number(point.radiusMeters)
if (!Number.isFinite(radius) || radius <= 0) {
wx.showToast({ title: `${name} 请输入有效考勤半径`, icon: 'none' })
return false
}
if (!this.validateSchedule(point, name)) {
return false
}
}
return true
},
async removePoint(e) {
const index = e.currentTarget.dataset.index
const point = this.data.form.attendancePoints[index]
if (!point) {
return
}
if (!point.originalName) {
const nextPoints = this.data.form.attendancePoints.filter((_, itemIndex) => itemIndex !== Number(index))
this.setData({
'form.attendancePoints': nextPoints,
expandedPointKey: this.data.expandedPointKey === point.localKey ? '' : this.data.expandedPointKey,
})
return
}
wx.showModal({
title: '停用考勤点',
content: `确认停用 ${point.name}?已有历史业务数据会保留。`,
confirmText: '停用',
confirmColor: '#d92d20',
success: async res => {
if (!res.confirm) {
return
}
try {
await api.deleteAttendancePoint(point.originalName)
wx.showToast({ title: '已停用', icon: 'success' })
await this.load()
} catch (error) {
wx.showToast({ title: error.message || '停用失败', icon: 'none' })
}
},
})
},
async save() {
const radius = Number(this.data.form.attendanceRadiusMeters)
if (!Number.isFinite(radius) || radius <= 0) {
wx.showToast({ title: '请输入有效考勤半径', icon: 'none' })
return
}
const latitudeText = String(this.data.form.attendanceLatitude || '').trim()
const longitudeText = String(this.data.form.attendanceLongitude || '').trim()
const autoSubmitHours = Number(this.data.form.autoSubmitHours)
if (!Number.isFinite(autoSubmitHours) || autoSubmitHours < 1 || autoSubmitHours > 168) {
wx.showToast({ title: '系统自动提交时长需在1到168小时之间', icon: 'none' })
return
}
if ((latitudeText && !longitudeText) || (!latitudeText && longitudeText)) {
wx.showToast({ title: '经纬度需要同时填写', icon: 'none' })
return
}
if (latitudeText || longitudeText) {
const latitude = Number(latitudeText)
const longitude = Number(longitudeText)
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
wx.showToast({ title: '纬度范围为-90到90', icon: 'none' })
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
wx.showToast({ title: '经度范围为-180到180', icon: 'none' })
return
}
}
this.setData({ saving: true })
wx.showLoading({ title: '保存中' })
try {
const points = this.data.form.attendancePoints || []
if (!points.length) {
wx.showToast({ title: '请至少维护一个考勤点', icon: 'none' })
return
}
if (!this.validateAttendancePoints(points)) {
return
}
const primaryPoint = points[0] || {}
const schedulePayload = {
...this.data.form,
...this.buildPointSchedule(primaryPoint, this.data.form),
attendanceLatitude: primaryPoint.latitude || this.data.form.attendanceLatitude,
attendanceLongitude: primaryPoint.longitude || this.data.form.attendanceLongitude,
attendanceRadiusMeters: primaryPoint.radiusMeters || this.data.form.attendanceRadiusMeters,
attendancePoints: points.map(point => ({
...point,
name: String(point.name || '').trim(),
originalName: point.originalName || point.name,
})),
}
const schedule = await api.saveWorkScheduleSettings(schedulePayload)
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({
form: this.buildForm(schedule),
updatedAtText: schedule.updatedAtText || '',
})
await this.load()
} catch (error) {
wx.showToast({ title: error.message || '保存失败', icon: 'none' })
} finally {
this.setData({ saving: false })
wx.hideLoading()
}
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "考勤设置"
}
+199
View File
@@ -0,0 +1,199 @@
<scroll-view
class="page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<text class="title">考勤设置</text>
<text class="subtitle">按考勤点维护作息时间、就餐时间和扫码考勤范围</text>
</view>
<view class="card auto-submit-card">
<view class="form-item">
<text class="label">系统自动提交时长(小时)</text>
<input
class="input"
type="digit"
value="{{form.autoSubmitHours}}"
placeholder="默认15小时"
bindinput="onAutoSubmitHoursInput"
/>
<text class="subtitle">超过该时长未下班报工,系统将自动提交待审核报工。</text>
</view>
</view>
<view class="section-title section-title-action">
<text>考勤范围</text>
<button class="btn mini primary add-point-btn" bindtap="addAttendancePoint">+</button>
</view>
<view
wx:for="{{form.attendancePoints}}"
wx:key="localKey"
wx:for-index="pointIndex"
wx:for-item="point"
class="card location-card {{expandedPointKey === point.localKey ? 'expanded' : 'collapsed'}}"
>
<view class="location-head" data-key="{{point.localKey}}" bindtap="toggleAttendancePoint">
<view class="location-text">
<text class="schedule-name">{{point.name || '新考勤点'}}</text>
<block wx:if="{{expandedPointKey === point.localKey}}">
<text class="subtitle">一个考勤点对应一个分公司,可独立配置作息和扫码范围</text>
<text wx:if="{{locationAccuracyText}}" class="location-accuracy">{{locationAccuracyText}}</text>
</block>
</view>
<button
wx:if="{{expandedPointKey === point.localKey}}"
class="btn primary locate-btn"
loading="{{locating}}"
data-index="{{pointIndex}}"
catchtap="useCurrentLocation"
>使用当前位置</button>
</view>
<block wx:if="{{expandedPointKey === point.localKey}}">
<view class="form-item">
<text class="label">考勤点名称</text>
<input
class="input"
value="{{point.name}}"
placeholder="请输入考勤点名称"
data-index="{{pointIndex}}"
data-field="name"
bindinput="onPointInput"
/>
</view>
<view class="point-section-title">作息时间</view>
<view class="schedule-panel">
<view class="schedule-row">
<text class="schedule-name">白班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.dayStart}}" data-index="{{pointIndex}}" data-field="dayStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dayStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.dayEnd}}" data-index="{{pointIndex}}" data-field="dayEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dayEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">夜班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.nightStart}}" data-index="{{pointIndex}}" data-field="nightStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.nightStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.nightEnd}}" data-index="{{pointIndex}}" data-field="nightEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.nightEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">中饭就餐</text>
<view class="time-range">
<picker mode="time" value="{{point.lunchStart}}" data-index="{{pointIndex}}" data-field="lunchStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.lunchStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.lunchEnd}}" data-index="{{pointIndex}}" data-field="lunchEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.lunchEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row">
<text class="schedule-name">晚饭就餐</text>
<view class="time-range">
<picker mode="time" value="{{point.dinnerStart}}" data-index="{{pointIndex}}" data-field="dinnerStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dinnerStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.dinnerEnd}}" data-index="{{pointIndex}}" data-field="dinnerEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.dinnerEnd}}</view>
</picker>
</view>
</view>
<view class="schedule-row last">
<text class="schedule-name">加班时间段</text>
<view class="time-range">
<picker mode="time" value="{{point.overtimeStart}}" data-index="{{pointIndex}}" data-field="overtimeStart" bindchange="onTimeChange">
<view class="picker time-picker">{{point.overtimeStart}}</view>
</picker>
<text class="range-sep">至</text>
<picker mode="time" value="{{point.overtimeEnd}}" data-index="{{pointIndex}}" data-field="overtimeEnd" bindchange="onTimeChange">
<view class="picker time-picker">{{point.overtimeEnd}}</view>
</picker>
</view>
</view>
</view>
<view class="point-section-title">考勤范围</view>
<view class="location-grid">
<view class="location-cell">
<text class="label">纬度</text>
<input
class="input coordinate-input mono"
type="digit"
value="{{point.latitude}}"
placeholder="请输入纬度"
data-index="{{pointIndex}}"
data-field="latitude"
bindinput="onPointInput"
/>
</view>
<view class="location-cell">
<text class="label">经度</text>
<input
class="input coordinate-input mono"
type="digit"
value="{{point.longitude}}"
placeholder="请输入经度"
data-index="{{pointIndex}}"
data-field="longitude"
bindinput="onPointInput"
/>
</view>
</view>
<view class="form-item radius-item">
<text class="label">考勤范围半径(米)</text>
<input
class="input"
type="number"
value="{{point.radiusMeters}}"
placeholder="请输入米数"
data-index="{{pointIndex}}"
data-field="radiusMeters"
bindinput="onPointInput"
/>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea
class="textarea"
value="{{point.remark}}"
placeholder="可填写分公司或厂区说明"
data-index="{{pointIndex}}"
data-field="remark"
bindinput="onPointInput"
></textarea>
</view>
<button class="btn danger full" data-index="{{pointIndex}}" catchtap="removePoint">停用/移除考勤点</button>
</block>
</view>
<view class="card save-card">
<text wx:if="{{updatedAtText}}" class="subtitle">最近更新 {{updatedAtText}}</text>
<view class="button-row">
<button class="btn secondary full" bindtap="resetDefault">恢复默认</button>
<button class="btn primary full" loading="{{saving}}" bindtap="save">保存设置</button>
</view>
</view>
</scroll-view>
+186
View File
@@ -0,0 +1,186 @@
.schedule-panel {
margin-bottom: 26rpx;
padding: 6rpx 20rpx;
border: 2rpx solid #d7e1ef;
border-radius: 16rpx;
background: #f8fbff;
}
.schedule-row {
display: grid;
grid-template-columns: 178rpx minmax(0, 1fr);
align-items: center;
gap: 18rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #e6edf6;
}
.schedule-row.last {
border-bottom: 0;
}
.schedule-name {
color: #162232;
font-size: 27rpx;
font-weight: 700;
line-height: 1.35;
}
.time-range {
display: grid;
grid-template-columns: minmax(0, 1fr) 42rpx minmax(0, 1fr);
align-items: center;
gap: 12rpx;
}
.time-picker {
min-height: 74rpx;
justify-content: center;
padding: 0 10rpx;
color: #162232;
font-size: 27rpx;
font-weight: 700;
}
.range-sep {
color: #6b778c;
text-align: center;
font-size: 24rpx;
font-weight: 700;
}
.save-card {
padding-top: 22rpx;
}
.auto-submit-card {
padding: 24rpx;
}
.point-section-title {
margin: 28rpx 0 16rpx;
color: #1f64d8;
font-size: 25rpx;
font-weight: 900;
line-height: 1.35;
}
.location-head + .point-section-title {
margin-top: 0;
}
.save-card .subtitle {
margin-top: 0;
margin-bottom: 18rpx;
}
.section-title-action {
justify-content: space-between;
}
.section-title-action text {
flex: 1;
}
.section-title-action::before {
margin-right: 0;
}
.add-point-btn {
width: 64rpx;
min-width: 64rpx;
padding: 0;
font-size: 40rpx;
font-weight: 900;
}
.location-card {
padding: 24rpx;
}
.location-card.collapsed {
padding: 0;
}
.location-card.expanded .location-head {
margin-bottom: 22rpx;
}
.location-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.location-card.collapsed .location-head {
min-height: 92rpx;
padding: 0 24rpx;
}
.location-text {
min-width: 0;
flex: 1;
}
.location-text .schedule-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.location-accuracy {
display: block;
margin-top: 8rpx;
color: #1f64d8;
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
}
.locate-btn {
flex: 0 0 220rpx;
margin: 0;
padding: 0 14rpx;
}
.location-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.location-cell {
min-width: 0;
padding: 18rpx;
border: 2rpx solid #d1dced;
border-radius: 14rpx;
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
box-shadow:
0 8rpx 0 rgba(181, 194, 213, 0.68),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
box-sizing: border-box;
}
.mono {
font-family: DIN Alternate, Arial, sans-serif;
font-size: 26rpx;
word-break: break-all;
}
.coordinate-input {
width: 100%;
margin-top: 8rpx;
padding: 0 14rpx;
color: #162232;
font-weight: 800;
text-align: left;
box-sizing: border-box;
}
.radius-item {
margin-top: 28rpx;
}