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
@@ -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;
}