feat:卡片打印机
This commit is contained in:
1 parent
53f79424b8
commit
cf1caef444
3 files changed
+130
-90
No files matched your search
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user