3168daedf4
- 完成/失败态统一卡位13自动续做,修复主进程失败时 mode 被重置导致续做被拒 - 接入 SAPI_PrinterCheckstatus,Header 轻量刷状态,Init 后一次 GetPrinterInfo - print_flag 取自 soon.flag,path_file 目录级 staging,加密狗与格式化选项对齐 - 失败页并入运行页,移除独立 DistributeFailedView Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
16 KiB
Vue
541 lines
16 KiB
Vue
<template>
|
||
<AppShell>
|
||
<AppHeader :mode="headerMode">
|
||
<div v-if="phase === 'failed'" class="c-nav-group">
|
||
<NavButton icon="arrow-left" label="返回" @click="onFailedBack" />
|
||
<NavButton icon="redo" label="重置" variant="primary" @click="onFailedReset" />
|
||
</div>
|
||
<NavButton
|
||
v-else-if="phase === 'completed'"
|
||
icon="home"
|
||
label="返回"
|
||
@click="onReturn"
|
||
/>
|
||
<NavButton v-else icon="stop" label="停止" variant="stop" @click="onStop" />
|
||
</AppHeader>
|
||
<main class="app-shell__main l-main-full">
|
||
<section class="l-hero-container">
|
||
<div class="m-left-panel">
|
||
<div class="c-status-panel">
|
||
<template v-if="phase === 'failed'">
|
||
<h2 class="c-status-title is-error">任务失败</h2>
|
||
<p class="c-status-sub">{{ failedSub }}</p>
|
||
<p class="c-status-counter">
|
||
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
||
class="err"
|
||
>{{ failCount }}</span
|
||
>。
|
||
</p>
|
||
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
|
||
</template>
|
||
<template v-else>
|
||
<h2 class="c-status-title" :class="{ 'is-looping': phase === 'running' }">
|
||
{{ statusTitle }}
|
||
</h2>
|
||
<p class="c-status-sub">{{ statusSub }}</p>
|
||
<p class="c-status-counter">
|
||
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
||
class="err"
|
||
>{{ failCount }}</span
|
||
>。
|
||
</p>
|
||
</template>
|
||
</div>
|
||
<WorkflowSteps
|
||
v-if="phase === 'failed'"
|
||
mode="failed"
|
||
:variant="isCollect ? 'collect' : 'distribute'"
|
||
:failed-step="failureStep"
|
||
/>
|
||
<WorkflowSteps
|
||
v-else
|
||
:active-step="workflowStep"
|
||
:variant="isCollect ? 'collect' : 'distribute'"
|
||
mode="running"
|
||
/>
|
||
</div>
|
||
<div class="m-right-panel">
|
||
<div v-if="phase === 'failed'" class="m-error-icon">
|
||
<AppIcon name="warning" size="xl" />
|
||
</div>
|
||
<div v-else class="m-progress-circle">
|
||
<svg viewBox="0 0 100 100">
|
||
<circle class="bg" cx="50" cy="50" r="45" />
|
||
<circle
|
||
class="fill"
|
||
cx="50"
|
||
cy="50"
|
||
r="45"
|
||
:style="{ strokeDashoffset: strokeOffset }"
|
||
/>
|
||
</svg>
|
||
<div class="m-progress-value">{{ displayProgress }}%</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
<AppFooter />
|
||
</AppShell>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { notify } from '@/composables/useNotify'
|
||
import AppShell from '@/layouts/AppShell.vue'
|
||
import AppHeader from '@/components/AppHeader.vue'
|
||
import AppFooter from '@/components/AppFooter.vue'
|
||
import NavButton from '@/components/NavButton.vue'
|
||
import AppIcon from '@/components/AppIcon.vue'
|
||
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
||
import { useJobStore } from '@/stores/job'
|
||
import { useCollectStore } from '@/stores/collect'
|
||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||
import { useAppStore } from '@/stores/app'
|
||
import {
|
||
dllJobCancel,
|
||
dllPrinterErrorStr,
|
||
dllUsbCopy,
|
||
onCardPositionTick,
|
||
onJobPollTick,
|
||
onUsbPollTick,
|
||
pollCardPositionStart,
|
||
pollCardPositionStop,
|
||
pollJobStart,
|
||
pollJobStop,
|
||
pollUsbStop
|
||
} from '@/api/cardsoon'
|
||
import { createDistributeJob } from '@/utils/createDistributeJob'
|
||
import { validateJobPreflight } from '@/utils/validateJobPreflight'
|
||
import { mapJobStateToUi } from '@/utils/job-state'
|
||
import { POSITION_PREPARE } from '@shared/card-position'
|
||
import {
|
||
USB_TASK_COPYING,
|
||
USB_TASK_PREPARING,
|
||
clampUsbCopyProgress,
|
||
usbTaskStatusHint
|
||
} from '@shared/usb-copy-state'
|
||
import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
||
|
||
const CIRCLE_LEN = 283
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const jobStore = useJobStore()
|
||
const collectStore = useCollectStore()
|
||
const formStore = useDistributeFormStore()
|
||
const appStore = useAppStore()
|
||
|
||
const isCollect = computed(() => route.name === 'collect-running')
|
||
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式'))
|
||
const successCount = computed(() =>
|
||
isCollect.value ? collectStore.successCount : jobStore.successCount
|
||
)
|
||
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
|
||
|
||
const phase = ref<'running' | 'completed' | 'failed'>('running')
|
||
const progress = ref(0)
|
||
const strokeOffset = ref(CIRCLE_LEN)
|
||
const workflowStep = ref(1)
|
||
const failureStep = ref(1)
|
||
const failureErrorText = ref('')
|
||
const waitCard = ref(false)
|
||
const collectHint = ref('')
|
||
let unsubJob: (() => void) | null = null
|
||
let unsubUsb: (() => void) | null = null
|
||
let unsubCard: (() => void) | null = null
|
||
let finishing = false
|
||
let resubmitting = false
|
||
let lastCardPosition = -1
|
||
let usbAwaitNewCycle = false
|
||
let queryFailStreak = 0
|
||
let usbQueryFailStreak = 0
|
||
|
||
const displayProgress = computed(() => Math.round(progress.value))
|
||
|
||
const failedSub = computed(() =>
|
||
isCollect.value
|
||
? '请检查读卡器与卡片,插入备卡位可自动重试'
|
||
: '请检查设备故障,插入备卡位可自动重试'
|
||
)
|
||
|
||
const statusTitle = computed(() => {
|
||
if (phase.value === 'completed') {
|
||
return isCollect.value ? '收集已完成' : '任务已完成'
|
||
}
|
||
if (isCollect.value) {
|
||
return collectHint.value || '数据收集中'
|
||
}
|
||
return waitCard.value ? '等待插卡' : '任务执行中'
|
||
})
|
||
|
||
const statusSub = computed(() => {
|
||
if (phase.value === 'completed') {
|
||
return isCollect.value
|
||
? '请点击返回,或插入备卡位继续下一张'
|
||
: '请点击返回,或插入备卡位自动开始下一张'
|
||
}
|
||
if (isCollect.value) {
|
||
return collectHint.value || '正在从卡片读取并写入导入目录'
|
||
}
|
||
return waitCard.value ? '请插入数据卡,任务将自动执行' : '任务执行中,请稍候'
|
||
})
|
||
|
||
function setProgress(value: number): void {
|
||
const p = Math.min(100, Math.max(0, value))
|
||
progress.value = p
|
||
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
|
||
}
|
||
|
||
function clearPollListeners(): void {
|
||
unsubJob?.()
|
||
unsubJob = null
|
||
unsubUsb?.()
|
||
unsubUsb = null
|
||
unsubCard?.()
|
||
unsubCard = null
|
||
}
|
||
|
||
async function releasePolls(resetMode: boolean): Promise<void> {
|
||
clearPollListeners()
|
||
await pollCardPositionStop()
|
||
if (isCollect.value) {
|
||
await pollUsbStop({ resetMode })
|
||
} else {
|
||
await pollJobStop({ resetMode })
|
||
}
|
||
}
|
||
|
||
async function stopTaskPollsOnly(): Promise<void> {
|
||
unsubJob?.()
|
||
unsubJob = null
|
||
unsubUsb?.()
|
||
unsubUsb = null
|
||
if (isCollect.value) {
|
||
await pollUsbStop({ resetMode: false })
|
||
} else {
|
||
await pollJobStop({ resetMode: false })
|
||
}
|
||
}
|
||
|
||
async function startCardPositionWatch(): Promise<void> {
|
||
await pollCardPositionStop()
|
||
const cardStarted = await pollCardPositionStart()
|
||
if (!cardStarted.ok) {
|
||
notify.warning(cardStarted.message || '无法监控卡位,请手动点击返回或重新提交')
|
||
}
|
||
unsubCard?.()
|
||
unsubCard = onCardPositionTick((payload) =>
|
||
applyCardPosition(payload as CardPositionPollPayload)
|
||
)
|
||
}
|
||
|
||
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
|
||
if (phase.value !== next) {
|
||
phase.value = next
|
||
if (next === 'completed') {
|
||
setProgress(100)
|
||
workflowStep.value = isCollect.value ? 3 : 4
|
||
} else {
|
||
failureStep.value = workflowStep.value
|
||
failureErrorText.value = errorText
|
||
if (!failureErrorText.value && !isCollect.value) {
|
||
try {
|
||
const r = await dllPrinterErrorStr(-1)
|
||
failureErrorText.value = r.ok && r.data?.text ? r.data.text : ''
|
||
} catch {
|
||
failureErrorText.value = ''
|
||
}
|
||
}
|
||
jobStore.clearActiveJob()
|
||
}
|
||
} else if (next === 'failed' && errorText) {
|
||
failureErrorText.value = errorText
|
||
}
|
||
await stopTaskPollsOnly()
|
||
appStore.setMode(isCollect.value ? 'usbCopying' : 'distributing')
|
||
lastCardPosition = -1
|
||
await startCardPositionWatch()
|
||
}
|
||
|
||
async function enterFailedPhase(errorText = ''): Promise<void> {
|
||
await enterWaitPhase('failed', errorText)
|
||
}
|
||
|
||
async function enterCompletedPhase(): Promise<void> {
|
||
await enterWaitPhase('completed')
|
||
}
|
||
|
||
function canAutoResubmit(): boolean {
|
||
return phase.value === 'completed' || phase.value === 'failed'
|
||
}
|
||
|
||
function applyCardPosition(p: CardPositionPollPayload): void {
|
||
if (!canAutoResubmit() || resubmitting) return
|
||
if (p.queryCode !== 0) return
|
||
const pos = p.position
|
||
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
|
||
lastCardPosition = pos
|
||
if (!enteredPrepare) return
|
||
void resubmitTask()
|
||
}
|
||
|
||
async function resubmitTask(): Promise<void> {
|
||
if (resubmitting || !canAutoResubmit()) return
|
||
const fromWaitPhase = phase.value
|
||
resubmitting = true
|
||
await pollCardPositionStop()
|
||
unsubCard?.()
|
||
unsubCard = null
|
||
phase.value = 'running'
|
||
setProgress(0)
|
||
workflowStep.value = 1
|
||
collectHint.value = ''
|
||
waitCard.value = false
|
||
queryFailStreak = 0
|
||
usbQueryFailStreak = 0
|
||
|
||
const backToWait = async (message?: string): Promise<void> => {
|
||
resubmitting = false
|
||
if (message) notify.error(message)
|
||
if (fromWaitPhase === 'failed') {
|
||
await enterFailedPhase(message || failureErrorText.value)
|
||
} else {
|
||
await enterCompletedPhase()
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (isCollect.value) {
|
||
const dest = collectStore.destPath.trim()
|
||
if (!dest) {
|
||
await backToWait('导入目录无效,无法继续')
|
||
return
|
||
}
|
||
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
|
||
if (!r.ok) {
|
||
await backToWait(r.message || '重新提交收集任务失败')
|
||
return
|
||
}
|
||
usbAwaitNewCycle = true
|
||
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
|
||
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||
return
|
||
}
|
||
|
||
const err = await validateJobPreflight(formStore)
|
||
if (err) {
|
||
notify.warning(err)
|
||
await backToWait()
|
||
return
|
||
}
|
||
const created = await createDistributeJob(formStore, { resubmit: true })
|
||
if (!created.ok) {
|
||
await backToWait(created.message)
|
||
return
|
||
}
|
||
jobStore.setActiveJob(created.jobId)
|
||
const started = await pollJobStart(created.jobId)
|
||
if (!started.ok) {
|
||
await backToWait(started.message || '启动任务轮询失败')
|
||
return
|
||
}
|
||
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||
} finally {
|
||
resubmitting = false
|
||
}
|
||
}
|
||
|
||
function applyJobProgress(p: JobPollPayload): void {
|
||
if (finishing || phase.value !== 'running') return
|
||
if (p.queryErrorCode !== 0) {
|
||
queryFailStreak += 1
|
||
if (queryFailStreak < 3) return
|
||
jobStore.failCount += 1
|
||
void enterFailedPhase(`查询任务失败: ${p.queryErrorCode}`)
|
||
return
|
||
}
|
||
queryFailStreak = 0
|
||
setProgress(p.progress)
|
||
|
||
const ui = mapJobStateToUi(p.jobState)
|
||
workflowStep.value = ui.workflowStep
|
||
waitCard.value = ui.hint === 'waitCard'
|
||
if (p.failed) {
|
||
jobStore.failCount += 1
|
||
void enterFailedPhase()
|
||
return
|
||
}
|
||
if (p.cancelled) {
|
||
void finishDistribute(false)
|
||
return
|
||
}
|
||
if (p.finished) {
|
||
jobStore.successCount += 1
|
||
void enterCompletedPhase()
|
||
return
|
||
}
|
||
}
|
||
|
||
function applyUsbProgress(p: UsbPollPayload): void {
|
||
if (finishing || phase.value !== 'running') return
|
||
if (p.queryCode !== 0) {
|
||
usbQueryFailStreak += 1
|
||
if (usbQueryFailStreak < 3) return
|
||
collectStore.failCount += 1
|
||
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`)
|
||
return
|
||
}
|
||
usbQueryFailStreak = 0
|
||
|
||
if (usbAwaitNewCycle && p.queryCode === 0 && p.taskStatus === USB_TASK_PREPARING) {
|
||
usbAwaitNewCycle = false
|
||
}
|
||
|
||
if (p.failed) {
|
||
collectStore.failCount += 1
|
||
void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
|
||
return
|
||
}
|
||
if (p.success) {
|
||
collectStore.successCount += 1
|
||
workflowStep.value = 3
|
||
collectHint.value = usbTaskStatusHint(p.taskStatus)
|
||
notify.success('USB 收集完成')
|
||
void enterCompletedPhase()
|
||
return
|
||
}
|
||
|
||
const copyProgress = clampUsbCopyProgress(p.progress)
|
||
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
|
||
collectHint.value = usbTaskStatusHint(p.taskStatus)
|
||
setProgress(copyProgress)
|
||
}
|
||
|
||
async function finishDistribute(
|
||
toHome: boolean,
|
||
redirect: '/home' | '/distribute/config' = toHome ? '/home' : '/distribute/config'
|
||
): Promise<void> {
|
||
if (finishing) return
|
||
finishing = true
|
||
const skipCancel = phase.value === 'completed' || phase.value === 'failed'
|
||
await releasePolls(true)
|
||
const id = jobStore.jobId
|
||
if (id && !skipCancel) {
|
||
const r = await dllJobCancel(id)
|
||
if (!r.ok) {
|
||
notify.warning(r.message || '取消任务时出现问题')
|
||
}
|
||
}
|
||
jobStore.clearActiveJob()
|
||
appStore.setMode('ready')
|
||
await router.push(redirect)
|
||
}
|
||
|
||
async function finishCollect(toHome: boolean): Promise<void> {
|
||
if (finishing) return
|
||
finishing = true
|
||
await releasePolls(true)
|
||
appStore.setMode('ready')
|
||
await router.push(toHome ? '/home' : '/collect')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (isCollect.value) {
|
||
if (appStore.mode !== 'usbCopying') {
|
||
router.replace('/collect')
|
||
return
|
||
}
|
||
workflowStep.value = 1
|
||
setProgress(0)
|
||
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
|
||
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||
return
|
||
}
|
||
|
||
if (!jobStore.jobId) {
|
||
router.replace('/distribute/config')
|
||
return
|
||
}
|
||
appStore.setMode('distributing')
|
||
setProgress(0)
|
||
const started = await pollJobStart(jobStore.jobId)
|
||
if (!started.ok) {
|
||
notify.error(started.message || '启动任务轮询失败')
|
||
await finishDistribute(false)
|
||
return
|
||
}
|
||
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (finishing || phase.value !== 'running') return
|
||
void releasePolls(true)
|
||
if (isCollect.value && appStore.mode === 'usbCopying') {
|
||
appStore.setMode('ready')
|
||
} else if (!isCollect.value && appStore.mode === 'distributing') {
|
||
appStore.setMode('ready')
|
||
}
|
||
})
|
||
|
||
async function leaveFailedPage(redirect: '/collect' | '/distribute/config' | '/home'): Promise<void> {
|
||
finishing = true
|
||
await releasePolls(true)
|
||
appStore.setMode('ready')
|
||
await router.push(redirect)
|
||
}
|
||
|
||
async function onFailedBack(): Promise<void> {
|
||
if (isCollect.value) {
|
||
await leaveFailedPage('/collect')
|
||
return
|
||
}
|
||
await leaveFailedPage('/distribute/config')
|
||
}
|
||
|
||
async function onFailedReset(): Promise<void> {
|
||
if (isCollect.value) {
|
||
collectStore.reset()
|
||
} else {
|
||
formStore.reset()
|
||
jobStore.reset()
|
||
}
|
||
await leaveFailedPage('/home')
|
||
}
|
||
|
||
async function onReturn(): Promise<void> {
|
||
if (isCollect.value) {
|
||
await finishCollect(false)
|
||
return
|
||
}
|
||
await finishDistribute(false)
|
||
}
|
||
|
||
async function onStop(): Promise<void> {
|
||
if (isCollect.value) {
|
||
await finishCollect(false)
|
||
return
|
||
}
|
||
await finishDistribute(false)
|
||
}
|
||
</script>
|
||
|
||
<style src="@/styles/pages/page2.css"></style>
|
||
<style src="@/styles/pages/page3.css"></style>
|
||
|
||
<style scoped>
|
||
.m-error-detail {
|
||
margin-top: 6px;
|
||
font-size: 10px;
|
||
color: #dc3545;
|
||
font-weight: 600;
|
||
max-width: 320px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.m-progress-circle .fill {
|
||
stroke-dasharray: 283;
|
||
transition: stroke-dashoffset 0.45s ease;
|
||
}
|
||
</style>
|