feat:卡片打印机

This commit is contained in:
dzc committed 2026-09-23 16:49:28 +08:00
1 parent 53f79424b8
commit cf1caef444
3 files changed
+130 -90

No files matched your search

+64 -16
View File
@@ -1,17 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 96">
<!-- 放卡方向:向下放入进卡槽 -->
<path d="M75 4 v12 M69 11 l6 6 l6 -6" fill="none" stroke="#2E5CF6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<!-- 打印机主体(俯视) -->
<rect x="14" y="22" width="122" height="64" rx="10" fill="#f5f7fa" stroke="#dcdfe6" />
<!-- 顶部进卡槽开口 -->
<rect x="30" y="34" width="90" height="26" rx="4" fill="#ebedf0" />
<!-- 卡片(横放在槽内,打印面朝上) -->
<rect x="33" y="37" width="84" height="20" rx="2.5" fill="#e4e7ed" />
<rect x="35" y="35" width="80" height="18" rx="2.5" fill="#ffffff" stroke="#ebeef5" />
<!-- 打印面上的图案(头像圆点 + 文字条),表示这一面朝上 -->
<circle cx="44" cy="44" r="3.6" fill="#2E5CF6" />
<rect x="51" y="41.5" width="30" height="2.6" rx="1.3" fill="#2E5CF6" />
<rect x="51" y="46.5" width="22" height="2.6" rx="1.3" fill="#2E5CF6" />
<!-- 前侧出卡口 -->
<rect x="54" y="76" width="42" height="6" rx="3" fill="#ebedf0" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 756 266" font-family="PingFang SC, Microsoft YaHei, sans-serif">
<!-- 顶部三要领 -->
<text x="378" y="20" text-anchor="middle" font-size="13.5" font-weight="bold" fill="#2E5CF6">长边左右横放 | 预印面朝上 | 文字正对自己</text>
<!-- ══════════ 图例① ✅ 正确放法 ══════════ -->
<circle cx="14" cy="42" r="9" fill="#67c23a"/>
<text x="14" y="46" text-anchor="middle" font-size="11" font-weight="bold" fill="#ffffff">✓</text>
<text x="30" y="46.5" font-size="13" font-weight="bold" fill="#67c23a">正确放法</text>
<rect x="0" y="60" width="244" height="154" rx="8" fill="#ffffff" stroke="#dcdfe6" stroke-width="1.5"/>
<!-- 预印(出厂已有):校徽 + 校名真实文字 -->
<path d="M14 72 h18 v11 l-9 8 l-9 -8 z" fill="#2E5CF6"/>
<text x="40" y="85" font-size="13" font-weight="bold" fill="#2E5CF6">青鸟同文实验学校</text>
<text x="40" y="99" font-size="9" fill="#a8abb2">学生卡 · STUDENT CARD</text>
<line x1="14" y1="110" x2="230" y2="110" stroke="#ebeef5"/>
<!-- 我们打印的文字(黑色,与预印同向正立) -->
<text x="18" y="140" font-size="16" font-weight="bold" fill="#303133">吴至元</text>
<text x="18" y="164" font-size="11.5" fill="#606266">青鸟班 · Q4</text>
<text x="18" y="184" font-size="10" fill="#909399">2023·09·01022</text>
<text x="122" y="236" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#67c23a">✓ 打印与预印同面、同向</text>
<text x="122" y="254" text-anchor="middle" font-size="10.5" fill="#909399">成品直接可用</text>
<!-- ══════════ 图例② ✗ 面放反了 ══════════ -->
<circle cx="270" cy="42" r="9" fill="#f56c6c"/>
<text x="270" y="46" text-anchor="middle" font-size="11" font-weight="bold" fill="#ffffff">✗</text>
<text x="286" y="46.5" font-size="13" font-weight="bold" fill="#f56c6c">面放反了</text>
<rect x="256" y="60" width="244" height="154" rx="8" fill="#ffffff" stroke="#dcdfe6" stroke-width="1.5"/>
<path d="M270 72 h18 v11 l-9 8 l-9 -8 z" fill="#2E5CF6"/>
<text x="296" y="85" font-size="13" font-weight="bold" fill="#2E5CF6">青鸟同文实验学校</text>
<text x="296" y="99" font-size="9" fill="#a8abb2">学生卡 · STUDENT CARD</text>
<line x1="270" y1="110" x2="486" y2="110" stroke="#ebeef5"/>
<!-- 打印的字落在背面:隔着卡料透显 = 左右反字、变淡 -->
<g transform="translate(756 0) scale(-1 1)" opacity="0.75">
<text x="274" y="140" font-size="16" font-weight="bold" fill="#a8abb2">吴至元</text>
<text x="274" y="164" font-size="11.5" fill="#a8abb2">青鸟班 · Q4</text>
<text x="274" y="184" font-size="10" fill="#a8abb2">2023·09·01022</text>
</g>
<text x="378" y="204" text-anchor="middle" font-size="8.5" fill="#c0c4cc">(隔卡料透显)</text>
<text x="378" y="236" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#f56c6c">✗ 字印到背面,正面看是反字</text>
<text x="378" y="254" text-anchor="middle" font-size="10.5" fill="#909399">整叠卡翻面重放即可</text>
<!-- ══════════ 图例③ ✗ 卡倒着放 ══════════ -->
<circle cx="526" cy="42" r="9" fill="#f56c6c"/>
<text x="526" y="46" text-anchor="middle" font-size="11" font-weight="bold" fill="#ffffff">✗</text>
<text x="542" y="46.5" font-size="13" font-weight="bold" fill="#f56c6c">卡倒着放</text>
<rect x="512" y="60" width="244" height="154" rx="8" fill="#ffffff" stroke="#dcdfe6" stroke-width="1.5"/>
<!-- 预印设计整组倒立(卡转了180°放入) -->
<g transform="rotate(180 634 137)">
<path d="M526 72 h18 v11 l-9 8 l-9 -8 z" fill="#2E5CF6"/>
<text x="552" y="85" font-size="13" font-weight="bold" fill="#2E5CF6">青鸟同文实验学校</text>
<text x="552" y="99" font-size="9" fill="#a8abb2">学生卡 · STUDENT CARD</text>
<line x1="526" y1="110" x2="742" y2="110" stroke="#ebeef5"/>
</g>
<!-- 我们打印的字正立(与倒立的预印上下相反) -->
<text x="530" y="140" font-size="16" font-weight="bold" fill="#303133">吴至元</text>
<text x="530" y="164" font-size="11.5" fill="#606266">青鸟班 · Q4</text>
<text x="530" y="184" font-size="10" fill="#909399">2023·09·01022</text>
<text x="634" y="236" text-anchor="middle" font-size="11.5" font-weight="bold" fill="#f56c6c">✗ 打印字与预印字上下相反</text>
<text x="634" y="254" text-anchor="middle" font-size="10.5" fill="#909399">把卡转正重放即可</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+9 -3
View File
@@ -57,13 +57,14 @@ export const sampleFieldsFor = (identity: string): Record<string, string> => {
// ---------------------------------------------------------------------------
const inlineCache = new Map<string, string>()
/** 单张图片转 data URL:fetch → crossOrigin img → 失败返回原地址(导出时再报错) */
/** 单张图片转 data URL:fetch → crossOrigin img → 失败返回原地址(导出时再报错)。
* 两条通道均有 8s 超时:外链偶发挂起时不再无限等待(否则多卡批量会卡死在该卡的导出上)。 */
export const inlineImage = async (url: string): Promise<string> => {
if (!url || /^(data:)/i.test(url)) return url
const cached = inlineCache.get(url)
if (cached) return cached
try {
const res = await fetch(url, { mode: "cors" })
const res = await fetch(url, { mode: "cors", signal: AbortSignal.timeout(8000) })
if (res.ok) {
const blob = await res.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
@@ -80,9 +81,11 @@ export const inlineImage = async (url: string): Promise<string> => {
}
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("img load timeout")), 8000)
const img = new Image()
img.crossOrigin = "anonymous"
img.onload = () => {
clearTimeout(timer)
const canvas = document.createElement("canvas")
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
@@ -93,7 +96,10 @@ export const inlineImage = async (url: string): Promise<string> => {
reject(err)
}
}
img.onerror = reject
img.onerror = () => {
clearTimeout(timer)
reject(new Error("img load fail"))
}
img.src = url
})
inlineCache.set(url, dataUrl)
@@ -159,16 +159,13 @@
</div>
<div v-show="!guideCollapsed" class="guide-body">
<div class="guide-fig">
<img :src="cardGuideSvg" class="guide-svg" alt="放卡方向示意图(横放·打印面朝上)" draggable="false" />
<span class="fig-cap">横放 · 打印面朝上</span>
<img :src="cardGuideSvg" class="guide-svg" alt="放卡图例:预印面朝上为正确;面放反字透背面;卡倒放上下颠倒" draggable="false" />
<p class="guide-note">
<span v-if="previewDocs">当前为{{ orientationLabel }}模板,放卡方式相同,无需转卡</span>
<span v-if="previewHasBack">双面模板:背面用下一张卡打印</span>
<span v-if="currentSideBgNone">「无背景」区域不上墨(白卡底)</span>
</p>
</div>
<ul class="guide-points">
<li>空白卡<b>横放</b>(长边朝左右)放入打印机<b>顶部进卡槽</b>,<b>打印面朝上</b>,一次约 100 张,放好后合上进卡槽盖。</li>
<li>请勿用手触摸卡片打印面,指纹油污会影响打印效果。</li>
<li v-if="previewDocs">当前模板为<b>{{ orientationLabel }}版式</b>:放卡方式不变,机器会按模板方向自动排版,<b>无需转卡</b>。</li>
<li v-if="previewHasBack">CS-200e 为<b>单面机型</b>:双面模板的背面会使用<b>下一张卡</b>打印,放卡方式相同。</li>
<li v-if="currentSideBgNone">卡面「无背景(透明)」区域打印时<b>不上墨</b>,成品即为白卡底色。</li>
</ul>
</div>
</div>
@@ -285,6 +282,8 @@ interface QueueItem {
const queue = ref<QueueItem[]>([])
const printing = ref(false)
const abortRequested = ref(false)
/** 抽屉关闭时中止在途任务轮询(否则关抽屉后轮询还会空转到超时) */
const onCloseCancellers: Array<() => void> = []
const identitySummary = computed(() => {
const summary = { student: 0, teacher: 0, other: 0, unbound: 0 }
@@ -421,32 +420,46 @@ const printOne = async (item: QueueItem) => {
item.state = "printing"
const { taskId } = await startPrint({ copies: copies.value, front, back })
// 轮询任务:1.2s 一次,3 分钟超时
// 轮询任务:1.2s 一次,3 分钟超时。递归 setTimeout 防止慢响应时轮询重叠;
// 任务号失效(助手重启过)快速失败,连续通讯失败 5 次中止,不再傻等满 3 分钟
await new Promise<void>((resolve, reject) => {
const startedAt = Date.now()
const timer = setInterval(async () => {
let failStreak = 0
let timer: ReturnType<typeof setTimeout> | null = null
const tick = async () => {
if (Date.now() - startedAt > 180_000) {
clearInterval(timer)
reject(new Error("打印超时,请检查打印机状态"))
return
}
try {
const task = await getPrintTask(taskId)
failStreak = 0
if (task.state === "done") {
clearInterval(timer)
resolve()
} else if (task.state === "error") {
clearInterval(timer)
return
}
if (task.state === "error") {
reject(new Error(deviceStatusText(task.status, task.statusText || "打印机返回异常")))
return
}
} catch (err: any) {
// 单次轮询失败不终止;助手失联则终止
if (err?.message?.includes("助手")) {
clearInterval(timer)
reject(err)
const msg = String(err?.message || err)
if (msg.includes("任务不存在") || msg.includes("已过期")) {
reject(new Error("打印任务丢失(打印助手可能重启过),请重试"))
return
}
if (++failStreak >= 5) {
reject(new Error("与打印助手通讯中断:" + msg))
return
}
}
}, 1200)
timer = setTimeout(tick, 1200)
}
timer = setTimeout(tick, 1200)
onCloseCancellers.push(() => {
if (timer) clearTimeout(timer)
reject(new Error("已停止打印"))
})
})
item.state = "done"
}
@@ -584,6 +597,14 @@ document.addEventListener("visibilitychange", onVisibilityChange)
const onClose = () => {
stopStatusPolling()
abortRequested.value = true
while (onCloseCancellers.length) {
const cancel = onCloseCancellers.shift()
try {
cancel?.()
} catch {
// 已 settle 的 promise 再 reject 是空操作
}
}
}
watch(visible, (v) => {
@@ -870,13 +891,14 @@ onUnmounted(() => {
}
}
/* 放卡指引:紧贴打印按钮上方,打印前看一眼装卡方向 */
/* 放卡指引:紧贴打印按钮上方,打印前看一眼装卡方向(醒目样式) */
.card-guide {
flex-shrink: 0;
margin-top: 12px;
padding: 10px 14px;
padding: 12px 14px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border: 1px solid var(--el-color-primary-light-5);
border-left: 4px solid var(--el-color-primary);
border-radius: 10px;
.guide-head {
@@ -891,9 +913,9 @@ onUnmounted(() => {
}
.guide-title {
font-size: 13px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
color: var(--el-color-primary);
}
.guide-sub {
@@ -904,62 +926,26 @@ onUnmounted(() => {
}
.guide-body {
display: flex;
gap: 18px;
align-items: center;
margin-top: 8px;
padding-top: 10px;
border-top: 1px dashed var(--el-color-primary-light-7);
border-top: 1px dashed var(--el-color-primary-light-5);
}
.guide-fig {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 2px;
align-items: center;
.guide-svg {
display: block;
width: 138px;
height: 88px;
width: 100%;
min-width: 480px;
max-width: 756px;
height: auto;
}
.fig-cap {
.guide-note {
display: flex;
gap: 16px;
margin: 6px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--el-color-primary);
}
}
.guide-points {
flex: 1;
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
li {
position: relative;
padding-left: 14px;
font-size: 12px;
line-height: 1.8;
color: var(--el-text-color-regular);
&::before {
position: absolute;
top: 0.72em;
left: 2px;
width: 5px;
height: 5px;
content: "";
border-radius: 50%;
background: var(--el-color-primary);
}
b {
color: var(--el-text-color-primary);
}
color: var(--el-text-color-secondary);
}
}
}