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
+634
View File
@@ -0,0 +1,634 @@
const api = require('../../utils/api')
const locationGuard = require('../../utils/locationGuard')
const avatarKey = phone => `jh_wrs_avatar_${phone}`
Page({
data: {
activeTab: 'home',
currentUser: null,
currentRoleName: '',
currentRolesText: '',
canSwitchRole: false,
avatarPath: '',
avatarText: '',
roleChoices: [],
selectionToken: '',
summary: {},
showOverview: false,
showWorkerTrend: false,
workerTrend: {
total: 0,
max: 0,
points: [],
segments: [],
},
notices: [],
noticeList: [],
actions: [],
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
locationInitialized: false,
loading: false,
refreshing: false,
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
},
onShow() {
this.load({ refreshLocation: !this.data.locationInitialized }).catch(error => {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
})
},
async load(options = {}) {
let currentUser = api.getCurrentUser()
let summary = {}
let showOverview = false
let showWorkerTrend = false
let workerTrend = { total: 0, max: 0, points: [], segments: [] }
let notices = []
let locationView = {
currentAttendancePointName: this.data.currentAttendancePointName,
locationStatusText: this.data.locationStatusText,
locationEntered: this.data.locationEntered,
}
let locationInitialized = this.data.locationInitialized
if (currentUser) {
try {
currentUser = await api.refreshCurrentUser() || currentUser
showOverview = currentUser.role !== 'worker'
showWorkerTrend = currentUser.role === 'worker'
if (showOverview) {
summary = await api.getHomeSummary(currentUser)
}
if (showWorkerTrend) {
workerTrend = await this.loadWorkerTrend()
}
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
currentUser = api.getCurrentUser()
showOverview = !!currentUser && currentUser.role !== 'worker'
showWorkerTrend = !!currentUser && currentUser.role === 'worker'
}
if (currentUser) {
notices = await this.loadNotices()
if (options.refreshLocation || !locationInitialized) {
locationView = await this.refreshLocationStatus()
locationInitialized = true
}
}
} else {
locationInitialized = false
locationView = {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
}
const actions = this.buildActions(currentUser)
const overviewItems = this.buildOverviewItems(currentUser, summary)
const userView = this.buildUserView(currentUser)
this.setData({
currentUser,
summary,
overviewItems,
showOverview,
showWorkerTrend,
workerTrend,
notices,
noticeList: notices.slice(0, 3),
actions,
...locationView,
locationInitialized,
...userView,
})
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load({ refreshLocation: true })
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
async loadNotices() {
try {
const result = await api.listNotices({ page: 1, pageSize: 6 })
return result.rows || []
} catch (error) {
return []
}
},
async refreshLocationStatus() {
try {
const result = await locationGuard.getCurrentAttendancePoint()
if (result && result.factory) {
return {
currentAttendancePointName: result.factory.name,
locationStatusText: `当前位置:已进入 ${result.factory.name} 考勤点`,
locationEntered: true,
}
}
} catch (error) {
return {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
}
return {
currentAttendancePointName: '',
locationStatusText: '当前位置:未进入考勤点',
locationEntered: false,
}
},
buildUserView(user) {
if (!user) {
return {
currentRoleName: '',
currentRolesText: '',
canSwitchRole: false,
avatarPath: '',
avatarText: '',
}
}
return {
currentRoleName: user.roleName || api.roleNames[user.role] || user.role,
currentRolesText: (user.roleNames || []).join('、') || user.roleName || '',
canSwitchRole: !!(user.rolesDisplay && user.rolesDisplay.length > 1),
avatarPath: wx.getStorageSync(avatarKey(user.phone)) || '',
avatarText: this.buildDefaultAvatarText(user.name),
}
},
buildOverviewItems(user, summary = {}) {
if (!user || user.role === 'worker') {
return []
}
const today = this.formatDate(new Date())
return [
{
key: 'products',
label: '产品',
value: summary.productCount || 0,
url: '/pages/manageProducts/manageProducts?readonly=1',
},
{
key: 'people',
label: '人员',
value: summary.peopleCount || 0,
url: '/pages/managePeople/managePeople?readonly=1',
},
...(user.role === 'admin' ? [{
key: 'pending',
label: '待审核',
value: summary.pendingCount || 0,
alert: (summary.pendingCount || 0) > 0,
url: '/pages/records/records?mode=pending&status=pending',
}] : []),
...(user.role === 'admin' ? [{
key: 'approved',
label: '今日通过',
value: summary.approvedToday || 0,
url: `/pages/records/records?mode=audit&status=approved&startDate=${today}&endDate=${today}`,
}] : []),
]
},
buildDefaultAvatarText(name) {
const text = String(name || '').trim()
if (!text) {
return '嘉'
}
const givenName = text.length > 1 ? text.slice(1) : text
return givenName.slice(0, 2)
},
switchTab(e) {
const activeTab = e.currentTarget.dataset.tab
this.setData({ activeTab })
},
buildRecentDates(days = 7) {
const dates = []
const today = new Date()
for (let index = days - 1; index >= 0; index -= 1) {
const date = new Date(today)
date.setDate(today.getDate() - index)
const value = this.formatDate(date)
dates.push({
value,
label: value.slice(5),
})
}
return dates
},
formatDate(date) {
const year = date.getFullYear()
const monthValue = date.getMonth() + 1
const dayValue = date.getDate()
const month = monthValue < 10 ? `0${monthValue}` : `${monthValue}`
const day = dayValue < 10 ? `0${dayValue}` : `${dayValue}`
return `${year}-${month}-${day}`
},
async loadWorkerTrend() {
const dates = this.buildRecentDates(7)
const result = await api.listReports({
startDate: dates[0].value,
endDate: dates[dates.length - 1].value,
page: 1,
pageSize: 100,
})
const totals = dates.reduce((map, item) => {
map[item.value] = 0
return map
}, {})
;(result.rows || []).forEach(report => {
if (totals[report.reportDate] === undefined) {
return
}
totals[report.reportDate] += Number(report.metrics && report.metrics.totalGood || 0)
})
const points = dates.map(item => ({
date: item.value,
label: item.label,
value: totals[item.value] || 0,
}))
const total = points.reduce((sum, item) => sum + item.value, 0)
const max = points.reduce((largest, item) => Math.max(largest, item.value), 0)
return this.buildTrendView(total, max, points)
},
buildTrendView(total, max, points) {
const maxValue = Math.max(1, max || 0)
const lastIndex = Math.max(1, points.length - 1)
const chartInset = 8
const chartSpan = 100 - chartInset * 2
const plottedPoints = points.map((item, index) => {
const left = chartInset + (index * chartSpan / lastIndex)
const top = 82 - (Number(item.value || 0) / maxValue) * 68
return {
...item,
left: left.toFixed(2),
top: top.toFixed(2),
edgeClass: index === 0 ? 'first' : (index === points.length - 1 ? 'last' : ''),
valueText: String(item.value || 0),
}
})
const segments = []
for (let index = 0; index < plottedPoints.length - 1; index += 1) {
const start = plottedPoints[index]
const end = plottedPoints[index + 1]
const dx = Number(end.left) - Number(start.left)
const dy = Number(end.top) - Number(start.top)
const dxRpx = dx * 5.6
const dyRpx = dy * 2.2
const width = Math.sqrt(dxRpx * dxRpx + dyRpx * dyRpx) / 5.6
const angle = Math.atan2(dyRpx, dxRpx) * 180 / Math.PI
segments.push({
left: start.left,
top: start.top,
width: width.toFixed(2),
angle: angle.toFixed(2),
})
}
return { total, max, points: plottedPoints, segments }
},
buildActions(user) {
if (!user) {
return []
}
if (user.role === 'worker') {
return [
{ key: 'records', icon: '记', title: '我的报工记录', desc: '按日期查看提交和审核状态', url: '/pages/records/records' },
]
}
if (user.role === 'admin') {
return [
{ key: 'products', icon: '产', title: '产品清单', desc: '导入、添加、修改产品', url: '/pages/manageProducts/manageProducts' },
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
{ key: 'review', icon: '审', title: '报工审核', desc: '处理待审核报工', url: '/pages/review/review' },
{ key: 'audit', icon: '核', title: '审核记录', desc: '查看已审核报工', url: '/pages/records/records?mode=audit' },
{ key: 'monitor', icon: '监', title: '智能监控', desc: '查看模具占用和反馈', url: '/pages/smartMonitor/smartMonitor' },
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
{ key: 'moldQr', icon: '码', title: '模具二维码', desc: '生成模具扫码入口', url: '/pages/deviceQr/deviceQr' },
{ key: 'equipment', icon: '设', title: '设备管理', desc: '维护设备号和备注', url: '/pages/manageEquipment/manageEquipment' },
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
]
}
return [
{ key: 'products', icon: '产', title: '产品清单', desc: '导入或添加新产品', url: '/pages/manageProducts/manageProducts' },
{ key: 'people', icon: '人', title: '人员清单', desc: '维护员工、管理员和经理', url: '/pages/managePeople/managePeople' },
{ key: 'schedule', icon: '勤', title: '考勤设置', desc: '维护作息时间和考勤范围', url: '/pages/workSchedule/workSchedule' },
{ key: 'ledger', icon: '账', title: '对账小账本', desc: '按月核对最后工序成品数量', url: '/pages/reconciliationLedger/reconciliationLedger' },
{ key: 'dashboard', icon: '看', title: '报工看板', desc: '按日期查看工人日报', url: '/pages/dashboard/dashboard' },
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
{ key: 'notices', icon: '通', title: '通知配置', desc: '维护首页通知公告', url: '/pages/manageNotices/manageNotices' },
]
},
decodeRepeated(value) {
let text = String(value || '').trim()
for (let index = 0; index < 3; index += 1) {
try {
const decoded = decodeURIComponent(text)
if (decoded === text) {
break
}
text = decoded
} catch (error) {
break
}
}
return text
},
extractMoldName(scanResult) {
const raw = this.decodeRepeated(scanResult)
if (!raw) {
return ''
}
const patterns = [
/(?:^|[?&])moldName=([^&]+)/i,
/(?:^|[?&])mold_name=([^&]+)/i,
/(?:^|[?&])deviceNo=([^&]+)/i,
/(?:^|[?&])device_no=([^&]+)/i,
/(?:^|[?&])scene=([^&]+)/i,
/moldName=([^&]+)/i,
/deviceNo=([^&]+)/i,
]
for (let index = 0; index < patterns.length; index += 1) {
const matched = raw.match(patterns[index])
if (matched && matched[1]) {
const value = this.decodeRepeated(matched[1])
if (value.includes('moldName=') || value.includes('deviceNo=')) {
return this.extractMoldName(value)
}
return String(value || '').trim()
}
}
return raw
},
async scanDevice() {
wx.showLoading({ title: '定位中' })
try {
await locationGuard.ensureFactoryLocation()
} catch (error) {
wx.hideLoading()
wx.showModal({
title: '无法扫码报工',
content: error.message || '当前位置不在允许范围内',
showCancel: false,
})
return
}
wx.hideLoading()
wx.scanCode({
onlyFromCamera: false,
success: async res => {
const candidates = [res.path, res.result, res.rawData]
const moldName = candidates.reduce((found, item) => (
found || this.extractMoldName(item)
), '')
if (!moldName) {
wx.showToast({ title: '未识别模具名称', icon: 'none' })
return
}
const paramName = moldName.startsWith('mold=') ? 'scene' : 'moldName'
if (paramName === 'scene') {
try {
wx.showLoading({ title: '校验中' })
const resolved = await api.resolveMoldScene(moldName)
await locationGuard.ensureFactoryLocation(resolved.attendancePointName)
} catch (error) {
wx.showModal({
title: '无法扫码报工',
content: error.message || '当前位置不在该考勤点范围内',
showCancel: false,
})
return
} finally {
wx.hideLoading()
}
}
wx.navigateTo({
url: `/pages/clock/clock?${paramName}=${encodeURIComponent(moldName)}`,
})
},
fail: error => {
if (error.errMsg && error.errMsg.includes('cancel')) {
return
}
wx.showToast({ title: '扫码失败', icon: 'none' })
},
})
},
async wechatLogin(e) {
const phoneCode = e.detail && e.detail.code
if (!phoneCode) {
wx.showToast({ title: '未获得手机号授权', icon: 'none' })
return
}
wx.showLoading({ title: '登录中' })
try {
const result = await api.loginWithPhoneCode(phoneCode)
if (result.needsTemporaryWorker) {
wx.hideLoading()
await this.confirmTemporaryWorker(result)
return
}
if (result.needsSelection) {
this.setData({
roleChoices: result.matchedPeople,
selectionToken: result.selectionToken,
})
return
}
await this.load({ refreshLocation: true })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
confirmTemporaryWorker(result) {
return new Promise(resolve => {
this.temporaryWorkerResult = result
this.temporaryWorkerResolve = resolve
this.setData({
temporaryWorkerModalVisible: true,
temporaryWorkerPrompt: `该手机号${result.temporaryPhone || ''}不在人员清单中。临时工账号有效期只有24小时,请填写姓名后创建。`,
temporaryWorkerName: '',
})
})
},
onTemporaryWorkerNameInput(e) {
this.setData({ temporaryWorkerName: e.detail.value })
},
closeTemporaryWorkerModal() {
this.setData({
temporaryWorkerModalVisible: false,
temporaryWorkerPrompt: '',
temporaryWorkerName: '',
})
this.temporaryWorkerResult = null
if (this.temporaryWorkerResolve) {
this.temporaryWorkerResolve()
this.temporaryWorkerResolve = null
}
},
async submitTemporaryWorker() {
const result = this.temporaryWorkerResult
const name = String(this.data.temporaryWorkerName || '').trim()
if (!name) {
wx.showToast({ title: '请输入姓名', icon: 'none' })
return
}
if (!result || !result.temporaryToken) {
wx.showToast({ title: '临时工注册信息已失效,请重新登录', icon: 'none' })
this.closeTemporaryWorkerModal()
return
}
this.setData({ temporaryWorkerModalVisible: false })
wx.showLoading({ title: '定位中' })
try {
const attendancePoint = await locationGuard.ensureTemporaryWorkerLocation()
wx.showLoading({ title: '创建中' })
await api.createTemporaryWorker(result.temporaryToken, name, attendancePoint)
await this.load({ refreshLocation: true })
wx.showToast({ title: '临时工已生效', icon: 'success' })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
this.closeTemporaryWorkerModal()
}
},
logout() {
api.logout()
this.setData({ locationInitialized: false })
this.load()
},
chooseAvatar() {
const user = this.data.currentUser
if (!user) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const save = tempFilePath => {
const saveToStorage = path => {
wx.setStorageSync(avatarKey(user.phone), path)
this.setData({ avatarPath: path })
}
if (wx.saveFile) {
wx.saveFile({
tempFilePath,
success: res => saveToStorage(res.savedFilePath),
fail: () => saveToStorage(tempFilePath),
})
return
}
saveToStorage(tempFilePath)
}
if (wx.chooseMedia) {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: res => {
const file = res.tempFiles && res.tempFiles[0]
if (file && file.tempFilePath) {
save(file.tempFilePath)
}
},
})
return
}
wx.chooseImage({
count: 1,
sourceType: ['album', 'camera'],
success: res => {
const path = res.tempFilePaths && res.tempFilePaths[0]
if (path) {
save(path)
}
},
})
},
switchCurrentRole() {
const user = this.data.currentUser
const choices = (user && user.rolesDisplay ? user.rolesDisplay : [])
.filter(item => item.role !== user.role)
if (!choices.length) {
wx.showToast({ title: '没有可切换的角色', icon: 'none' })
return
}
wx.showActionSheet({
itemList: choices.map(item => item.roleName),
success: async res => {
const target = choices[res.tapIndex]
if (!target) {
return
}
wx.showLoading({ title: '切换中' })
try {
await api.switchRole(target.role)
await this.load({ refreshLocation: true })
wx.showToast({ title: '已切换角色', icon: 'success' })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
})
},
async chooseRole(e) {
const role = e.currentTarget.dataset.role
wx.showLoading({ title: '登录中' })
try {
await api.selectRole(this.data.selectionToken, role)
this.setData({ roleChoices: [], selectionToken: '' })
await this.load({ refreshLocation: true })
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
openAction(e) {
wx.navigateTo({
url: e.currentTarget.dataset.url,
})
},
openOverview(e) {
const url = e.currentTarget.dataset.url
const disabledText = e.currentTarget.dataset.disabledText
if (!url) {
wx.showToast({ title: disabledText || '当前角色无权限', icon: 'none' })
return
}
wx.navigateTo({ url })
},
noop() {},
resetDemo() {
wx.showModal({
title: '退出登录',
content: '退出当前账号后需要重新进行手机号快捷登录。',
success: res => {
if (res.confirm) {
api.logout()
this.setData({ roleChoices: [], selectionToken: '', locationInitialized: false })
this.load()
}
},
})
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {
}
}
+268
View File
@@ -0,0 +1,268 @@
<view class="index-shell">
<scroll-view
class="page index-page safe-bottom"
scroll-y
type="list"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onPullDownRefresh"
>
<view class="header">
<view class="header-row">
<view class="brand-block">
<text class="title metallic-title">嘉恒智能报工</text>
<text class="subtitle">宁波嘉恒智能科技有限公司</text>
<view wx:if="{{currentUser}}" class="location-status">
<text>当前位置:</text>
<block wx:if="{{locationEntered}}">
<text>已进入 </text>
<text class="location-point-name">{{currentAttendancePointName}}</text>
<text> 考勤点</text>
</block>
<text wx:else class="location-not-entered">未进入考勤点</text>
</view>
<text wx:if="{{currentUser}}" class="location-refresh-tip">下拉刷新位置</text>
</view>
<view wx:if="{{currentUser}}" class="header-user" bindtap="switchCurrentRole">
<view class="avatar small-avatar">
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
<text wx:else class="avatar-text">{{avatarText}}</text>
</view>
<view class="header-user-text">
<text class="header-name">{{currentUser.name}}</text>
<view class="role-line">
<text>{{currentRoleName}}</text>
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
</view>
</view>
</view>
</view>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'home'}}">
<button class="scan-btn" bindtap="scanDevice">扫一扫</button>
<view wx:if="{{currentUser}}" class="notice-card">
<view class="notice-hero">
<view class="notice-badge">通知</view>
<swiper wx:if="{{notices.length}}" class="notice-swiper" autoplay circular vertical interval="3200" duration="420">
<swiper-item wx:for="{{notices}}" wx:key="id">
<view class="notice-slide">
<text class="notice-title">{{item.title}}</text>
<text class="notice-content">{{item.content}}</text>
</view>
</swiper-item>
</swiper>
<view wx:else class="notice-empty">
<text class="notice-title">暂无通知</text>
<text class="notice-content">今天暂无新的通知。</text>
</view>
</view>
<view wx:if="{{noticeList.length}}" class="notice-list">
<view wx:for="{{noticeList}}" wx:key="id" class="notice-list-row">
<text class="notice-dot"></text>
<text class="notice-list-title">{{item.title}}</text>
</view>
</view>
</view>
<view wx:if="{{!currentUser}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<block wx:if="{{showOverview}}">
<view class="section-title">概览</view>
<view class="grid">
<view
wx:for="{{overviewItems}}"
wx:key="key"
class="stat tappable-stat stat-{{item.key}} {{item.alert ? 'stat-alert' : ''}}"
data-url="{{item.url}}"
data-disabled-text="{{item.disabledText}}"
bindtap="openOverview"
>
<text class="label">{{item.label}}</text>
<text class="stat-number">{{item.value || 0}}</text>
<text class="stat-link">查看清单</text>
</view>
</view>
</block>
<block wx:if="{{showWorkerTrend}}">
<view class="section-title">最近7天成品数量</view>
<view class="card trend-card">
<view class="row trend-head">
<view>
<text class="label">7天合计</text>
<text class="value">{{workerTrend.total || 0}}</text>
</view>
<view class="text-right">
<text class="label">单日最高</text>
<text class="value">{{workerTrend.max || 0}}</text>
</view>
</view>
<view class="trend-plot">
<view class="trend-axis trend-axis-top">{{workerTrend.max || 0}}</view>
<view class="trend-axis trend-axis-bottom">0</view>
<view class="trend-plot-body">
<view class="trend-grid-line top"></view>
<view class="trend-grid-line middle"></view>
<view class="trend-grid-line bottom"></view>
<view
wx:for="{{workerTrend.segments}}"
wx:key="index"
class="trend-segment"
style="left: {{item.left}}%; top: {{item.top}}%; width: {{item.width}}%; transform: rotate({{item.angle}}deg);"
></view>
<view
wx:for="{{workerTrend.points}}"
wx:key="date"
class="trend-point-wrap {{item.edgeClass}}"
style="left: {{item.left}}%; top: {{item.top}}%;"
>
<text wx:if="{{item.value}}" class="trend-point-value">{{item.valueText}}</text>
<text class="trend-point"></text>
</view>
</view>
<view class="trend-label-row">
<text
wx:for="{{workerTrend.points}}"
wx:key="date"
class="trend-date-label {{item.edgeClass}}"
style="left: {{item.left}}%;"
>{{item.label}}</text>
</view>
</view>
</view>
</block>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'workbench'}}">
<view class="section-title">工作台</view>
<view wx:if="{{!actions.length}}" class="list-empty">请选择账号</view>
<view wx:else class="workbench-panel">
<view class="app-grid">
<view
wx:for="{{actions}}"
wx:key="key"
class="app-item"
data-url="{{item.url}}"
data-action="{{item.action}}"
bindtap="openAction"
>
<view class="app-icon app-icon-{{item.key}}">{{item.icon}}</view>
<text class="app-title">{{item.title}}</text>
<text class="app-desc">{{item.desc}}</text>
</view>
</view>
</view>
</view>
<view class="tab-pane" hidden="{{activeTab !== 'mine'}}">
<view class="section-title">我的</view>
<view wx:if="{{!currentUser}}" class="card">
<view class="button-row">
<button class="btn primary full" open-type="getPhoneNumber" bindgetphonenumber="wechatLogin">手机号快捷登录</button>
</view>
<view wx:if="{{roleChoices.length}}" class="role-list">
<text class="label">请选择角色</text>
<button
wx:for="{{roleChoices}}"
wx:key="role"
class="role-btn"
data-role="{{item.role}}"
bindtap="chooseRole"
>
{{item.name}} · {{item.roleName}}
</button>
</view>
</view>
<view wx:else class="card profile-card">
<view class="profile-head">
<view class="avatar large-avatar">
<image wx:if="{{avatarPath}}" class="avatar-image" src="{{avatarPath}}" mode="aspectFill"></image>
<text wx:else class="avatar-text large-avatar-text">{{avatarText}}</text>
</view>
<view class="profile-main">
<text class="profile-name">{{currentUser.name}}</text>
<text class="subtitle">{{currentUser.phone}}</text>
</view>
</view>
<view class="divider"></view>
<view class="info-row" bindtap="switchCurrentRole">
<text class="label inline-label">当前角色</text>
<view class="info-value">
<text>{{currentRoleName}}</text>
<text wx:if="{{canSwitchRole}}" class="role-arrow">▾</text>
</view>
</view>
<view class="info-row">
<text class="label inline-label">全部角色</text>
<text class="value">{{currentRolesText}}</text>
</view>
<view wx:if="{{currentUser.role !== 'manager'}}" class="info-row">
<text class="label inline-label">考勤点</text>
<text class="value">{{currentUser.attendancePointsText || '-'}}</text>
</view>
<view class="button-row">
<button class="btn secondary full" bindtap="chooseAvatar">更换头像</button>
<button class="btn danger full" bindtap="resetDemo">退出登录</button>
</view>
</view>
</view>
</scroll-view>
<view class="bottom-bar">
<view class="bottom-item {{activeTab === 'home' ? 'active' : ''}}" data-tab="home" bindtap="switchTab">
<text class="bottom-icon">⌂</text>
<text>首页</text>
</view>
<view class="bottom-item {{activeTab === 'workbench' ? 'active' : ''}}" data-tab="workbench" bindtap="switchTab">
<text class="bottom-icon">▦</text>
<text>工作台</text>
</view>
<view class="bottom-item {{activeTab === 'mine' ? 'active' : ''}}" data-tab="mine" bindtap="switchTab">
<text class="bottom-icon">○</text>
<text>我的</text>
</view>
</view>
<view wx:if="{{temporaryWorkerModalVisible}}" class="temp-worker-mask" catchtap="closeTemporaryWorkerModal">
<view class="temp-worker-panel" catchtap="noop">
<text class="temp-worker-title">创建临时工</text>
<text class="temp-worker-tip">{{temporaryWorkerPrompt}}</text>
<view class="temp-worker-field">
<text class="temp-worker-label">姓名</text>
<input
class="input temp-worker-input"
value="{{temporaryWorkerName}}"
placeholder="请输入姓名"
bindinput="onTemporaryWorkerNameInput"
confirm-type="done"
cursor-spacing="24"
/>
</view>
<view class="button-row temp-worker-actions">
<button class="btn secondary full" bindtap="closeTemporaryWorkerModal">取消</button>
<button class="btn primary full" bindtap="submitTemporaryWorker">确认创建</button>
</view>
</view>
</view>
</view>
+806
View File
@@ -0,0 +1,806 @@
.index-shell {
min-height: 100vh;
background: #f2f5f9;
}
.index-page {
padding-bottom: calc(env(safe-area-inset-bottom) + 150rpx);
}
.header-row {
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
padding-top: 8rpx;
}
.index-page .header::after {
left: 18rpx;
right: auto;
top: 128rpx;
width: 150rpx;
height: 46rpx;
opacity: 0.06;
transform: rotate(14deg);
}
.brand-block {
min-width: 0;
flex: 1;
}
.location-status {
display: block;
margin-top: 8rpx;
color: #1f2937;
font-size: 23rpx;
font-weight: 800;
line-height: 1.35;
}
.location-refresh-tip {
display: block;
margin-top: 2rpx;
color: #7a8798;
font-size: 20rpx;
line-height: 1.3;
}
.location-not-entered {
color: #c5221f;
font-weight: 900;
}
.location-point-name {
color: #087443;
font-weight: 900;
}
.metallic-title {
display: inline-block;
color: #4b5563;
font-weight: 900;
background:
linear-gradient(90deg, #38414d 0%, #dfe6ec 7%, #7f8b97 14%, #f8fbfd 22%, #59636f 31%, #eef3f6 41%, #3f4853 52%, #c4cdd6 64%, #ffffff 73%, #626c78 84%, #2f3741 100%),
linear-gradient(180deg, #f9fbfc 0%, #a7b0bb 18%, #ffffff 34%, #5b6470 54%, #d3dae2 74%, #3b4450 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
-webkit-text-stroke: 1rpx rgba(47, 55, 65, 0.42);
text-shadow:
0 1rpx 0 rgba(255, 255, 255, 0.92),
0 3rpx 0 rgba(87, 96, 108, 0.78),
0 6rpx 0 rgba(38, 45, 54, 0.62),
0 12rpx 16rpx rgba(15, 23, 42, 0.28),
0 20rpx 28rpx rgba(15, 23, 42, 0.16);
}
.header-user {
display: flex;
align-items: center;
gap: 12rpx;
flex: 0 0 auto;
max-width: 300rpx;
padding: 10rpx 12rpx;
border: 1rpx solid #e1e7f0;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 8rpx 0 rgba(205, 216, 229, 0.54),
0 14rpx 22rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.header-user-text {
min-width: 0;
text-align: right;
}
.header-name {
display: block;
color: #1f2933;
font-size: 26rpx;
font-weight: 700;
line-height: 1.25;
}
.role-line {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4rpx;
margin-top: 6rpx;
color: #667085;
font-size: 22rpx;
line-height: 1.2;
}
.role-arrow {
color: #1463ff;
font-size: 24rpx;
line-height: 1;
}
.avatar {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: 50%;
background: #e8f1ff;
color: #1456c2;
font-weight: 700;
}
.small-avatar {
width: 66rpx;
height: 66rpx;
font-size: 24rpx;
}
.large-avatar {
width: 112rpx;
height: 112rpx;
font-size: 34rpx;
}
.avatar-image {
width: 100%;
height: 100%;
}
.avatar-text {
display: block;
line-height: 1;
}
.large-avatar-text {
font-size: 34rpx;
}
.scan-btn {
width: 100%;
height: 112rpx;
line-height: 112rpx;
margin: 0 0 28rpx;
padding: 0;
border-radius: 18rpx;
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
color: #ffffff;
font-size: 36rpx;
font-weight: 700;
box-shadow:
0 12rpx 0 #0d3f93,
0 20rpx 30rpx rgba(20, 86, 194, 0.28),
inset 0 2rpx 0 rgba(255, 255, 255, 0.26);
}
.scan-btn:active {
transform: translateY(5rpx);
box-shadow:
0 7rpx 0 #0d3f93,
0 14rpx 22rpx rgba(20, 86, 194, 0.22),
inset 0 2rpx 0 rgba(255, 255, 255, 0.2);
}
.scan-btn::after {
border: 0;
}
.notice-card {
position: relative;
overflow: hidden;
margin-bottom: 28rpx;
border: 1rpx solid #dce5f2;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 14rpx 0 rgba(205, 216, 229, 0.62),
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.notice-card::before,
.workbench-panel::before {
content: "";
position: absolute;
right: 18rpx;
top: 18rpx;
z-index: 1;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
opacity: 0.54;
pointer-events: none;
background:
linear-gradient(90deg, transparent 42%, rgba(47, 55, 65, 0.78) 43%, rgba(47, 55, 65, 0.78) 57%, transparent 58%),
radial-gradient(circle at 32% 28%, #ffffff 0, #dfe6ec 19%, #97a3af 58%, #475467 100%);
box-shadow:
0 3rpx 0 rgba(72, 83, 97, 0.38),
0 6rpx 10rpx rgba(22, 34, 50, 0.14),
inset 0 2rpx 0 rgba(255, 255, 255, 0.85);
transform: rotate(28deg);
}
.notice-hero {
position: relative;
min-height: 178rpx;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #1456c2 0%, #15847c 100%);
box-sizing: border-box;
}
.notice-hero::after {
content: "";
position: absolute;
right: -14rpx;
bottom: 14rpx;
width: 180rpx;
height: 48rpx;
opacity: 0.14;
transform: rotate(-20deg);
pointer-events: none;
background:
radial-gradient(circle at 18% 50%, transparent 0rpx, transparent 12rpx, #ffffff 13rpx, #ffffff 20rpx, transparent 21rpx),
radial-gradient(circle at 86% 50%, #ffffff 0rpx, #ffffff 16rpx, transparent 17rpx),
linear-gradient(90deg, transparent 0rpx, transparent 30rpx, #ffffff 31rpx, #ffffff 142rpx, transparent 143rpx);
}
.notice-badge {
display: inline-flex;
align-items: center;
height: 42rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
font-size: 22rpx;
font-weight: 700;
}
.notice-swiper {
height: 106rpx;
margin-top: 12rpx;
}
.notice-slide,
.notice-empty {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
}
.notice-title {
display: block;
overflow: hidden;
color: #ffffff;
font-size: 32rpx;
font-weight: 700;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-content {
display: -webkit-box;
overflow: hidden;
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.86);
font-size: 24rpx;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.notice-list {
padding: 16rpx 26rpx 18rpx;
}
.notice-list-row {
display: flex;
align-items: center;
gap: 12rpx;
min-height: 46rpx;
}
.notice-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #12a375;
}
.notice-list-title {
overflow: hidden;
min-width: 0;
color: #344054;
font-size: 24rpx;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.trend-card {
padding-bottom: 14rpx;
border-left: 6rpx solid #12a375;
}
.trend-head {
margin-bottom: 12rpx;
}
.trend-plot {
position: relative;
height: 360rpx;
padding: 18rpx 8rpx 0 58rpx;
box-sizing: border-box;
}
.trend-axis {
position: absolute;
left: 0;
width: 48rpx;
color: #667085;
font-size: 20rpx;
line-height: 1;
text-align: right;
}
.trend-axis-top {
top: 24rpx;
}
.trend-axis-bottom {
bottom: 56rpx;
}
.trend-plot-body {
position: relative;
height: 244rpx;
margin-right: 10rpx;
}
.trend-grid-line {
position: absolute;
left: 0;
right: 0;
height: 1rpx;
background: #e6eaf0;
}
.trend-grid-line.top {
top: 14%;
}
.trend-grid-line.middle {
top: 48%;
}
.trend-grid-line.bottom {
top: 82%;
}
.trend-segment {
position: absolute;
z-index: 2;
height: 6rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #6aa1ff 0%, #1463ff 100%);
box-shadow: 0 5rpx 10rpx rgba(20, 99, 255, 0.22);
transform-origin: 0 50%;
}
.trend-point-wrap {
position: absolute;
z-index: 3;
width: 1rpx;
height: 1rpx;
}
.trend-point {
position: absolute;
left: -11rpx;
top: -11rpx;
width: 22rpx;
height: 22rpx;
border: 5rpx solid #1463ff;
border-radius: 50%;
background: #ffffff;
box-shadow:
0 7rpx 12rpx rgba(20, 99, 255, 0.2),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
box-sizing: border-box;
}
.trend-point-value {
position: absolute;
left: -44rpx;
bottom: 18rpx;
width: 88rpx;
color: #1f2933;
font-size: 21rpx;
font-weight: 700;
line-height: 1;
text-align: center;
}
.trend-label-row {
position: relative;
height: 40rpx;
margin-top: 24rpx;
margin-right: 10rpx;
}
.trend-date-label {
position: absolute;
top: 0;
width: 78rpx;
color: #667085;
font-size: 20rpx;
line-height: 1;
text-align: center;
transform: translateX(-50%);
}
.tappable-stat {
position: relative;
transition: background 0.15s ease;
}
.tappable-stat:active {
background: #f3f6fb;
}
.stat-products {
border-left-color: #1463ff;
}
.stat-people {
border-left-color: #12a375;
}
.stat-pending {
border-left-color: #d9822b;
}
.stat-alert {
border-color: #ffb4a8;
border-left-color: #d92d20;
background:
linear-gradient(135deg, rgba(217, 45, 32, 0.13), rgba(255, 255, 255, 0.92) 48%),
#fff7f5;
box-shadow:
0 14rpx 0 rgba(244, 179, 169, 0.7),
0 22rpx 34rpx rgba(180, 35, 24, 0.14),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.stat-alert .stat-number,
.stat-alert .stat-link {
color: #b42318;
}
.stat-approved {
border-left-color: #7c3aed;
}
.stat-link {
display: block;
margin-top: 12rpx;
color: #6b778c;
font-size: 22rpx;
font-weight: 600;
}
.profile-card {
padding-bottom: 28rpx;
border-left: 6rpx solid #12a375;
}
.profile-head {
display: flex;
align-items: center;
gap: 20rpx;
}
.profile-main {
min-width: 0;
flex: 1;
}
.profile-name {
display: block;
color: #1f2933;
font-size: 36rpx;
font-weight: 700;
line-height: 1.3;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72rpx;
gap: 20rpx;
}
.inline-label {
margin-bottom: 0;
}
.info-value {
display: flex;
align-items: center;
gap: 6rpx;
color: #1f2933;
font-weight: 600;
}
.role-list {
margin-top: 22rpx;
}
.role-btn {
margin-top: 12rpx;
height: 72rpx;
line-height: 72rpx;
border-radius: 14rpx;
background: linear-gradient(180deg, #f6faff 0%, #e4efff 100%);
color: #1463ff;
font-size: 26rpx;
box-shadow:
0 7rpx 0 rgba(144, 178, 235, 0.62),
0 12rpx 18rpx rgba(20, 86, 194, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.84);
}
.role-btn::after {
border: 0;
}
.workbench-panel {
position: relative;
overflow: hidden;
padding: 24rpx 18rpx 10rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 14rpx 0 rgba(205, 216, 229, 0.62),
0 22rpx 34rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.app-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18rpx 12rpx;
}
.app-item {
display: flex;
flex-direction: column;
align-items: center;
min-height: 178rpx;
padding: 16rpx 8rpx;
border-radius: 16rpx;
box-sizing: border-box;
}
.app-item:active {
background: #f3f6fb;
}
.app-item:active .app-icon {
transform: translateY(4rpx);
box-shadow:
0 5rpx 0 rgba(74, 91, 115, 0.42),
0 8rpx 14rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.56),
inset 0 -8rpx 12rpx rgba(15, 23, 42, 0.12);
}
.app-icon {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
width: 78rpx;
height: 78rpx;
border-radius: 22rpx;
border: 1rpx solid rgba(255, 255, 255, 0.62);
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
color: #1456c2;
font-size: 34rpx;
font-weight: 800;
line-height: 1;
text-shadow:
0 2rpx 0 rgba(255, 255, 255, 0.82),
0 4rpx 8rpx rgba(15, 23, 42, 0.18);
box-shadow:
0 10rpx 0 rgba(77, 103, 145, 0.42),
0 16rpx 22rpx rgba(22, 34, 50, 0.16),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(15, 23, 42, 0.14);
}
.app-icon::before,
.app-icon::after {
content: "";
position: absolute;
width: 14rpx;
height: 14rpx;
border-radius: 50%;
pointer-events: none;
background:
linear-gradient(90deg, transparent 40%, rgba(47, 55, 65, 0.72) 41%, rgba(47, 55, 65, 0.72) 59%, transparent 60%),
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.96) 0, rgba(226, 232, 240, 0.96) 20%, rgba(132, 146, 164, 0.88) 62%, rgba(59, 70, 84, 0.86) 100%);
box-shadow:
0 2rpx 0 rgba(49, 61, 77, 0.36),
inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
}
.app-icon::before {
left: 10rpx;
top: 10rpx;
transform: rotate(24deg);
}
.app-icon::after {
right: 10rpx;
bottom: 10rpx;
transform: rotate(-28deg);
}
.app-icon-products,
.app-icon-device,
.app-icon-moldQr,
.app-icon-equipment {
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #8bb7ff 58%, #1456c2 100%);
color: #1456c2;
}
.app-icon-review,
.app-icon-audit {
background: linear-gradient(160deg, #fffdf7 0%, #ffe2aa 34%, #f0a23a 68%, #a95605 100%);
color: #a95605;
box-shadow:
0 10rpx 0 rgba(143, 84, 16, 0.38),
0 16rpx 22rpx rgba(104, 62, 17, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(94, 47, 0, 0.14);
}
.app-icon-people,
.app-icon-dashboard,
.app-icon-schedule {
background: linear-gradient(160deg, #ffffff 0%, #c9f2df 32%, #5fd19d 64%, #087443 100%);
color: #087443;
box-shadow:
0 10rpx 0 rgba(24, 117, 77, 0.38),
0 16rpx 22rpx rgba(20, 92, 63, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(0, 81, 49, 0.14);
}
.app-icon-records,
.app-icon-notices {
background: linear-gradient(160deg, #ffffff 0%, #e6d8ff 32%, #b68dff 64%, #6d35c7 100%);
color: #6d35c7;
box-shadow:
0 10rpx 0 rgba(98, 67, 151, 0.38),
0 16rpx 22rpx rgba(81, 54, 125, 0.14),
inset 0 3rpx 0 rgba(255, 255, 255, 0.82),
inset 0 -10rpx 14rpx rgba(65, 29, 122, 0.14);
}
.app-title {
display: block;
margin-top: 12rpx;
color: #162232;
font-size: 25rpx;
font-weight: 700;
line-height: 1.25;
text-align: center;
}
.app-desc {
display: -webkit-box;
overflow: hidden;
margin-top: 6rpx;
color: #7a8699;
font-size: 20rpx;
line-height: 1.35;
text-align: center;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 10rpx 22rpx calc(env(safe-area-inset-bottom) + 10rpx);
border-top: 1rpx solid #e4e9f1;
background: linear-gradient(180deg, #ffffff 0%, #f6f9fd 100%);
box-shadow:
0 -8rpx 0 rgba(205, 216, 229, 0.42),
0 -14rpx 28rpx rgba(22, 34, 50, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
}
.bottom-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 94rpx;
color: #8a94a6;
font-size: 22rpx;
font-weight: 600;
border-radius: 18rpx;
}
.bottom-item.active {
background: linear-gradient(180deg, #eef5ff 0%, #dceaff 100%);
color: #1463ff;
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.95),
0 7rpx 0 rgba(144, 178, 235, 0.46),
0 12rpx 20rpx rgba(20, 86, 194, 0.14);
}
.bottom-item.active text:not(.bottom-icon) {
color: #1463ff;
font-size: 24rpx;
font-weight: 800;
}
.bottom-item text:not(.bottom-icon) {
color: #7a8699;
}
.bottom-icon {
display: flex;
align-items: center;
justify-content: center;
width: 46rpx;
height: 46rpx;
margin-bottom: 6rpx;
border: 1rpx solid #c8d2df;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #e7edf5 100%);
color: #8b96a8;
font-size: 28rpx;
font-weight: 800;
line-height: 1;
box-shadow:
0 5rpx 0 rgba(157, 170, 188, 0.4),
0 8rpx 12rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.88);
}
.bottom-item.active .bottom-icon {
width: 58rpx;
height: 58rpx;
margin-bottom: 4rpx;
border-color: rgba(255, 255, 255, 0.72);
background: linear-gradient(160deg, #ffffff 0%, #dbe8ff 28%, #72a9ff 58%, #1456c2 100%);
color: #ffffff;
font-size: 34rpx;
text-shadow: 0 2rpx 4rpx rgba(15, 23, 42, 0.28);
box-shadow:
0 8rpx 0 #0d3f93,
0 14rpx 20rpx rgba(20, 86, 194, 0.24),
inset 0 3rpx 0 rgba(255, 255, 255, 0.78),
inset 0 -8rpx 14rpx rgba(15, 23, 42, 0.16);
}