WuYongJie c7a9324635 feat(打印): 新增远程标签打印与 .NET 打印助手
- 新增打印终端、配对码、打印任务相关表结构与接口\n- 增加后台打印终端/任务管理页和资产标签远程打印入口\n- 引入 .NET 8 Windows 打印助手源码及下载清单配置
2026-03-25 21:05:30 +08:00

319 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>