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
+489
View File
@@ -0,0 +1,489 @@
const api = require('../../utils/api')
Page({
data: {
keyword: '',
selectedMold: null,
moldOptions: [],
filteredMoldOptions: [],
batchMoldOptions: [],
batchKeyword: '',
dropdownVisible: false,
selectedMoldKeys: [],
selectedMoldCount: 0,
allFilteredSelected: false,
batchModalVisible: false,
batchGenerating: false,
generatedMoldName: '',
qrPath: '',
qrUrl: '',
qrImageUrl: '',
qrLoadError: '',
taskRows: [],
taskPage: 1,
taskPageInput: '1',
taskPageSize: 5,
taskTotal: 0,
taskTotalPages: 1,
taskLoading: false,
swipedTaskId: null,
},
onShow() {
this.loadMolds()
this.loadBatchTasks()
this.startTaskPolling()
},
onHide() {
this.stopTaskPolling()
},
onUnload() {
this.stopTaskPolling()
},
startTaskPolling() {
this.stopTaskPolling()
this.taskTimer = setInterval(() => {
this.loadBatchTasks({ silent: true })
}, 5000)
},
stopTaskPolling() {
if (this.taskTimer) {
clearInterval(this.taskTimer)
this.taskTimer = null
}
},
async loadMolds() {
try {
const moldOptions = await api.listMoldNames({ limit: 500 })
const availableKeys = new Set(moldOptions.map(item => item.uniqueKey))
const selectedMoldKeys = this.data.selectedMoldKeys.filter(key => availableKeys.has(key))
this.setData({
moldOptions,
selectedMoldKeys,
selectedMoldCount: selectedMoldKeys.length,
...this.buildFilteredState(this.data.keyword, moldOptions, selectedMoldKeys),
})
} catch (error) {
wx.showToast({ title: error.message || '加载模具失败', icon: 'none' })
}
},
async loadBatchTasks(options = {}) {
if (this.data.taskLoading && options.silent) {
return
}
if (!options.silent) {
this.setData({ taskLoading: true })
}
try {
const result = await api.listMoldQrBatchTasks({
page: this.data.taskPage,
pageSize: this.data.taskPageSize,
})
this.setData({
taskRows: result.rows,
taskPage: result.page,
taskPageInput: String(result.page || 1),
taskTotal: result.total,
taskTotalPages: result.totalPages,
swipedTaskId: result.rows.some(row => row.id === this.data.swipedTaskId) ? this.data.swipedTaskId : null,
})
} catch (error) {
if (!options.silent) {
wx.showToast({ title: error.message || '加载任务失败', icon: 'none' })
}
} finally {
if (!options.silent) {
this.setData({ taskLoading: false })
}
}
},
filterMolds(keyword, source = this.data.moldOptions) {
const text = String(keyword || '').trim().toLowerCase()
if (!text) {
return source.slice(0, 500)
}
return source.filter(item => (
String(item.displayName || '').toLowerCase().includes(text)
|| String(item.moldName || item.name || '').toLowerCase().includes(text)
|| String(item.processName || '').toLowerCase().includes(text)
|| String(item.stampingMethod || '').toLowerCase().includes(text)
|| String(item.attendancePointName || '').toLowerCase().includes(text)
)).slice(0, 500)
},
buildFilteredState(keyword, source = this.data.moldOptions, selectedKeys = this.data.selectedMoldKeys) {
const selectedSet = new Set(selectedKeys || [])
const filteredMoldOptions = this.filterMolds(keyword, source).map(item => ({
...item,
checked: selectedSet.has(item.uniqueKey),
}))
return {
filteredMoldOptions,
allFilteredSelected: filteredMoldOptions.length > 0 && filteredMoldOptions.every(item => item.checked),
}
},
buildBatchState(selectedKeys = this.data.selectedMoldKeys, keyword = this.data.batchKeyword) {
const selectedSet = new Set(selectedKeys || [])
const batchMoldOptions = this.filterMolds(keyword, this.data.moldOptions).map(item => ({
...item,
checked: selectedSet.has(item.uniqueKey),
}))
return {
batchMoldOptions,
allFilteredSelected: batchMoldOptions.length > 0 && batchMoldOptions.every(item => item.checked),
}
},
setBatchSelection(selectedMoldKeys) {
this.setData({
selectedMoldKeys,
selectedMoldCount: selectedMoldKeys.length,
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, selectedMoldKeys),
...this.buildBatchState(selectedMoldKeys),
})
},
onInput(e) {
const keyword = e.detail.value
this.setData({
keyword,
selectedMold: null,
...this.buildFilteredState(keyword),
dropdownVisible: true,
})
},
showDropdown() {
this.setData({
dropdownVisible: true,
...this.buildFilteredState(this.data.keyword),
})
},
selectMold(e) {
const index = Number(e.currentTarget.dataset.index)
const mold = this.data.filteredMoldOptions[index]
if (!mold) {
return
}
this.setData({
selectedMold: mold,
keyword: mold.displayName,
...this.buildFilteredState(mold.displayName),
dropdownVisible: false,
})
},
noop() {},
openBatchModal() {
this.setData({
batchModalVisible: true,
dropdownVisible: false,
batchKeyword: '',
...this.buildBatchState(this.data.selectedMoldKeys, ''),
})
},
closeBatchModal() {
if (this.data.batchGenerating) {
return
}
this.setData({ batchModalVisible: false })
},
onBatchSelectionChange(e) {
const visibleKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
const selectedVisibleKeys = e.detail.value || []
const selectedSet = new Set(this.data.selectedMoldKeys.filter(key => !visibleKeys.includes(key)))
selectedVisibleKeys.forEach(key => selectedSet.add(key))
this.setBatchSelection(Array.from(selectedSet))
},
onBatchInput(e) {
const batchKeyword = e.detail.value
this.setData({
batchKeyword,
...this.buildBatchState(this.data.selectedMoldKeys, batchKeyword),
})
},
toggleSelectAll() {
const batchKeys = this.data.batchMoldOptions.map(item => item.uniqueKey)
if (!batchKeys.length) {
wx.showToast({ title: '当前没有可选模具', icon: 'none' })
return
}
let selectedKeys = []
const selectedSet = new Set(this.data.selectedMoldKeys)
if (this.data.allFilteredSelected) {
batchKeys.forEach(key => selectedSet.delete(key))
} else {
batchKeys.forEach(key => selectedSet.add(key))
}
selectedKeys = Array.from(selectedSet)
this.setBatchSelection(selectedKeys)
},
getSelectedMolds() {
const selectedSet = new Set(this.data.selectedMoldKeys)
return this.data.moldOptions.filter(item => selectedSet.has(item.uniqueKey))
},
batchZipFilePath(fileName) {
const safeName = String(fileName || `模具二维码_${Date.now()}.zip`).replace(/[\\/:*?"<>|]/g, '_')
return `${wx.env.USER_DATA_PATH}/${safeName}`
},
shareZipFile(filePath, fileName) {
return new Promise((resolve, reject) => {
if (!wx.shareFileMessage) {
reject(new Error('当前微信版本不支持转发ZIP文件'))
return
}
wx.shareFileMessage({
filePath,
fileName,
success: resolve,
fail: reject,
})
})
},
isRequestTimeout(error) {
const message = `${error && error.message || ''} ${error && error.errMsg || ''}`.toLowerCase()
return message.includes('timeout') || message.includes('超时')
},
async createBatchTask() {
const molds = this.getSelectedMolds()
if (!molds.length) {
wx.showToast({ title: '请先勾选模具', icon: 'none' })
return
}
this.setData({ batchGenerating: true })
wx.showLoading({ title: '创建任务中' })
try {
const task = await api.createMoldQrBatchTask(molds)
wx.hideLoading()
wx.showToast({ title: `已创建${task.itemCount || molds.length}个二维码任务`, icon: 'success' })
this.setData({
batchModalVisible: false,
selectedMoldKeys: [],
selectedMoldCount: 0,
taskPage: 1,
...this.buildFilteredState(this.data.keyword, this.data.moldOptions, []),
...this.buildBatchState([], this.data.batchKeyword),
})
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
if (this.isRequestTimeout(error)) {
this.setData({
batchModalVisible: false,
taskPage: 1,
})
this.loadBatchTasks()
wx.showModal({
title: '创建结果待确认',
content: '请求超时,但任务可能已经创建。已刷新任务列表,请先查看列表状态,避免重复创建。',
showCancel: false,
})
} else {
wx.showToast({ title: error.message || '创建任务失败', icon: 'none' })
}
} finally {
this.setData({ batchGenerating: false })
}
},
changeTaskPage(e) {
const direction = Number(e.currentTarget.dataset.direction || 0)
const nextPage = Math.max(1, Math.min(this.data.taskTotalPages, this.data.taskPage + direction))
if (nextPage === this.data.taskPage) {
return
}
this.setData({ taskPage: nextPage }, () => this.loadBatchTasks())
},
onTaskPageInput(e) {
this.setData({ taskPageInput: e.detail.value })
},
jumpTaskPage(e) {
wx.hideKeyboard()
const inputValue = e && e.detail && e.detail.value !== undefined ? e.detail.value : this.data.taskPageInput
const target = Math.max(1, Math.min(Number(inputValue) || this.data.taskPage, this.data.taskTotalPages))
if (target === this.data.taskPage) {
this.setData({ taskPageInput: String(this.data.taskPage || 1) })
return
}
this.setData({ taskPage: target }, () => this.loadBatchTasks())
},
refreshBatchTasks() {
this.loadBatchTasks()
},
onTaskTouchStart(e) {
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
this.taskTouchStartX = touch.clientX
this.taskTouchStartY = touch.clientY
},
onTaskTouchMove(e) {
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
const deltaX = touch.clientX - this.taskTouchStartX
const deltaY = touch.clientY - this.taskTouchStartY
if (Math.abs(deltaX) < 45 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) {
return
}
const id = Number(e.currentTarget.dataset.id)
if (deltaX < 0) {
this.setData({ swipedTaskId: id })
} else if (this.data.swipedTaskId === id) {
this.setData({ swipedTaskId: null })
}
},
onTaskTouchEnd() {
this.taskTouchStartX = 0
this.taskTouchStartY = 0
},
async stopBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showLoading({ title: '停止中' })
try {
await api.stopMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已停止', icon: 'success' })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '停止失败', icon: 'none' })
}
},
async resumeBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showLoading({ title: '继续中' })
try {
await api.resumeMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已继续生成', icon: 'success' })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '继续失败', icon: 'none' })
}
},
async shareBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task || !task.zipUrl) {
wx.showToast({ title: 'ZIP还未生成完成', icon: 'none' })
return
}
wx.showLoading({ title: '准备转发' })
try {
const fileName = task.fileName || `模具二维码_${Date.now()}.zip`
const filePath = await api.downloadFileByUrl(task.zipUrl, this.batchZipFilePath(fileName), 120000)
wx.hideLoading()
await this.shareZipFile(filePath, fileName)
} catch (error) {
wx.hideLoading()
const message = error && error.errMsg && error.errMsg.includes('cancel')
? '已取消转发'
: (error.message || '转发失败')
wx.showToast({ title: message, icon: 'none' })
}
},
async deleteBatchTask(e) {
const index = Number(e.currentTarget.dataset.index)
const task = this.data.taskRows[index]
if (!task) {
return
}
wx.showModal({
title: '删除ZIP任务',
content: '删除后会同时删除这条记录及对应ZIP包,是否确定删除?',
cancelText: '否',
confirmText: '删除',
confirmColor: '#d92d20',
success: async res => {
if (!res.confirm) {
return
}
wx.showLoading({ title: '删除中' })
try {
await api.deleteMoldQrBatchTask(task.id)
wx.hideLoading()
wx.showToast({ title: '已删除', icon: 'success' })
this.setData({ swipedTaskId: null })
this.loadBatchTasks()
} catch (error) {
wx.hideLoading()
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
}
},
})
},
async generate() {
const mold = this.data.selectedMold
if (!mold || !mold.moldName || !mold.processName) {
wx.showToast({ title: '请选择产品名称、工序和冲压方式', icon: 'none' })
return
}
wx.showLoading({ title: '生成中' })
try {
const result = await api.generateMoldQr(mold)
const qrUrl = this.withCacheBuster(result.qrUrl)
this.setData({
generatedMoldName: result.displayName || result.moldName || result.deviceNo,
qrPath: result.qrPath,
qrUrl,
qrImageUrl: '',
qrLoadError: '',
})
if (!result.qrUrl) {
wx.showToast({ title: '微信配置未生成图片', icon: 'none' })
} else {
this.prepareQrImage(qrUrl)
}
} catch (error) {
wx.showToast({ title: error.message, icon: 'none' })
} finally {
wx.hideLoading()
}
},
withCacheBuster(url) {
if (!url) {
return ''
}
const separator = url.indexOf('?') === -1 ? '?' : '&'
return `${url}${separator}_t=${Date.now()}`
},
prepareQrImage(url) {
wx.downloadFile({
url,
success: res => {
if (res.statusCode === 200 && res.tempFilePath) {
this.setData({ qrImageUrl: res.tempFilePath, qrLoadError: '' })
return
}
this.setData({ qrImageUrl: url })
},
fail: () => {
this.setData({
qrImageUrl: url,
qrLoadError: '二维码图片下载失败,已切换为直接显示',
})
},
})
},
onQrLoad() {
this.setData({ qrLoadError: '' })
},
onQrError() {
this.setData({ qrLoadError: '二维码图片加载失败,点击空白区域可放大查看' })
},
preview() {
if (!this.data.qrUrl) {
return
}
wx.previewImage({
urls: [this.data.qrUrl],
current: this.data.qrUrl,
})
},
})
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "模具二维码"
}
+191
View File
@@ -0,0 +1,191 @@
<view class="page safe-bottom">
<view class="header">
<text class="title">模具二维码</text>
<text class="subtitle">选择产品名称、工序和冲压方式生成扫码报工入口</text>
</view>
<view class="card qr-work-card">
<view class="form-item">
<view class="field-head">
<text class="label">产品名称 / 工序 / 冲压方式</text>
<button class="btn mini secondary batch-entry-btn" bindtap="openBatchModal">批量生成</button>
</view>
<input
class="input"
value="{{keyword}}"
placeholder="输入产品、工序或冲压方式搜索模具"
bindfocus="showDropdown"
bindinput="onInput"
/>
<view wx:if="{{dropdownVisible}}" class="mold-dropdown">
<view
wx:for="{{filteredMoldOptions}}"
wx:key="uniqueKey"
class="mold-option"
data-index="{{index}}"
bindtap="selectMold"
>
<text class="mold-option-name">{{item.moldName || item.name}}</text>
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
</view>
<view wx:if="{{!filteredMoldOptions.length}}" class="mold-empty">未找到模具</view>
</view>
</view>
<button class="btn primary full" bindtap="generate">生成二维码</button>
<view wx:if="{{qrUrl}}" class="qr-result">
<view class="qr-frame" bindtap="preview">
<image
class="qr-image"
src="{{qrImageUrl || qrUrl}}"
mode="widthFix"
show-menu-by-longpress="{{true}}"
bindload="onQrLoad"
binderror="onQrError"
></image>
</view>
<text wx:if="{{qrLoadError}}" class="qr-error">{{qrLoadError}}</text>
<text class="value">模具 {{generatedMoldName}}</text>
<text wx:if="{{selectedMold && selectedMold.attendancePointName}}" class="subtitle">考勤点 {{selectedMold.attendancePointName}}</text>
<text class="subtitle">{{qrPath}}</text>
</view>
</view>
<view class="card batch-task-card">
<view class="task-head">
<view>
<text class="task-title">ZIP任务列表</text>
<text class="subtitle">批量二维码后台生成,完成后可转发ZIP</text>
</view>
<button class="btn mini secondary" loading="{{taskLoading}}" bindtap="refreshBatchTasks">刷新</button>
</view>
<view wx:if="{{!taskRows.length}}" class="mold-empty">暂无ZIP任务</view>
<view
wx:for="{{taskRows}}"
wx:key="id"
wx:for-index="taskIndex"
class="task-swipe-wrap"
data-id="{{item.id}}"
bindtouchstart="onTaskTouchStart"
bindtouchmove="onTaskTouchMove"
bindtouchend="onTaskTouchEnd"
>
<view
class="task-delete-btn"
data-index="{{taskIndex}}"
catchtap="deleteBatchTask"
>
<text>删除</text>
</view>
<view class="task-item {{swipedTaskId == item.id ? 'swiped' : ''}}">
<view class="task-row">
<view class="task-main">
<text class="task-file">{{item.fileName}}</text>
<text class="task-meta">二维码数量 {{item.itemCount}} · {{item.statusName}}</text>
</view>
<view class="task-side">
<text class="task-status {{item.isCompleted ? 'success' : ''}} {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'danger' : ''}}">{{item.progressPercent}}%</text>
<button
wx:if="{{item.isPending || item.isRunning}}"
class="btn mini secondary task-small-btn"
data-index="{{taskIndex}}"
catchtap="stopBatchTask"
>停止</button>
<button
wx:if="{{item.isPaused}}"
class="btn mini primary task-small-btn"
data-index="{{taskIndex}}"
catchtap="resumeBatchTask"
>继续</button>
</view>
</view>
<view class="progress-track">
<view class="progress-fill {{item.isPaused ? 'paused' : ''}} {{item.isFailed ? 'failed' : ''}}" style="width: {{item.progressPercent}}%;"></view>
</view>
<view class="task-time-grid">
<view>
<text class="label">开始时间</text>
<text class="value">{{item.startedAtText || '-'}}</text>
</view>
<view>
<text class="label">完成时间</text>
<text class="value">{{item.finishedAtText || '-'}}</text>
</view>
</view>
<text wx:if="{{item.errorMessage}}" class="task-error">{{item.errorMessage}}</text>
<view class="task-actions-row">
<button
wx:if="{{item.isCompleted && item.zipUrl}}"
class="btn primary full task-action-btn"
data-index="{{taskIndex}}"
catchtap="shareBatchTask"
>转发ZIP</button>
</view>
</view>
</view>
<view wx:if="{{taskRows.length}}" class="button-row pager-row task-pager">
<button class="btn secondary pager-nav" data-direction="-1" bindtap="changeTaskPage" disabled="{{taskPage <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{taskPageInput}}" bindinput="onTaskPageInput" bindconfirm="jumpTaskPage" />
<text class="page-total-label">/ {{taskTotalPages}}</text>
<text class="page-jump-action" bindtap="jumpTaskPage">跳</text>
</view>
<button class="btn secondary pager-nav" data-direction="1" bindtap="changeTaskPage" disabled="{{taskPage >= taskTotalPages}}">下一页</button>
</view>
</view>
<view wx:if="{{batchModalVisible}}" class="modal-mask batch-modal-mask" catchtap="closeBatchModal">
<view class="modal-panel batch-modal-panel" catchtap="noop">
<view class="batch-head">
<view>
<text class="batch-title">批量生成二维码</text>
<text class="subtitle">已选择 {{selectedMoldCount}} 个模具</text>
</view>
<button class="btn mini secondary batch-select-btn" bindtap="toggleSelectAll">
{{allFilteredSelected ? '取消全选' : '全选'}}
</button>
</view>
<view class="batch-search">
<input
class="input"
value="{{batchKeyword}}"
placeholder="筛选产品、工序、冲压方式或考勤点"
bindinput="onBatchInput"
/>
</view>
<checkbox-group bindchange="onBatchSelectionChange">
<scroll-view class="batch-list" scroll-y>
<label
wx:for="{{batchMoldOptions}}"
wx:key="uniqueKey"
class="batch-option {{item.checked ? 'checked' : ''}}"
>
<checkbox value="{{item.uniqueKey}}" checked="{{item.checked}}" />
<view class="batch-option-text">
<text class="mold-option-name">{{item.moldName || item.name}}</text>
<text wx:if="{{item.isMultiPerson}}" class="mold-option-tag">多人协作</text>
<text wx:if="{{item.isContinuousDie}}" class="mold-option-tag continuous-tag">连续模</text>
<text class="mold-option-process">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="mold-option-process">工序 {{item.processName || '-'}} · {{item.stampingMethod || '未填冲压方式'}}</text>
</view>
</label>
<view wx:if="{{!batchMoldOptions.length}}" class="mold-empty">未找到模具</view>
</scroll-view>
</checkbox-group>
<view class="batch-actions">
<button class="btn secondary full" bindtap="closeBatchModal">取消</button>
<button
class="btn primary full"
loading="{{batchGenerating}}"
bindtap="createBatchTask"
>生成ZIP任务</button>
</view>
</view>
</view>
</view>
+440
View File
@@ -0,0 +1,440 @@
.qr-work-card {
border-left: 6rpx solid #12a375;
}
.field-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 10rpx;
}
.field-head .label {
margin-bottom: 0;
}
.batch-entry-btn {
flex: 0 0 168rpx;
margin: 0;
}
.mold-dropdown {
overflow-y: auto;
max-height: 420rpx;
margin-top: 12rpx;
border: 2rpx solid #b8c6dc;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 10rpx 0 rgba(174, 188, 208, 0.46),
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.mold-option {
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #e6edf6;
color: #162232;
font-size: 27rpx;
font-weight: 650;
line-height: 1.35;
}
.mold-option-name {
color: #162232;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.mold-option-process {
color: #667085;
font-size: 24rpx;
font-weight: 650;
line-height: 1.3;
}
.mold-option-tag {
align-self: flex-start;
padding: 6rpx 12rpx;
border: 2rpx solid rgba(217, 119, 6, 0.58);
border-radius: 10rpx;
color: #9a4a05;
background: #fff7e8;
font-size: 22rpx;
font-weight: 900;
line-height: 1;
}
.mold-option-tag.continuous-tag {
color: #b45f00;
border-color: rgba(217, 119, 6, 0.58);
background: #fff4e0;
}
.mold-option:last-child {
border-bottom: 0;
}
.mold-option:active {
background: #eef5ff;
}
.mold-empty {
padding: 32rpx 24rpx;
color: #667085;
text-align: center;
}
.modal-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(15, 23, 42, 0.48);
box-sizing: border-box;
}
.modal-panel {
width: 100%;
overflow-y: auto;
padding: 30rpx;
border: 1rpx solid #e4e9f1;
border-radius: 18rpx;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
box-shadow:
0 16rpx 0 rgba(170, 184, 204, 0.62),
0 30rpx 64rpx rgba(15, 23, 42, 0.24),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.batch-modal-mask {
align-items: flex-start;
padding: 28rpx 20rpx calc(env(safe-area-inset-bottom) + 28rpx);
}
.batch-modal-panel {
max-height: 92vh;
}
.batch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 18rpx;
}
.batch-title {
display: block;
color: #162232;
font-size: 30rpx;
font-weight: 850;
line-height: 1.3;
}
.batch-select-btn {
flex: 0 0 156rpx;
margin: 0;
}
.batch-search {
margin-bottom: 18rpx;
}
.batch-list {
height: 64vh;
margin-bottom: 22rpx;
border: 2rpx solid #b8c6dc;
border-radius: 16rpx;
background: #ffffff;
box-shadow:
0 10rpx 0 rgba(174, 188, 208, 0.46),
0 18rpx 28rpx rgba(22, 34, 50, 0.12),
inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.batch-option {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 22rpx 24rpx;
border-bottom: 1rpx solid #e6edf6;
box-sizing: border-box;
}
.batch-option.checked {
background: #eef6ff;
}
.batch-option:last-child {
border-bottom: 0;
}
.batch-option-text {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.batch-actions {
display: grid;
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.24fr);
gap: 16rpx;
}
.batch-task-card {
border-left: 6rpx solid #1463ff;
}
.task-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 18rpx;
}
.task-title {
display: block;
color: #162232;
font-size: 30rpx;
font-weight: 850;
line-height: 1.3;
}
.task-swipe-wrap {
position: relative;
overflow: hidden;
margin-bottom: 22rpx;
}
.task-delete-btn {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 150rpx;
height: 100%;
margin: 0;
padding: 0;
border-radius: 0 16rpx 16rpx 0;
color: #ffffff;
background: #d92d20;
font-size: 28rpx;
font-weight: 900;
line-height: normal;
box-sizing: border-box;
}
.task-delete-btn text {
color: #ffffff;
font-size: 28rpx;
font-weight: 900;
line-height: 1;
}
.task-item {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 0;
padding: 22rpx;
border: 2rpx solid #c8d3e3;
border-radius: 16rpx;
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
box-shadow:
0 9rpx 0 rgba(180, 193, 213, 0.48),
0 15rpx 24rpx rgba(22, 34, 50, 0.08),
inset 0 2rpx 0 rgba(255, 255, 255, 0.94);
box-sizing: border-box;
transition: transform 180ms ease;
}
.task-item.swiped {
transform: translateX(-150rpx);
}
.task-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.task-main {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.task-file {
color: #162232;
font-size: 27rpx;
font-weight: 850;
line-height: 1.35;
word-break: break-all;
}
.task-meta {
color: #667085;
font-size: 24rpx;
font-weight: 650;
line-height: 1.35;
}
.task-status {
min-width: 96rpx;
padding: 8rpx 12rpx;
border-radius: 999rpx;
color: #1463ff;
background: #eef5ff;
font-size: 24rpx;
font-weight: 900;
text-align: center;
}
.task-side {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.task-small-btn {
min-width: 108rpx;
height: 54rpx;
line-height: 54rpx;
margin: 0;
padding: 0 14rpx;
font-size: 22rpx;
border-radius: 12rpx;
}
.task-status.success {
color: #087443;
background: #e8fff4;
}
.task-status.paused {
color: #8a5a00;
background: #fff7df;
}
.task-status.danger {
color: #b42318;
background: #fff1f0;
}
.progress-track {
overflow: hidden;
height: 18rpx;
border-radius: 999rpx;
background: #dbe4f0;
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.12);
}
.progress-fill {
height: 100%;
min-width: 8rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #12a375 100%);
}
.progress-fill.failed {
background: linear-gradient(90deg, #d92d20 0%, #f79009 100%);
}
.progress-fill.paused {
background: linear-gradient(90deg, #f79009 0%, #fdb022 100%);
}
.task-time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.task-error {
color: #b42318;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
word-break: break-all;
}
.task-actions-row {
display: flex;
gap: 14rpx;
margin-top: 2rpx;
}
.task-action-btn {
flex: 1;
margin-top: 2rpx;
}
.task-pager {
gap: 14rpx;
margin-top: 10rpx;
}
.qr-result {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
margin-top: 26rpx;
padding-top: 26rpx;
border-top: 2rpx solid #e6edf6;
}
.qr-frame {
width: 520rpx;
max-width: 100%;
min-height: 520rpx;
padding: 16rpx;
border: 1rpx solid #ccd8e6;
border-radius: 18rpx;
background: #ffffff;
box-shadow:
0 12rpx 0 rgba(205, 216, 229, 0.58),
0 18rpx 28rpx rgba(22, 34, 50, 0.1),
inset 0 2rpx 0 rgba(255, 255, 255, 0.96);
box-sizing: border-box;
}
.qr-image {
display: block;
width: 100%;
height: auto;
}
.qr-error {
color: #b42318;
font-size: 24rpx;
line-height: 1.4;
text-align: center;
}