chore: initialize mini program frontend
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "清洗报工"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "报工打卡"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "报工看板"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "模具二维码"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "通知配置"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "人员清单"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "产品清单"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "报工记录"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "下班报工"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationStyle": "custom",
|
||||
"disableSwipeBack": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "报工审核"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "智能监控",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "设备模具使用统计"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "考勤设置"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user