chore: initialize mini program frontend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit fa73a3c184
96 changed files with 20561 additions and 0 deletions
+612
View File
@@ -0,0 +1,612 @@
const api = require('../../utils/api')
const locationGuard = require('../../utils/locationGuard')
Page({
data: {
user: null,
moldName: '',
processName: '',
attendancePointName: '',
stampingMethod: '',
isCleaning: false,
isMisc: false,
moldDisplayName: '',
sceneToken: '',
state: null,
roleChoices: [],
selectionToken: '',
toastVisible: false,
toastMessage: '',
locationChecking: true,
locationAllowed: false,
locationMessage: '正在校验当前位置',
currentLocation: null,
occupiedDetail: null,
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
},
toastTimer: null,
onLoad(options) {
this.resolveOpenMold(options)
},
async resolveOpenMold(options) {
const scene = this.decodeRepeated(options.scene || '')
let moldName = this.extractMoldName(options, scene)
let processName = this.extractProcessName(options, scene)
let attendancePointName = this.decodeRepeated(options.attendancePointName || options.attendance_point_name || '')
let stampingMethod = this.decodeRepeated(options.stampingMethod || options.stamping_method || '')
let isCleaning = false
let isMisc = false
let moldDisplayName = api.moldDisplayName(moldName, processName, stampingMethod)
const sceneToken = this.extractSceneToken(scene || options.moldName || '')
if (!moldName && sceneToken && api.getCurrentUser()) {
try {
const resolved = await api.resolveMoldScene(sceneToken)
moldName = resolved.moldName
processName = resolved.processName
attendancePointName = resolved.attendancePointName || ''
stampingMethod = resolved.stampingMethod || ''
isCleaning = !!resolved.isCleaning
isMisc = !!resolved.isMisc
moldDisplayName = resolved.displayName
} catch (error) {
this.showTip(error.message || '未找到模具二维码')
}
}
this.setData({ moldName, processName, attendancePointName, stampingMethod, isCleaning, isMisc, moldDisplayName, sceneToken })
this.verifyOpenLocation()
},
async resolveSceneAfterLogin() {
if (this.data.moldName || !this.data.sceneToken) {
return true
}
try {
const resolved = await api.resolveMoldScene(this.data.sceneToken)
this.setData({
moldName: resolved.moldName,
processName: resolved.processName,
attendancePointName: resolved.attendancePointName || '',
stampingMethod: resolved.stampingMethod || '',
isCleaning: !!resolved.isCleaning,
isMisc: !!resolved.isMisc,
moldDisplayName: resolved.displayName,
})
await this.verifyOpenLocation()
return false
} catch (error) {
this.showTip(error.message || '未找到模具二维码')
return false
}
},
extractMoldName(options, scene) {
const explicit = options.moldName || options.deviceNo
if (explicit) {
const value = this.decodeRepeated(explicit)
return value.startsWith('mold=') ? '' : value
}
const raw = this.decodeRepeated(scene)
const moldMatched = raw.match(/(?:^|[?&])?moldName=([^&]+)/)
if (moldMatched && moldMatched[1]) {
return this.decodeRepeated(moldMatched[1])
}
const oldMatched = raw.match(/(?:^|[?&])?deviceNo=([^&]+)/)
if (oldMatched && oldMatched[1]) {
return this.decodeRepeated(oldMatched[1])
}
if (raw.startsWith('mold=')) {
return ''
}
return raw
},
extractProcessName(options, scene) {
const explicit = options.processName || options.process_name
if (explicit) {
return this.decodeRepeated(explicit)
}
const raw = this.decodeRepeated(scene)
const matched = raw.match(/(?:^|[?&])?(?:processName|process_name)=([^&]+)/)
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
},
extractSceneToken(scene) {
const raw = this.decodeRepeated(scene)
const matched = raw.match(/(?:^|[?&])?mold=([^&]+)/)
return matched && matched[1] ? this.decodeRepeated(matched[1]) : ''
},
decodeRepeated(value) {
let text = String(value || '').trim()
for (let index = 0; index < 3; index += 1) {
try {
const decoded = decodeURIComponent(text)
if (decoded === text) {
break
}
text = decoded
} catch (error) {
break
}
}
return text
},
isCleaningProcess() {
return !!this.data.isCleaning
|| String(this.data.stampingMethod || '').trim() === '清洗'
},
openCleaningReport() {
const moldName = encodeURIComponent(this.data.moldName || '')
const processName = encodeURIComponent(this.data.processName || '')
const stampingMethod = encodeURIComponent(this.data.stampingMethod || '')
const displayName = encodeURIComponent(this.data.moldDisplayName || '')
const attendancePointName = encodeURIComponent(this.data.attendancePointName || '')
wx.redirectTo({
url: `/pages/cleaningReport/cleaningReport?moldName=${moldName}&processName=${processName}&attendancePointName=${attendancePointName}&stampingMethod=${stampingMethod}&isCleaning=1&displayName=${displayName}`,
})
},
onShow() {
if (this.data.locationAllowed) {
this.load()
}
},
onUnload() {
if (this.toastTimer) {
clearTimeout(this.toastTimer)
this.toastTimer = null
}
},
showTip(message, duration = 5000) {
if (this.toastTimer) {
clearTimeout(this.toastTimer)
}
this.setData({
toastVisible: true,
toastMessage: String(message || ''),
})
this.toastTimer = setTimeout(() => {
this.setData({ toastVisible: false, toastMessage: '' })
this.toastTimer = null
}, duration)
},
async verifyOpenLocation() {
this.setData({
locationChecking: true,
locationAllowed: false,
locationMessage: '正在校验当前位置',
})
try {
const locationScope = api.getCurrentUser() ? 'accessible' : 'public'
const locationResult = await locationGuard.ensureFactoryLocation(this.data.attendancePointName, locationScope)
this.setData({
locationChecking: false,
locationAllowed: true,
locationMessage: '',
currentLocation: locationResult.current,
})
await this.load()
} catch (error) {
this.setData({
locationChecking: false,
locationAllowed: false,
locationMessage: error.message || '当前位置不在允许范围内,不能扫码报工',
currentLocation: null,
user: null,
state: null,
})
}
},
async load() {
let user = api.getCurrentUser()
let state = null
this.setData({ occupiedDetail: null })
if (user && !this.data.moldName && this.data.sceneToken) {
const shouldContinue = await this.resolveSceneAfterLogin()
if (!shouldContinue) {
return
}
}
if (user && this.data.moldName) {
try {
user = await api.refreshCurrentUser() || user
if (this.isCleaningProcess()) {
this.setData({ user, state: null }, () => {
this.openCleaningReport()
})
return
}
state = await api.getClockState(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
if (state && state.isCleaning) {
this.setData({
user,
state: null,
stampingMethod: state.stampingMethod || '',
isCleaning: true,
isMisc: false,
}, () => {
this.openCleaningReport()
})
return
}
if (state && state.isMisc) {
this.setData({
stampingMethod: state.stampingMethod || '',
isMisc: true,
moldDisplayName: state.displayName || this.data.moldDisplayName,
})
}
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
user = api.getCurrentUser()
this.setData({ user, state: null })
return
}
if (await this.handleUnfinishedReportError(error)) {
user = api.getCurrentUser()
this.setData({ user, state: null })
return
}
this.showTip(error.message)
user = api.getCurrentUser()
}
}
this.setData({ user, state })
},
async wechatLogin(e) {
const phoneCode = e.detail && e.detail.code
if (!phoneCode) {
this.showTip('未获得手机号授权', 3000)
return
}
wx.showLoading({ title: '登录中' })
try {
const result = await api.loginWithPhoneCode(phoneCode)
if (result.needsTemporaryWorker) {
wx.hideLoading()
await this.confirmTemporaryWorker(result)
return
}
if (result.needsSelection) {
this.setData({
roleChoices: result.matchedPeople,
selectionToken: result.selectionToken,
})
return
}
await this.load()
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
}
},
confirmTemporaryWorker(result) {
return new Promise(resolve => {
this.temporaryWorkerResult = result
this.temporaryWorkerResolve = resolve
this.setData({
temporaryWorkerModalVisible: true,
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
temporaryWorkerName: '',
})
})
},
onTemporaryWorkerNameInput(e) {
this.setData({ temporaryWorkerName: e.detail.value })
},
closeTemporaryWorkerModal() {
this.setData({
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
})
this.temporaryWorkerResult = null
if (this.temporaryWorkerResolve) {
this.temporaryWorkerResolve()
this.temporaryWorkerResolve = null
}
},
async submitTemporaryWorker() {
const result = this.temporaryWorkerResult
const name = String(this.data.temporaryWorkerName || '').trim()
if (!name) {
this.showTip('请输入姓名', 3000)
return
}
if (!result || !result.temporaryToken) {
this.showTip('临时工注册信息已失效,请重新登录')
this.closeTemporaryWorkerModal()
return
}
this.setData({ temporaryWorkerModalVisible: false })
wx.showLoading({ title: '定位中' })
try {
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
wx.showLoading({ title: '创建中' })
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
await this.load()
wx.showToast({ title: '临时工已生效', icon: 'success' })
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
this.closeTemporaryWorkerModal()
}
},
async chooseRole(e) {
const role = e.currentTarget.dataset.role
wx.showLoading({ title: '登录中' })
try {
await api.selectRole(this.data.selectionToken, role)
this.setData({ roleChoices: [], selectionToken: '' })
await this.load()
} catch (error) {
this.showTip(error.message)
} finally {
wx.hideLoading()
}
},
canOperate() {
const { user, moldName, state } = this.data
if (!user) {
this.showTip('请先登录', 3000)
return false
}
if (user.role !== 'worker') {
this.showTip('只有冲压工人可以报工', 3000)
return false
}
if (!moldName) {
this.showTip('缺少模具名称', 3000)
return false
}
if (!state) {
this.showTip('当前模具码不可用', 3000)
return false
}
return true
},
formatAutoSubmitHours(value) {
const hours = Number(value || 15)
if (!Number.isFinite(hours)) {
return '15'
}
return Number.isInteger(hours) ? String(hours) : String(Math.round(hours * 100) / 100)
},
confirmStartWork() {
const hoursText = this.formatAutoSubmitHours(this.data.state && this.data.state.autoSubmitHours)
return new Promise(resolve => {
wx.showModal({
title: '开始上班',
content: `该班次时长最多为${hoursText}个小时。${hoursText}小时后未下班报工,系统将自动报工`,
confirmText: '开始上班',
cancelText: '取消',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
},
async switchMold() {
if (!this.canOperate()) {
return
}
try {
await api.switchDevice(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
wx.showToast({ title: '已记录换模具', icon: 'success' })
await this.load()
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
async finishWork() {
if (!this.canOperate()) {
return
}
try {
const session = await api.finishWork(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${session.id}`,
})
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
async handleAction() {
if (!this.canOperate()) {
return
}
const { moldName, state } = this.data
try {
if (state.action === 'start') {
const confirmed = await this.confirmStartWork()
if (!confirmed) {
return
}
await api.startWork(moldName, this.data.processName, this.data.attendancePointName, this.data.currentLocation)
wx.showToast({ title: '已开始上班', icon: 'success' })
await this.load()
return
}
if (state.action === 'switch') {
await this.switchMold()
return
}
if (state.action === 'report' || (state.action === 'finish' && state.endAtText && state.session)) {
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${state.session.id}`,
})
return
}
if (state.action === 'refinish') {
await this.finishWork()
return
}
await this.finishWork()
} catch (error) {
if (await this.handleMoldOccupiedError(error)) {
return
}
if (await this.handleUnfinishedReportError(error)) {
return
}
this.showTip(error.message)
}
},
resolveUnfinishedReportDetail(error) {
if (!error || error.statusCode !== 400) {
return null
}
let detail = error.detail
if (typeof detail === 'string') {
try {
detail = JSON.parse(detail)
} catch (parseError) {
detail = null
}
}
return detail && detail.code === 'unfinished_report_mold_mismatch' ? detail : null
},
async openUnfinishedReport(detail) {
let sessionId = detail.session_id
if (detail.reporting_expired) {
wx.showLoading({ title: '处理中' })
try {
const session = await api.finishWork(
this.data.moldName,
this.data.processName,
this.data.attendancePointName,
this.data.currentLocation,
)
sessionId = session.id
} finally {
wx.hideLoading()
}
}
wx.navigateTo({
url: `/pages/reportForm/reportForm?sessionId=${sessionId}`,
})
},
handleUnfinishedReportError(error) {
const detail = this.resolveUnfinishedReportDetail(error)
if (!detail || !detail.session_id) {
return Promise.resolve(false)
}
const message = detail.message || error.message || '当前有未完成的报工'
return new Promise(resolve => {
wx.showModal({
title: '未完成报工',
content: `${message}\n是否立刻前往未完成的报工?`,
cancelText: '否',
confirmText: '是',
confirmColor: '#1463ff',
success: async res => {
if (!res.confirm) {
resolve(true)
return
}
try {
await this.openUnfinishedReport(detail)
} catch (openError) {
this.showTip(openError.message || '打开未完成报工失败')
} finally {
resolve(true)
}
},
fail: () => resolve(true),
})
})
},
resolveMoldOccupiedDetail(error) {
if (!error || error.statusCode !== 423) {
return null
}
let detail = error.detail
if (typeof detail === 'string') {
try {
detail = JSON.parse(detail)
} catch (parseError) {
detail = null
}
}
return detail && detail.code === 'mold_occupied' ? detail : null
},
handleMoldOccupiedError(error) {
const detail = this.resolveMoldOccupiedDetail(error)
if (!detail) {
return Promise.resolve(false)
}
this.setData({ occupiedDetail: detail })
return new Promise(resolve => {
wx.showModal({
title: '模具已被占用',
content: detail.message || `已经被${detail.occupied_name || detail.occupied_phone || '其他员工'}占用,请联系管理员解除占用`,
confirmText: '反馈给管理员',
cancelText: '确定',
confirmColor: '#1463ff',
success: async res => {
if (!res.confirm) {
resolve(true)
return
}
wx.showLoading({ title: '反馈中' })
try {
await api.createMoldLockFeedback(detail.lock_id)
wx.showToast({ title: '反馈成功', icon: 'success' })
} catch (feedbackError) {
this.showTip(feedbackError.message || '反馈失败')
} finally {
wx.hideLoading()
resolve(true)
}
},
fail: () => resolve(true),
})
})
},
async feedbackMoldOccupied() {
const detail = this.data.occupiedDetail
if (!detail || !detail.lock_id) {
this.showTip('占用信息缺失,无法反馈')
return
}
wx.showLoading({ title: '反馈中' })
try {
await api.createMoldLockFeedback(detail.lock_id)
wx.showToast({ title: '反馈成功', icon: 'success' })
} catch (error) {
this.showTip(error.message || '反馈失败')
} finally {
wx.hideLoading()
}
},
noop() {},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "报工打卡"
}
+108
View File
@@ -0,0 +1,108 @@
<view class="page safe-bottom">
<view class="header">
<text class="title">报工打卡</text>
<view class="device-badge">
<text class="device-label">考勤点</text>
<text class="subtitle">{{attendancePointName || '-'}}</text>
<text class="device-label">当前模具</text>
<text class="device-no">{{moldDisplayName || moldName || '-'}}</text>
</view>
</view>
<block wx:if="{{locationAllowed}}">
<view class="card">
<view class="row">
<view>
<text class="label">上班打卡时间</text>
<text class="value">{{state.startAtText || '-'}}</text>
</view>
<view class="text-right">
<text class="label">下班打卡时间</text>
<text class="value">{{state.endAtText || '-'}}</text>
</view>
</view>
</view>
<view class="card">
<view class="row">
<view class="row-left">
<text class="label">当前人员</text>
<block wx:if="{{user}}">
<text class="value">{{user.name}} · {{user.phone}}</text>
<text class="subtitle">{{user.role === 'worker' ? '冲压工人' : user.role === 'admin' ? '管理员' : '经理'}}</text>
</block>
<text wx:else class="value">未登录</text>
</view>
</view>
</view>
<view wx:if="{{!user}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<view wx:if="{{state && user && state.action === 'switch_or_finish'}}" class="clock-action-row">
<button class="btn secondary clock-action-btn" bindtap="switchMold">换模具</button>
<button class="btn primary clock-action-btn" bindtap="finishWork">打卡下班</button>
</view>
<button
wx:elif="{{state && user}}"
class="btn primary full clock-btn"
bindtap="handleAction"
>
{{state.actionText}}
</button>
<view wx:elif="{{occupiedDetail}}" class="card occupied-card">
<text class="value">模具已被占用</text>
<text class="subtitle">{{occupiedDetail.message || '该模具已被其他员工占用,请联系管理员解除占用'}}</text>
<view class="button-row">
<button class="btn secondary full" bindtap="feedbackMoldOccupied">反馈给管理员</button>
</view>
</view>
<view wx:else class="list-empty">当前模具码不可用</view>
</block>
<view wx:else class="card">
<text class="value">{{locationChecking ? '正在校验位置' : '无法打开报工页面'}}</text>
<text class="subtitle">{{locationMessage}}</text>
</view>
<view wx:if="{{toastVisible}}" class="clock-toast">
<text>{{toastMessage}}</text>
</view>
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
<view class="temp-worker-panel" catchtap="noop">
<text class="temp-worker-title">创建临时工</text>
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
<view class="temp-worker-field">
<text class="temp-worker-label">姓名</text>
<input
class="input temp-worker-input"
value="{{temporaryWorkerName}}"
placeholder="请输入姓名"
bindinput="onTemporaryWorkerNameInput"
confirm-type="done"
cursor-spacing="24"
/>
</view>
<view class="button-row temp-worker-actions">
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
</view>
</view>
</view>
</view>
+105
View File
@@ -0,0 +1,105 @@
.clock-btn {
margin-top: 36rpx;
height: 104rpx;
line-height: 104rpx;
font-size: 32rpx;
border-radius: 18rpx;
}
.clock-action-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
margin-top: 36rpx;
}
.clock-action-btn {
width: 100%;
height: 104rpx;
margin: 0;
padding: 0;
line-height: 104rpx;
border-radius: 18rpx;
font-size: 32rpx;
box-sizing: border-box;
}
.occupied-card {
margin-top: 30rpx;
border-color: #d92d20;
background: linear-gradient(180deg, #fffafa 0%, #fff1f1 100%);
}
.device-badge {
display: inline-flex;
align-items: center;
gap: 16rpx;
margin-top: 18rpx;
padding: 14rpx 22rpx;
border: 1rpx solid #b8c4d4;
border-radius: 18rpx;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(225, 232, 241, 0.92)),
#edf2f7;
box-shadow:
0 9rpx 0 rgba(173, 185, 201, 0.72),
0 16rpx 22rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.92);
}
.device-label {
color: #667085;
font-size: 24rpx;
font-weight: 700;
}
.device-no {
color: #162232;
font-size: 46rpx;
font-weight: 800;
line-height: 1;
}
.role-list {
margin-top: 22rpx;
}
.role-btn {
margin-top: 12rpx;
height: 72rpx;
line-height: 72rpx;
border-radius: 14rpx;
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
color: #1456c2;
font-size: 26rpx;
font-weight: 600;
box-shadow:
0 7rpx 0 rgba(144, 178, 235, 0.62),
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
}
.role-btn::after {
border: 0;
}
.clock-toast {
position: fixed;
left: 72rpx;
right: 72rpx;
top: 48%;
z-index: 120;
padding: 24rpx 28rpx;
border-radius: 18rpx;
background: rgba(32, 37, 45, 0.9);
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
line-height: 1.5;
text-align: center;
word-break: break-all;
box-shadow:
0 12rpx 0 rgba(15, 23, 42, 0.2),
0 22rpx 34rpx rgba(15, 23, 42, 0.2);
box-sizing: border-box;
}