- 新增打印终端、配对码、打印任务相关表结构与接口\n- 增加后台打印终端/任务管理页和资产标签远程打印入口\n- 引入 .NET 8 Windows 打印助手源码及下载清单配置
319 lines
12 KiB
Vue
319 lines
12 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<section class="page-header">
|
||
<div>
|
||
<p class="page-kicker">Print Jobs</p>
|
||
<h2 class="page-title">打印任务</h2>
|
||
</div>
|
||
<div class="page-meta-grid">
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">排队中</p>
|
||
<p class="page-meta__value">{{ queuedCount }}</p>
|
||
<p class="page-meta__subvalue">等待默认终端</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">处理中</p>
|
||
<p class="page-meta__value">{{ processingCount }}</p>
|
||
<p class="page-meta__subvalue">已被终端领取</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">已完成</p>
|
||
<p class="page-meta__value">{{ completedCount }}</p>
|
||
<p class="page-meta__subvalue">成功出纸</p>
|
||
</div>
|
||
<div class="page-meta">
|
||
<p class="page-meta__label">失败</p>
|
||
<p class="page-meta__value">{{ failedCount }}</p>
|
||
<p class="page-meta__subvalue">需人工查看原因</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="filter-bar">
|
||
<div class="filter-bar__inputs">
|
||
<div class="field-stack">
|
||
<label class="field-label">任务状态</label>
|
||
<select v-model="statusFilter" class="select" @change="handleSearch">
|
||
<option value="">全部状态</option>
|
||
<option value="queued">排队中</option>
|
||
<option value="claimed">已领取</option>
|
||
<option value="printing">打印中</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="failed">失败</option>
|
||
<option value="cancelled">已取消</option>
|
||
</select>
|
||
</div>
|
||
<div class="field-stack">
|
||
<label class="field-label">当前说明</label>
|
||
<p class="text-sm leading-6 text-text-secondary">
|
||
这里只展示最近远程打印任务。排队中的任务会等待默认打印电脑上线;当前前端先不做手动改派。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bar__actions">
|
||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchJobs">
|
||
{{ loading ? '刷新中...' : '刷新' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="list-header">
|
||
<div>
|
||
<h3 class="section-title">任务列表</h3>
|
||
<p class="mt-1 text-sm text-text-secondary">用于查看是否已经入队、是否开始打印、以及失败时的人话错误原因。</p>
|
||
</div>
|
||
<span class="badge badge-neutral">最近 {{ jobs.length }} 条</span>
|
||
</div>
|
||
|
||
<div class="mobile-card-list md:hidden">
|
||
<article v-for="job in jobs" :key="`mobile-${job.id}`" class="mobile-card">
|
||
<div class="mobile-card__row">
|
||
<div>
|
||
<p class="text-sm font-semibold text-text-primary">{{ getJobTypeText(job.job_type) }}</p>
|
||
<p class="mt-1 text-xs text-text-tertiary">{{ formatNullableDateTime(job.created_at) }}</p>
|
||
</div>
|
||
<span class="badge" :class="getJobStatusBadgeClass(job.status)">
|
||
{{ getJobStatusText(job.status) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="mt-4 mobile-card__meta">
|
||
<div>
|
||
<p class="mobile-card__label">目标终端</p>
|
||
<p class="mobile-card__value">{{ getTerminalLabel(job) }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">标签数量</p>
|
||
<p class="mobile-card__value">{{ getEquipmentCount(job) }} 张</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">发起人</p>
|
||
<p class="mobile-card__value">{{ job.requested_by_name || '系统' }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="mobile-card__label">尝试次数</p>
|
||
<p class="mobile-card__value">{{ job.attempt_count || 0 }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 rounded-xl border border-border bg-bg-elevated px-4 py-3">
|
||
<p class="mobile-card__label">任务结果</p>
|
||
<p class="mt-2 text-sm leading-6 text-text-primary">{{ getJobResultText(job) }}</p>
|
||
</div>
|
||
|
||
<div v-if="job.status === 'queued'" class="mt-4 flex justify-end">
|
||
<button
|
||
class="btn-secondary text-xs sm:text-sm"
|
||
:disabled="busyJobId === job.id"
|
||
@click="handleCancelJob(job)"
|
||
>
|
||
{{ busyJobId === job.id ? '取消中...' : '取消任务' }}
|
||
</button>
|
||
</div>
|
||
</article>
|
||
|
||
<p v-if="!loading && jobs.length === 0" class="rounded-xl border border-border bg-bg-elevated px-4 py-8 text-center text-sm text-text-tertiary">
|
||
当前筛选条件下暂无打印任务。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="hidden md:block">
|
||
<div class="table-container">
|
||
<table class="data-table min-w-[1080px]">
|
||
<thead>
|
||
<tr>
|
||
<th>创建时间</th>
|
||
<th>任务类型</th>
|
||
<th>状态</th>
|
||
<th>目标终端</th>
|
||
<th class="text-right">标签数量</th>
|
||
<th>发起人</th>
|
||
<th class="text-right">尝试次数</th>
|
||
<th>任务结果</th>
|
||
<th class="text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="job in jobs" :key="job.id">
|
||
<td class="text-sm text-text-secondary">{{ formatNullableDateTime(job.created_at) }}</td>
|
||
<td class="text-sm font-medium text-text-primary">{{ getJobTypeText(job.job_type) }}</td>
|
||
<td>
|
||
<span class="badge" :class="getJobStatusBadgeClass(job.status)">
|
||
{{ getJobStatusText(job.status) }}
|
||
</span>
|
||
</td>
|
||
<td class="text-sm text-text-secondary">{{ getTerminalLabel(job) }}</td>
|
||
<td class="text-right text-sm text-text-primary">{{ getEquipmentCount(job) }}</td>
|
||
<td class="text-sm text-text-secondary">{{ job.requested_by_name || '系统' }}</td>
|
||
<td class="text-right text-sm text-text-secondary">{{ job.attempt_count || 0 }}</td>
|
||
<td class="max-w-[280px]">
|
||
<p class="truncate text-sm text-text-secondary">{{ getJobResultText(job) }}</p>
|
||
</td>
|
||
<td class="text-right">
|
||
<button
|
||
v-if="job.status === 'queued'"
|
||
class="table-action"
|
||
:disabled="busyJobId === job.id"
|
||
@click="handleCancelJob(job)"
|
||
>
|
||
{{ busyJobId === job.id ? '取消中' : '取消任务' }}
|
||
</button>
|
||
<span v-else class="text-xs text-text-tertiary">-</span>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!loading && jobs.length === 0">
|
||
<td colspan="9" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无打印任务。</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="total > 0" class="pagination-bar">
|
||
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条任务</p>
|
||
<div class="flex items-center gap-2">
|
||
<button class="btn-secondary text-sm" :disabled="page <= 1 || loading" @click="handlePrevPage">上一页</button>
|
||
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { cancelQueuedPrintJob, getPrintJobs } from '@/api/print'
|
||
import type { PrintJob, PrintJobStatus } from '@/types'
|
||
import { formatDateTime } from '@/utils/format'
|
||
|
||
const jobs = ref<PrintJob[]>([])
|
||
const total = ref(0)
|
||
const page = ref(1)
|
||
const pageSize = 12
|
||
const loading = ref(false)
|
||
const busyJobId = ref<number | null>(null)
|
||
const statusFilter = ref<PrintJobStatus | ''>('')
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||
const queuedCount = computed(() => jobs.value.filter((job) => job.status === 'queued').length)
|
||
const processingCount = computed(() => jobs.value.filter((job) => ['claimed', 'printing'].includes(job.status)).length)
|
||
const completedCount = computed(() => jobs.value.filter((job) => job.status === 'completed').length)
|
||
const failedCount = computed(() => jobs.value.filter((job) => job.status === 'failed').length)
|
||
|
||
const formatNullableDateTime = (value?: string | null) => {
|
||
if (!value) return '未记录'
|
||
try {
|
||
return formatDateTime(value)
|
||
} catch {
|
||
return value
|
||
}
|
||
}
|
||
|
||
const getJobTypeText = (type?: string | null) => {
|
||
if (type === 'asset_label') return '资产标签'
|
||
return type || '未知任务'
|
||
}
|
||
|
||
const getJobStatusText = (status?: string | null) => {
|
||
if (status === 'queued') return '排队中'
|
||
if (status === 'claimed') return '已领取'
|
||
if (status === 'printing') return '打印中'
|
||
if (status === 'completed') return '已完成'
|
||
if (status === 'failed') return '失败'
|
||
if (status === 'cancelled') return '已取消'
|
||
return status || '未知'
|
||
}
|
||
|
||
const getJobStatusBadgeClass = (status?: string | null) => {
|
||
if (status === 'completed') return 'badge-success'
|
||
if (status === 'failed') return 'badge-error'
|
||
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
|
||
return 'badge-neutral'
|
||
}
|
||
|
||
const getTerminalLabel = (job: PrintJob) => {
|
||
return job.target_terminal_name || (job.target_terminal_id ? `终端 ${job.target_terminal_id}` : '等待默认终端')
|
||
}
|
||
|
||
const getEquipmentCount = (job: PrintJob) => {
|
||
if (Array.isArray(job.payload?.equipment_ids)) {
|
||
return job.payload.equipment_ids.length
|
||
}
|
||
if (Array.isArray(job.payload?.equipment_codes)) {
|
||
return job.payload.equipment_codes.length
|
||
}
|
||
return 0
|
||
}
|
||
|
||
const getJobResultText = (job: PrintJob) => {
|
||
if (job.error_message) return job.error_message
|
||
if (job.status === 'completed') {
|
||
return job.completed_at ? `已完成,时间 ${formatNullableDateTime(job.completed_at)}` : '已完成'
|
||
}
|
||
if (job.status === 'printing') return '终端正在打印'
|
||
if (job.status === 'claimed') return '终端已领取,等待开始打印'
|
||
if (job.status === 'queued') return '等待默认打印电脑上线或接单'
|
||
if (job.status === 'cancelled') return '任务已取消'
|
||
return '状态待更新'
|
||
}
|
||
|
||
const fetchJobs = async () => {
|
||
loading.value = true
|
||
try {
|
||
const response = await getPrintJobs({
|
||
page: page.value,
|
||
pageSize,
|
||
status: statusFilter.value
|
||
})
|
||
jobs.value = response.list || []
|
||
total.value = response.total || jobs.value.length
|
||
} catch (error: any) {
|
||
alert(error?.message || '获取打印任务失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSearch = () => {
|
||
page.value = 1
|
||
fetchJobs()
|
||
}
|
||
|
||
const handlePrevPage = () => {
|
||
if (page.value <= 1) return
|
||
page.value -= 1
|
||
fetchJobs()
|
||
}
|
||
|
||
const handleNextPage = () => {
|
||
if (page.value >= totalPages.value) return
|
||
page.value += 1
|
||
fetchJobs()
|
||
}
|
||
|
||
const handleCancelJob = async (job: PrintJob) => {
|
||
if (job.status !== 'queued') return
|
||
if (!window.confirm(`确定要取消任务 #${job.id} 吗?`)) {
|
||
return
|
||
}
|
||
|
||
busyJobId.value = job.id
|
||
try {
|
||
await cancelQueuedPrintJob(job.id)
|
||
await fetchJobs()
|
||
alert('打印任务已取消')
|
||
} catch (error: any) {
|
||
alert(error?.message || '取消打印任务失败')
|
||
} finally {
|
||
busyJobId.value = null
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchJobs()
|
||
})
|
||
</script>
|