更新打印机连接
This commit is contained in:
@@ -5,6 +5,12 @@ interface ImportMetaEnv {
|
||||
readonly VITE_OPENPRINT_API_BASE?: string
|
||||
/** 可选:Bearer 鉴权 token */
|
||||
readonly VITE_OPENPRINT_API_TOKEN?: string
|
||||
/**
|
||||
* 可选:本地打印客户端基地址,覆盖出厂默认 http://127.0.0.1:18888。
|
||||
* 例:VITE_OPENPRINT_PRINTER_BASE=http://192.168.1.20:19000
|
||||
* 优先级低于「设置 → 本地打印」里手填的 IP/端口。
|
||||
*/
|
||||
readonly VITE_OPENPRINT_PRINTER_BASE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
@@ -42,7 +42,7 @@ describe('print-settings —— 打印设置读写', () => {
|
||||
const s = readPrintSettings()
|
||||
expect(s.local.method).toBe('silent')
|
||||
expect(s.local.copies).toBe(DEFAULT_PRINT_SETTINGS.local.copies)
|
||||
expect(s.local.silent).toEqual({ host: '127.0.0.1', port: 9122 })
|
||||
expect(s.local.silent).toEqual({ host: '127.0.0.1', port: 18888 })
|
||||
expect(s.remote.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
export interface LocalPrintConfig {
|
||||
/** 打印方式:browser=浏览器打印对话框 / silent=客户端静默打印(需本地客户端服务) */
|
||||
method: 'browser' | 'silent'
|
||||
/** 静默打印客户端连接地址(仅 method=silent 生效) */
|
||||
/**
|
||||
* 本地打印客户端连接地址。
|
||||
* 同时也是 /health、/printers、/print 三个接口的基地址来源,
|
||||
* 解析优先级见 `@/config/printer` 的 resolvePrinterBaseUrl()。
|
||||
*/
|
||||
silent: {
|
||||
/** 客户端所在主机 IP(默认本机回环) */
|
||||
/** 客户端所在主机 IP(默认本机回环 127.0.0.1) */
|
||||
host: string
|
||||
/** 客户端服务端口(默认 9122) */
|
||||
/** 客户端服务端口(默认 18888) */
|
||||
port: number
|
||||
}
|
||||
/** 副本数 */
|
||||
@@ -40,7 +44,7 @@ export const PRINT_SETTINGS_KEY = 'openprint:print-settings'
|
||||
export const DEFAULT_PRINT_SETTINGS: PrintSettings = {
|
||||
local: {
|
||||
method: 'browser',
|
||||
silent: { host: '127.0.0.1', port: 9122 },
|
||||
silent: { host: '127.0.0.1', port: 18888 },
|
||||
copies: 1,
|
||||
closeAfterPrint: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 打印客户端服务地址解析 —— 端口/地址覆盖的唯一出口
|
||||
*
|
||||
* 出厂默认:http://127.0.0.1:18888
|
||||
*
|
||||
* 覆盖优先级(高 → 低):
|
||||
* 1. 用户在「设置 → 本地打印」里填的 IP/端口(localStorage `openprint:print-settings`)
|
||||
* 2. 构建期环境变量 `VITE_OPENPRINT_PRINTER_BASE`(项目根 .env)
|
||||
* 3. 出厂默认 http://127.0.0.1:18888
|
||||
*
|
||||
* 主任铁律:无后端全链路可用 —— 这里只读本地存储与构建期常量,绝不请求任何后端。
|
||||
*/
|
||||
import { DEFAULT_PRINTER_BASE_URL } from '@/core/print-client'
|
||||
import { PRINT_SETTINGS_KEY } from './print-settings'
|
||||
|
||||
/** 构建期环境变量覆盖(示例:VITE_OPENPRINT_PRINTER_BASE=http://192.168.1.20:19000) */
|
||||
const ENV_PRINTER_BASE = (import.meta.env.VITE_OPENPRINT_PRINTER_BASE ?? '').trim()
|
||||
|
||||
/** 地址来源,UI 用于提示用户当前生效的是哪一层配置 */
|
||||
export type PrinterBaseSource = 'settings' | 'env' | 'default'
|
||||
|
||||
/** 出厂默认,供 UI 占位/重置使用 */
|
||||
export const FACTORY_PRINTER_BASE_URL = DEFAULT_PRINTER_BASE_URL
|
||||
export const FACTORY_PRINTER_HOST = '127.0.0.1'
|
||||
export const FACTORY_PRINTER_PORT = 18888
|
||||
|
||||
/** 补全协议 + 去掉尾部斜杠 */
|
||||
export function normalizePrinterBase(input: string): string {
|
||||
const raw = input.trim().replace(/\/+$/, '')
|
||||
if (!raw) return ''
|
||||
return /^https?:\/\//i.test(raw) ? raw : `http://${raw}`
|
||||
}
|
||||
|
||||
/** host + port → 基地址。host 可带协议(http://x)或只写 IP/域名 */
|
||||
export function buildPrinterBase(host: string, port: number): string {
|
||||
const base = normalizePrinterBase(host)
|
||||
if (!base) return ''
|
||||
// host 里已经自带端口就不再拼(如用户填了 "127.0.0.1:18888")
|
||||
if (/:\d+$/.test(base)) return base
|
||||
const p = Number.isFinite(port) && port > 0 ? Math.floor(port) : FACTORY_PRINTER_PORT
|
||||
return `${base}:${p}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取用户在设置里显式配置的端点。
|
||||
* 只有 localStorage 里确实存在 local.silent.host/port 才算「用户配置过」,
|
||||
* 否则返回 null 让位给环境变量。
|
||||
*/
|
||||
export function readStoredPrinterEndpoint(): { host: string; port: number } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PRINT_SETTINGS_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as {
|
||||
local?: { silent?: { host?: unknown; port?: unknown } }
|
||||
}
|
||||
const silent = parsed?.local?.silent
|
||||
if (!silent) return null
|
||||
const host = typeof silent.host === 'string' ? silent.host.trim() : ''
|
||||
const port = typeof silent.port === 'number' ? silent.port : Number(silent.port)
|
||||
if (!host || !Number.isFinite(port) || port <= 0) return null
|
||||
return { host, port: Math.floor(port) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前生效的打印客户端基地址 */
|
||||
export function resolvePrinterBaseUrl(): string {
|
||||
const stored = readStoredPrinterEndpoint()
|
||||
if (stored) {
|
||||
const base = buildPrinterBase(stored.host, stored.port)
|
||||
if (base) return base
|
||||
}
|
||||
if (ENV_PRINTER_BASE) {
|
||||
const base = normalizePrinterBase(ENV_PRINTER_BASE)
|
||||
if (base) return base
|
||||
}
|
||||
return FACTORY_PRINTER_BASE_URL
|
||||
}
|
||||
|
||||
/** 当前地址来自哪一层配置(UI 提示用) */
|
||||
export function resolvePrinterBaseSource(): PrinterBaseSource {
|
||||
if (readStoredPrinterEndpoint()) return 'settings'
|
||||
if (ENV_PRINTER_BASE) return 'env'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
/** 是否存在构建期环境变量覆盖(设置面板里给出说明) */
|
||||
export const hasEnvPrinterBase = ENV_PRINTER_BASE.length > 0
|
||||
|
||||
/** 环境变量原值(仅用于设置面板展示) */
|
||||
export const envPrinterBase = ENV_PRINTER_BASE
|
||||
@@ -21,7 +21,7 @@ export function renderBar(model: ChartModel): string {
|
||||
const titleH = title ? 22 : 0
|
||||
const mTop = 10 + titleH
|
||||
const mRight = 16
|
||||
const mBottom = (showAxis ? 38 : 22) + legendH
|
||||
const mBottom = (showAxis ? 42 : 22) + legendH
|
||||
const mLeft = showAxis ? 44 : 16
|
||||
|
||||
const px0 = mLeft
|
||||
@@ -64,7 +64,7 @@ export function renderBar(model: ChartModel): string {
|
||||
if (showGrid && i > 0) {
|
||||
parts.push(`<line x1="${px0}" y1="${y.toFixed(1)}" x2="${px1}" y2="${y.toFixed(1)}" stroke="#EEEEEE" stroke-width="1"/>`)
|
||||
}
|
||||
parts.push(`<text x="${px0 - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="#8a8f99">${fmt(v)}</text>`)
|
||||
parts.push(`<text x="${px0 - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="11" fill="#000000">${fmt(v)}</text>`)
|
||||
}
|
||||
// 基线
|
||||
parts.push(`<line x1="${px0}" y1="${py1}" x2="${px1}" y2="${py1}" stroke="#cccccc" stroke-width="1"/>`)
|
||||
@@ -83,7 +83,7 @@ export function renderBar(model: ChartModel): string {
|
||||
const color = seriesColor(model.series[s]!, s, opt)
|
||||
parts.push(`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${(barW * 0.9).toFixed(1)}" height="${Math.max(0, h).toFixed(1)}" fill="${color}" rx="1.5"/>`)
|
||||
if (valueLabel && h > 0) {
|
||||
parts.push(`<text x="${(x + barW * 0.45).toFixed(1)}" y="${(y - 4).toFixed(1)}" text-anchor="middle" font-size="9" fill="#5a6068">${fmt(val)}</text>`)
|
||||
parts.push(`<text x="${(x + barW * 0.45).toFixed(1)}" y="${(y - 4).toFixed(1)}" text-anchor="middle" font-size="10" fill="#000000">${fmt(val)}</text>`)
|
||||
}
|
||||
}
|
||||
// x 轴标签:始终以类目为基准居中于对应条带正下方,并与图表留一点间距
|
||||
@@ -102,7 +102,7 @@ export function renderBar(model: ChartModel): string {
|
||||
}
|
||||
const startX = legendStartX(align, W, totalW, px0)
|
||||
let lx = startX
|
||||
let ly = py1 + (showAxis ? 30 : 16)
|
||||
let ly = py1 + (showAxis ? 40 : 16)
|
||||
for (let s = 0; s < sCount; s++) {
|
||||
const name = model.series[s]!.name || `系列${s + 1}`
|
||||
const color = seriesColor(model.series[s]!, s, opt)
|
||||
@@ -112,7 +112,7 @@ export function renderBar(model: ChartModel): string {
|
||||
ly += 16
|
||||
}
|
||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="10" fill="#5a6068">${escapeXml(truncate(name, 10))}</text>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="11" fill="#000000">${escapeXml(truncate(name, 10))}</text>`)
|
||||
lx += itemW
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function renderLine(model: ChartModel): string {
|
||||
const titleH = title ? 22 : 0
|
||||
const mTop = 10 + titleH
|
||||
const mRight = 16
|
||||
const mBottom = (showAxis ? 38 : 22) + legendH
|
||||
const mBottom = (showAxis ? 42 : 22) + legendH
|
||||
const mLeft = showAxis ? 44 : 16
|
||||
|
||||
const px0 = mLeft
|
||||
@@ -95,7 +95,7 @@ export function renderLine(model: ChartModel): string {
|
||||
if (showGrid && i > 0) {
|
||||
parts.push(`<line x1="${px0}" y1="${y.toFixed(1)}" x2="${px1}" y2="${y.toFixed(1)}" stroke="#EEEEEE" stroke-width="1"/>`)
|
||||
}
|
||||
parts.push(`<text x="${px0 - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="10" fill="#8a8f99">${fmt(v)}</text>`)
|
||||
parts.push(`<text x="${px0 - 6}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="11" fill="#000000">${fmt(v)}</text>`)
|
||||
}
|
||||
parts.push(`<line x1="${px0}" y1="${py0}" x2="${px0}" y2="${py1}" stroke="#cccccc" stroke-width="1"/>`)
|
||||
parts.push(`<line x1="${px0}" y1="${py1}" x2="${px1}" y2="${py1}" stroke="#cccccc" stroke-width="1"/>`)
|
||||
@@ -126,7 +126,7 @@ export function renderLine(model: ChartModel): string {
|
||||
const series = model.series[si]!
|
||||
for (let c = 0; c < sp.pts.length; c++) {
|
||||
const v = series.data[c] ?? 0
|
||||
parts.push(`<text x="${sp.pts[c]!.x.toFixed(1)}" y="${(sp.pts[c]!.y - 6).toFixed(1)}" text-anchor="middle" font-size="9" fill="#5a6068">${fmt(v)}</text>`)
|
||||
parts.push(`<text x="${sp.pts[c]!.x.toFixed(1)}" y="${(sp.pts[c]!.y - 6).toFixed(1)}" text-anchor="middle" font-size="10" fill="#000000">${fmt(v)}</text>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export function renderLine(model: ChartModel): string {
|
||||
}
|
||||
const startX = legendStartX(align, W, totalW, px0)
|
||||
let lx = startX
|
||||
let ly = py1 + (showAxis ? 30 : 16)
|
||||
let ly = py1 + (showAxis ? 40 : 16)
|
||||
for (let s = 0; s < seriesPts.length; s++) {
|
||||
const name = seriesPts[s]!.name || `系列${s + 1}`
|
||||
const color = seriesPts[s]!.color
|
||||
@@ -150,7 +150,7 @@ export function renderLine(model: ChartModel): string {
|
||||
ly += 16
|
||||
}
|
||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="10" fill="#5a6068">${escapeXml(truncate(name, 10))}</text>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="11" fill="#000000">${escapeXml(truncate(name, 10))}</text>`)
|
||||
lx += itemW
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function renderPie(model: ChartModel): string {
|
||||
const lr = donut ? (rOuter + rInner) / 2 : rOuter * 0.62
|
||||
const p = polarL(lr, mid)
|
||||
const pct = ((v / total) * 100).toFixed(0) + '%'
|
||||
parts.push(`<text x="${p.x.toFixed(1)}" y="${(p.y + 3).toFixed(1)}" text-anchor="middle" font-size="10" fill="#ffffff" font-weight="600">${pct}</text>`)
|
||||
parts.push(`<text x="${p.x.toFixed(1)}" y="${(p.y + 3).toFixed(1)}" text-anchor="middle" font-size="11" fill="#ffffff" font-weight="600">${pct}</text>`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function renderPie(model: ChartModel): string {
|
||||
ly += 16
|
||||
}
|
||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="10" fill="#5a6068">${escapeXml(truncate(name, 10))}</text>`)
|
||||
parts.push(`<text x="${lx + 15}" y="${ly}" font-size="11" fill="#000000">${escapeXml(truncate(name, 10))}</text>`)
|
||||
lx += itemW
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* chart-svg-to-path —— 把图表 SVG 里的 <text> 转成矢量 <path> 字形轮廓
|
||||
*
|
||||
* 背景:jsPDF 只能内嵌 TrueType(glyf) 字体,无法内嵌 CFF/OpenType 字体;
|
||||
* 思源宋体 .ttf 实为 CFF,jsPDF 内嵌会静默失败 → 矢量图表中文标签变空白。
|
||||
* 解决:在导出 PDF 的矢量路径里,用 opentype.js 把每个字形展开为 path,
|
||||
* 文本即成为与字体无关的矢量轮廓,svg2pdf 直接画 path,中文不再丢。
|
||||
*
|
||||
* 仅 export-pdf 的图表矢量路径按需动态调用,不进主包、不影响设计期/预览。
|
||||
*/
|
||||
const SERIF_TTF_URL = '/fonts/SourceHanSerifCN-Regular.ttf'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let fontPromise: Promise<any> | null = null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function loadFont(): Promise<any | null> {
|
||||
if (!fontPromise) {
|
||||
fontPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(SERIF_TTF_URL)
|
||||
if (!res.ok) return null
|
||||
const buf = await res.arrayBuffer()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ot: any = await import('opentype.js')
|
||||
const parse = ot.parse ?? ot.default?.parse ?? ot.default
|
||||
return parse(buf)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return fontPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 把图表 SVG 字符串里的所有 <text> 替换为字形轮廓 <path>。
|
||||
* 字体加载失败/解析异常时原样返回(交给 svg2pdf,最坏情况该页走栅格兜底)。
|
||||
*/
|
||||
export async function outlineChartSvgText(svg: string): Promise<string> {
|
||||
const font = await loadFont()
|
||||
if (!font) return svg
|
||||
|
||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||
const svgEl = doc.documentElement
|
||||
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg') return svg
|
||||
|
||||
const texts = Array.from(svgEl.getElementsByTagName('text'))
|
||||
for (const t of texts) {
|
||||
const text = t.textContent ?? ''
|
||||
if (!text) {
|
||||
t.remove()
|
||||
continue
|
||||
}
|
||||
const x = parseFloat(t.getAttribute('x') ?? '0') || 0
|
||||
const y = parseFloat(t.getAttribute('y') ?? '0') || 0
|
||||
const fontSize = parseFloat(t.getAttribute('font-size') ?? '10') || 10
|
||||
const fill = t.getAttribute('fill') || '#000000'
|
||||
const anchor = (t.getAttribute('text-anchor') || 'start').toLowerCase()
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const total = font.getAdvanceWidth(text, fontSize)
|
||||
let startX = x
|
||||
if (anchor === 'middle') startX = x - total / 2
|
||||
else if (anchor === 'end') startX = x - total
|
||||
|
||||
const ds: string[] = []
|
||||
let cursor = startX
|
||||
for (const ch of text) {
|
||||
const g = font.charToGlyph(ch)
|
||||
if (!g) continue
|
||||
const gp = g.getPath(cursor, y, fontSize)
|
||||
const d = gp.toPathData(2)
|
||||
if (d && d.trim()) ds.push(d)
|
||||
cursor += (g.advanceWidth || 0) * scale
|
||||
}
|
||||
|
||||
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', ds.join(' '))
|
||||
path.setAttribute('fill', fill)
|
||||
if (t.parentNode) t.parentNode.replaceChild(path, t)
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(svgEl)
|
||||
}
|
||||
@@ -4,13 +4,15 @@
|
||||
* ## 图表走矢量(svg2pdf)
|
||||
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
|
||||
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
|
||||
* 2. 再遍历该页 chart 节点,用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
|
||||
* 3. 思源宋体 TTF 注册进 jsPDF('SourceHanSerifCN'),图表中文标签才不会变空白/方块。
|
||||
* 2. 再遍历该页 chart 节点,把 SVG 里的 `<text>` 用 opentype.js 展开为**矢量字形轮廓**,
|
||||
* 再用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
|
||||
* (关键:jsPDF 只能内嵌 TrueType 字体、无法内嵌 CFF/OpenType,而思源宋体恰为 CFF,
|
||||
* 直接注册会静默失败导致中文标签空白——转轮廓后文字与字体完全解耦,中文必现。)
|
||||
*
|
||||
* 兜底:
|
||||
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
|
||||
* - 单个图表 svg2pdf 失败 → 该图表单独栅格化后叠加(不丢图);
|
||||
* - 字体加载失败(极端环境)→ 整页退回全栅格化(仍是位图,但保证有图)。
|
||||
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(不丢图);
|
||||
* - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
|
||||
*
|
||||
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
|
||||
*/
|
||||
@@ -20,14 +22,12 @@ import { embedFontsInSvg, type FontFaceDef } from './fonts'
|
||||
import type { PageDecoration } from '@/types/template'
|
||||
import { blobToDataURL } from './util'
|
||||
import { mmv } from '@/core/renderer-html/css-generator'
|
||||
import { outlineChartSvgText } from './chart-svg-to-path'
|
||||
|
||||
/** 思源宋体 —— PDF 唯一内联字体(写死 TTF,位图 PDF 保证中文衬线效果;矢量图表也注册它) */
|
||||
/** 思源宋体 —— 位图底图(含非图表文本)内联字体,保证中文衬线效果 */
|
||||
const PDF_FONTS: FontFaceDef[] = [
|
||||
{ family: '思源宋体', src: '/fonts/SourceHanSerifCN-Regular.ttf', weight: 400 },
|
||||
]
|
||||
/** 矢量图表文本使用的 jsPDF 字体名(必须与 chartkit SVG 的 font-family 首选项一致) */
|
||||
const SERIF_FONT_NAME = 'SourceHanSerifCN'
|
||||
const SERIF_TTF_URL = '/fonts/SourceHanSerifCN-Regular.ttf'
|
||||
|
||||
export interface PdfOptions {
|
||||
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi,默认 2 */
|
||||
@@ -76,31 +76,6 @@ function chartsOf(page: LayoutPage): ChartBox[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/** ArrayBuffer → base64(分块,避免 call stack 溢出) */
|
||||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let bin = ''
|
||||
const chunk = 0x8000
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
/** 把思源宋体 TTF 注册进 jsPDF,供矢量图表中文标签使用。失败不抛(退化为位图兜底) */
|
||||
async function registerSerifFont(doc: { addFileToVFS: (n: string, d: string) => void; addFont: (f: string, n: string, s: string) => void }): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(SERIF_TTF_URL)
|
||||
if (!res.ok) return false
|
||||
const b64 = arrayBufferToBase64(await res.arrayBuffer())
|
||||
doc.addFileToVFS(`${SERIF_FONT_NAME}.ttf`, b64)
|
||||
doc.addFont(`${SERIF_FONT_NAME}.ttf`, SERIF_FONT_NAME, 'normal')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 单图表栅格化(svg2pdf 失败时的兜底,保证不丢图) */
|
||||
async function rasterizeChart(chartSvg: string, wMm: number, hMm: number, scale: number): Promise<string> {
|
||||
const inner = `<div style="width:${mmv(wMm)};height:${mmv(hMm)};overflow:hidden">${chartSvg}</div>`
|
||||
@@ -127,8 +102,6 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
||||
|
||||
const doc = new JsPDF({ unit: 'mm', format: [w, h], orientation })
|
||||
|
||||
let serifReady = false
|
||||
|
||||
for (let i = 0; i < result.pages.length; i++) {
|
||||
if (i > 0) doc.addPage([w, h], orientation)
|
||||
|
||||
@@ -143,8 +116,7 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
||||
continue
|
||||
}
|
||||
|
||||
// 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加
|
||||
if (!serifReady) serifReady = await registerSerifFont(doc)
|
||||
// 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加(文字已转轮廓,无需内嵌字体)
|
||||
const strippedResult: LayoutResult = {
|
||||
...result,
|
||||
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
|
||||
@@ -155,7 +127,9 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
||||
let allVectorOk = true
|
||||
for (const ch of charts) {
|
||||
try {
|
||||
const el = svgElementFromString(ch.svg)
|
||||
// 字形转轮廓:把 <text> 展开为矢量 path,避免 jsPDF 无法内嵌 CFF 字体导致中文空白
|
||||
const outlinedSvg = await outlineChartSvgText(ch.svg)
|
||||
const el = svgElementFromString(outlinedSvg)
|
||||
el.style.position = 'absolute'
|
||||
el.style.left = '-99999px'
|
||||
el.style.top = '0'
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 本地打印机客户端 HTTP 客户端 —— 纯 TypeScript,零框架依赖
|
||||
*
|
||||
* 所有方法都显式接收 baseUrl(由 `@/config/printer` 解析),
|
||||
* 便于 headless / 单测直接注入,不隐式读全局配置。
|
||||
*/
|
||||
import {
|
||||
PrintClientError,
|
||||
type PrinterHealth,
|
||||
type PrinterInfo,
|
||||
type PrinterListResponse,
|
||||
type PrintJobRequest,
|
||||
type PrintJobResponse,
|
||||
} from './types'
|
||||
|
||||
/** 出厂默认地址(用户未配置时使用) */
|
||||
export const DEFAULT_PRINTER_BASE_URL = 'http://127.0.0.1:18888'
|
||||
|
||||
/** 探测类请求默认超时(ms)—— 本机服务,短超时快速失败 */
|
||||
export const PROBE_TIMEOUT_MS = 3000
|
||||
|
||||
/** 提交打印任务默认超时(ms)—— 大文档 base64 传输留足时间 */
|
||||
export const SUBMIT_TIMEOUT_MS = 30000
|
||||
|
||||
/** 去掉尾部斜杠,拼接路径 */
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, '')}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 带超时 + 错误分类的 fetch。
|
||||
* 用手工 AbortController 而非 AbortSignal.timeout,才能区分「超时」与「连不上」。
|
||||
*/
|
||||
async function request<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const ctrl = new AbortController()
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
ctrl.abort()
|
||||
}, timeoutMs)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, { ...init, signal: ctrl.signal })
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
throw new PrintClientError('timeout', `请求超时(${timeoutMs}ms):${url}`)
|
||||
}
|
||||
throw new PrintClientError('network', `无法连接打印客户端:${url}`)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new PrintClientError('http', `HTTP ${res.status} ${res.statusText}`, res.status)
|
||||
}
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch {
|
||||
throw new PrintClientError('parse', '响应不是合法 JSON')
|
||||
}
|
||||
if (json === null || typeof json !== 'object') {
|
||||
throw new PrintClientError('parse', '响应结构异常')
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
/* ------------------------------ GET /health ------------------------------ */
|
||||
|
||||
/**
|
||||
* 健康检查。成功返回服务版本、打印机数量、运行时长。
|
||||
* @throws PrintClientError 连接失败 / 超时 / ok:false
|
||||
*/
|
||||
export async function checkHealth(
|
||||
baseUrl: string = DEFAULT_PRINTER_BASE_URL,
|
||||
timeoutMs: number = PROBE_TIMEOUT_MS,
|
||||
): Promise<PrinterHealth> {
|
||||
const data = await request<Partial<PrinterHealth>>(
|
||||
joinUrl(baseUrl, '/health'),
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } },
|
||||
timeoutMs,
|
||||
)
|
||||
if (data.ok !== true) {
|
||||
throw new PrintClientError('service', '打印客户端报告服务异常(ok=false)')
|
||||
}
|
||||
return {
|
||||
app: data.app ?? 'Unknown',
|
||||
ok: true,
|
||||
printers: typeof data.printers === 'number' ? data.printers : 0,
|
||||
time: data.time ?? '',
|
||||
uptimeSec: typeof data.uptimeSec === 'number' ? data.uptimeSec : 0,
|
||||
version: data.version ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ GET /printers ------------------------------ */
|
||||
|
||||
/** 单台打印机字段归一化(服务端字段缺失时给安全默认,避免 UI 崩) */
|
||||
function normalizePrinter(raw: Partial<PrinterInfo>, index: number): PrinterInfo {
|
||||
return {
|
||||
driver: raw.driver ?? '',
|
||||
isDefault: raw.isDefault === true,
|
||||
isOnline: raw.isOnline !== false,
|
||||
kind: raw.kind === 'virtual' || raw.kind === 'ticket' ? raw.kind : 'common',
|
||||
maxDpi: typeof raw.maxDpi === 'number' ? raw.maxDpi : 0,
|
||||
name: raw.name ?? `未命名打印机 ${index + 1}`,
|
||||
status: raw.status === 'error' ? 'error' : 'idle',
|
||||
supportsColor: raw.supportsColor === true,
|
||||
supportsDuplex: raw.supportsDuplex === true,
|
||||
trays: Array.isArray(raw.trays) ? raw.trays.filter((t): t is string => typeof t === 'string') : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取打印机列表。
|
||||
* 注意:**以 `printers` 数组长度为准**,服务端的 `count` 字段仅供参考(实测可能不一致)。
|
||||
*/
|
||||
export async function listPrinters(
|
||||
baseUrl: string = DEFAULT_PRINTER_BASE_URL,
|
||||
timeoutMs: number = PROBE_TIMEOUT_MS,
|
||||
): Promise<PrinterInfo[]> {
|
||||
const data = await request<Partial<PrinterListResponse>>(
|
||||
joinUrl(baseUrl, '/printers'),
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } },
|
||||
timeoutMs,
|
||||
)
|
||||
if (data.ok === false) {
|
||||
throw new PrintClientError('service', '打印客户端未能枚举打印机')
|
||||
}
|
||||
if (!Array.isArray(data.printers)) {
|
||||
throw new PrintClientError('parse', '响应缺少 printers 数组')
|
||||
}
|
||||
return data.printers.map((p, i) => normalizePrinter(p ?? {}, i))
|
||||
}
|
||||
|
||||
/* ------------------------------ POST /print ------------------------------ */
|
||||
|
||||
/**
|
||||
* 提交打印任务。
|
||||
* 载荷约定(见《打印机数据交互文档》):
|
||||
* - 单页 → `format:'svg'`,`encoding:'utf8'`,content 为 SVG 原文
|
||||
* - 多页 → `format:'pdf'`,`encoding:'base64'`,content 为 base64(无 data: 前缀)
|
||||
*/
|
||||
export async function submitPrintJob(
|
||||
job: PrintJobRequest,
|
||||
baseUrl: string = DEFAULT_PRINTER_BASE_URL,
|
||||
timeoutMs: number = SUBMIT_TIMEOUT_MS,
|
||||
): Promise<PrintJobResponse> {
|
||||
const data = await request<Partial<PrintJobResponse>>(
|
||||
joinUrl(baseUrl, '/print'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(job),
|
||||
},
|
||||
timeoutMs,
|
||||
)
|
||||
if (data.ok !== true) {
|
||||
throw new PrintClientError('service', data.message || '打印客户端拒绝了本次任务')
|
||||
}
|
||||
return { ok: true, jobId: data.jobId, message: data.message }
|
||||
}
|
||||
|
||||
/* ------------------------------ 工具 ------------------------------ */
|
||||
|
||||
/**
|
||||
* Blob → base64(不含 `data:*;base64,` 前缀)。
|
||||
* 用于把 PDF 二进制转成可 JSON 传输的字符串。
|
||||
*/
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(new Error('读取导出文件失败'))
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '')
|
||||
const comma = result.indexOf(',')
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** Blob → utf8 文本(用于 SVG 载荷) */
|
||||
export function blobToText(blob: Blob): Promise<string> {
|
||||
return blob.text()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 本地打印机客户端服务层入口
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* import { checkHealth, listPrinters, submitPrintJob } from '@/core/print-client'
|
||||
* import { resolvePrinterBaseUrl } from '@/config/printer'
|
||||
*
|
||||
* const base = resolvePrinterBaseUrl()
|
||||
* const health = await checkHealth(base)
|
||||
* const printers = await listPrinters(base)
|
||||
* ```
|
||||
*/
|
||||
export {
|
||||
DEFAULT_PRINTER_BASE_URL,
|
||||
PROBE_TIMEOUT_MS,
|
||||
SUBMIT_TIMEOUT_MS,
|
||||
checkHealth,
|
||||
listPrinters,
|
||||
submitPrintJob,
|
||||
blobToBase64,
|
||||
blobToText,
|
||||
} from './client'
|
||||
|
||||
export { PrintClientError, describePrintError } from './types'
|
||||
|
||||
export { buildPrintPayload, formatPayloadSize } from './payload'
|
||||
export type { PrintPayload, BuildPrintPayloadOptions } from './payload'
|
||||
|
||||
export type {
|
||||
PrinterHealth,
|
||||
PrinterInfo,
|
||||
PrinterKind,
|
||||
PrinterState,
|
||||
PrinterListResponse,
|
||||
PrintPayloadFormat,
|
||||
PrintPayloadEncoding,
|
||||
PrintJobRequest,
|
||||
PrintJobResponse,
|
||||
PrintClientErrorCode,
|
||||
} from './types'
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 打印载荷构建 —— 模板 + 数据 → 推送给本地打印客户端的文档内容
|
||||
*
|
||||
* 推送格式规则(主任定,2026-08-12):
|
||||
* - **单页** → SVG(矢量原文,utf8)。体积小、矢量无损,客户端可直接送打印或转 EMF。
|
||||
* - **多页** → PDF(base64)。PDF 天然多页,base64 后可走 JSON 传输。
|
||||
*
|
||||
* 只 render 一次:先 `renderDocument()` 拿到 LayoutResult 判定页数,
|
||||
* 再按页数走对应导出器,避免 `exportDocument()` 内部二次分页。
|
||||
*/
|
||||
import type { RenderRequest } from '@/core/sdk'
|
||||
import { renderDocument } from '@/core/sdk'
|
||||
import { documentToPdf, documentToSvgString } from '@/core/export-engine'
|
||||
import type { FontFaceDef } from '@/core/export-engine'
|
||||
import { templateUsedFonts, toExportFontDefs } from '@/core/fonts/loader'
|
||||
import { blobToBase64 } from './client'
|
||||
import type { PrintPayloadEncoding, PrintPayloadFormat } from './types'
|
||||
|
||||
export interface PrintPayload {
|
||||
/** 载荷格式:单页 svg / 多页 pdf */
|
||||
format: PrintPayloadFormat
|
||||
/** 载荷编码:svg=utf8 / pdf=base64 */
|
||||
encoding: PrintPayloadEncoding
|
||||
/** 文档内容 */
|
||||
content: string
|
||||
/** 总页数 */
|
||||
pages: number
|
||||
/** 载荷字符长度(UI 展示体积用) */
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface BuildPrintPayloadOptions {
|
||||
/** PDF 位图底图倍率,默认 2(192dpi) */
|
||||
scale?: number
|
||||
/**
|
||||
* 需要内联进 SVG 的字体。不传则自动从模板里提取用到的内置字体,
|
||||
* 保证客户端渲染 SVG 时中文不掉字(SVG 是隔离上下文,看不到宿主 @font-face)。
|
||||
*/
|
||||
fonts?: FontFaceDef[]
|
||||
}
|
||||
|
||||
/** 构建推送载荷(单页 SVG / 多页 PDF-base64) */
|
||||
export async function buildPrintPayload(
|
||||
request: RenderRequest,
|
||||
options: BuildPrintPayloadOptions = {},
|
||||
): Promise<PrintPayload> {
|
||||
const result = await renderDocument(request)
|
||||
const pages = result.pages.length
|
||||
const deco = request.output?.pageDecoration
|
||||
|
||||
if (pages <= 1) {
|
||||
const fonts = options.fonts ?? toExportFontDefs(templateUsedFonts(request.template))
|
||||
const svg = await documentToSvgString(result, fonts.length ? fonts : undefined, deco)
|
||||
return { format: 'svg', encoding: 'utf8', content: svg, pages: Math.max(pages, 1), bytes: svg.length }
|
||||
}
|
||||
|
||||
const blob = await documentToPdf(result, { scale: options.scale ?? 2, pageDecoration: deco })
|
||||
const base64 = await blobToBase64(blob)
|
||||
return { format: 'pdf', encoding: 'base64', content: base64, pages, bytes: base64.length }
|
||||
}
|
||||
|
||||
/** 人类可读体积(UI 提示用) */
|
||||
export function formatPayloadSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 本地打印机客户端服务 —— 协议类型
|
||||
*
|
||||
* 对接本机常驻的打印客户端(默认 http://127.0.0.1:18888),提供:
|
||||
* GET /health 服务健康与版本
|
||||
* GET /printers 打印机列表
|
||||
* POST /print 提交打印任务(单页 SVG / 多页 PDF-base64)
|
||||
*
|
||||
* 主任铁律:无后端全链路可用 —— 客户端不可达时设计器一切功能照常,只是不能推送打印。
|
||||
*/
|
||||
|
||||
/* ------------------------------ /health ------------------------------ */
|
||||
|
||||
/** GET /health 响应体 */
|
||||
export interface PrinterHealth {
|
||||
/** 客户端应用名,如 "OpenPrint" */
|
||||
app: string
|
||||
/** 服务是否正常 */
|
||||
ok: boolean
|
||||
/** 当前可用打印机数量 */
|
||||
printers: number
|
||||
/** 服务端时间(ISO 字符串) */
|
||||
time: string
|
||||
/** 已运行秒数 */
|
||||
uptimeSec: number
|
||||
/** 客户端版本号 */
|
||||
version: string
|
||||
}
|
||||
|
||||
/* ------------------------------ /printers ------------------------------ */
|
||||
|
||||
/** 打印机类型:虚拟打印机 / 普通打印机 / 票据打印机 */
|
||||
export type PrinterKind = 'virtual' | 'common' | 'ticket'
|
||||
|
||||
/** 打印机运行状态 */
|
||||
export type PrinterState = 'idle' | 'error'
|
||||
|
||||
/** 单台打印机描述 */
|
||||
export interface PrinterInfo {
|
||||
/** 驱动名 */
|
||||
driver: string
|
||||
/** 是否系统默认打印机 */
|
||||
isDefault: boolean
|
||||
/** 是否在线 */
|
||||
isOnline: boolean
|
||||
/** 打印机类型 */
|
||||
kind: PrinterKind
|
||||
/** 最大分辨率(DPI) */
|
||||
maxDpi: number
|
||||
/** 打印机名(提交任务时的唯一标识) */
|
||||
name: string
|
||||
/** 运行状态 */
|
||||
status: PrinterState
|
||||
/** 是否支持彩色 */
|
||||
supportsColor: boolean
|
||||
/** 是否支持双面 */
|
||||
supportsDuplex: boolean
|
||||
/** 纸盒列表 */
|
||||
trays: string[]
|
||||
}
|
||||
|
||||
/** GET /printers 响应体 */
|
||||
export interface PrinterListResponse {
|
||||
/** 服务端声明的数量(可能与 printers.length 不一致,以数组为准) */
|
||||
count: number
|
||||
ok: boolean
|
||||
printers: PrinterInfo[]
|
||||
}
|
||||
|
||||
/* ------------------------------ /print ------------------------------ */
|
||||
|
||||
/** 推送载荷格式:单页 SVG(矢量文本),多页 PDF(base64) */
|
||||
export type PrintPayloadFormat = 'svg' | 'pdf'
|
||||
|
||||
/** 载荷编码:svg 用 utf8 原文,pdf 用 base64 */
|
||||
export type PrintPayloadEncoding = 'utf8' | 'base64'
|
||||
|
||||
/** POST /print 请求体 */
|
||||
export interface PrintJobRequest {
|
||||
/** 任务名(用于打印队列显示) */
|
||||
taskName: string
|
||||
/** 目标打印机名(取自 /printers 的 name;留空由客户端用系统默认) */
|
||||
printer: string
|
||||
/** 载荷格式 */
|
||||
format: PrintPayloadFormat
|
||||
/** 载荷编码 */
|
||||
encoding: PrintPayloadEncoding
|
||||
/** 文档内容:svg=XML 原文;pdf=base64(不含 data: 前缀) */
|
||||
content: string
|
||||
/** 文档总页数 */
|
||||
pages: number
|
||||
/** 打印份数 */
|
||||
copies: number
|
||||
/** 纸张方向 */
|
||||
orientation: 'portrait' | 'landscape'
|
||||
/** 是否双面 */
|
||||
duplex: boolean
|
||||
/** 是否彩色(false = 黑白) */
|
||||
color: boolean
|
||||
}
|
||||
|
||||
/** POST /print 响应体 */
|
||||
export interface PrintJobResponse {
|
||||
ok: boolean
|
||||
/** 客户端队列任务 ID */
|
||||
jobId?: string
|
||||
/** 服务端消息(失败原因等) */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/* ------------------------------ 错误 ------------------------------ */
|
||||
|
||||
/**
|
||||
* 错误分类:
|
||||
* - `timeout` 请求超时(客户端未响应)
|
||||
* - `network` 连接失败(服务未启动 / 端口不通 / CORS 拦截)
|
||||
* - `http` HTTP 非 2xx
|
||||
* - `parse` 响应不是合法 JSON 或字段缺失
|
||||
* - `service` 服务端返回 ok:false
|
||||
*/
|
||||
export type PrintClientErrorCode = 'timeout' | 'network' | 'http' | 'parse' | 'service'
|
||||
|
||||
/** 打印客户端统一错误 */
|
||||
export class PrintClientError extends Error {
|
||||
readonly code: PrintClientErrorCode
|
||||
readonly status?: number
|
||||
|
||||
constructor(code: PrintClientErrorCode, message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'PrintClientError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** 把任意异常转成人话(用于 UI 提示) */
|
||||
export function describePrintError(err: unknown): string {
|
||||
if (err instanceof PrintClientError) {
|
||||
switch (err.code) {
|
||||
case 'timeout':
|
||||
return '打印客户端响应超时,请确认服务未卡死'
|
||||
case 'network':
|
||||
return '无法连接打印客户端,请确认本机打印服务已启动'
|
||||
case 'http':
|
||||
return `打印客户端返回错误(HTTP ${err.status ?? '?'})`
|
||||
case 'parse':
|
||||
return '打印客户端返回数据格式异常'
|
||||
case 'service':
|
||||
return err.message || '打印客户端拒绝了本次请求'
|
||||
}
|
||||
}
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 打印客户端探测状态 —— 模块级单例,顶栏状态灯与打印弹窗共用一份
|
||||
*
|
||||
* 为什么做成单例:
|
||||
* - 顶栏打印按钮要常驻显示连接状态;打印弹窗打开时也要拿打印机列表。
|
||||
* 两处各自请求会重复打扰本机服务,也会出现状态不同步(顶栏红点 / 弹窗已连接)。
|
||||
* - 这里用模块级 ref 共享,一次探测两处同时更新;并发调用合并为同一个 inflight Promise。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
checkHealth,
|
||||
describePrintError,
|
||||
listPrinters,
|
||||
type PrinterHealth,
|
||||
type PrinterInfo,
|
||||
} from '@/core/print-client'
|
||||
import { resolvePrinterBaseUrl } from '@/config/printer'
|
||||
|
||||
/** idle=从未探测 / checking=探测中 / connected=已连接 / disconnected=不可达 */
|
||||
export type PrinterProbeState = 'idle' | 'checking' | 'connected' | 'disconnected'
|
||||
|
||||
const state = ref<PrinterProbeState>('idle')
|
||||
const health = ref<PrinterHealth | null>(null)
|
||||
const printers = ref<PrinterInfo[]>([])
|
||||
const errorText = ref('')
|
||||
const baseUrl = ref(resolvePrinterBaseUrl())
|
||||
const checkedAt = ref(0)
|
||||
|
||||
let inflight: Promise<boolean> | null = null
|
||||
|
||||
/** 在线且可用的打印机(离线的仍在列表里,只是不可选) */
|
||||
const onlinePrinters = computed(() => printers.value.filter((p) => p.isOnline))
|
||||
|
||||
/** 默认打印机(服务端标记 isDefault,否则取第一台在线的) */
|
||||
const defaultPrinter = computed<PrinterInfo | null>(
|
||||
() => printers.value.find((p) => p.isDefault) ?? onlinePrinters.value[0] ?? printers.value[0] ?? null,
|
||||
)
|
||||
|
||||
/** 一句话状态描述(顶栏 tooltip / 弹窗提示复用) */
|
||||
const summary = computed(() => {
|
||||
switch (state.value) {
|
||||
case 'idle':
|
||||
return '打印客户端:未检测'
|
||||
case 'checking':
|
||||
return '正在检测打印客户端…'
|
||||
case 'connected': {
|
||||
const h = health.value
|
||||
const v = h?.version ? ` v${h.version}` : ''
|
||||
return `打印客户端已连接(${h?.app ?? 'OpenPrint'}${v} · ${printers.value.length} 台打印机)`
|
||||
}
|
||||
case 'disconnected':
|
||||
return `打印客户端不可达:${errorText.value || '未知原因'}`
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 执行一次探测:/health 判活 → /printers 取列表。
|
||||
* 并发调用共享同一次请求;返回是否连接成功。
|
||||
*/
|
||||
async function probe(): Promise<boolean> {
|
||||
if (inflight) return inflight
|
||||
state.value = 'checking'
|
||||
errorText.value = ''
|
||||
const base = resolvePrinterBaseUrl()
|
||||
baseUrl.value = base
|
||||
|
||||
inflight = (async () => {
|
||||
try {
|
||||
health.value = await checkHealth(base)
|
||||
printers.value = await listPrinters(base)
|
||||
state.value = 'connected'
|
||||
return true
|
||||
} catch (e) {
|
||||
health.value = null
|
||||
printers.value = []
|
||||
errorText.value = describePrintError(e)
|
||||
state.value = 'disconnected'
|
||||
return false
|
||||
} finally {
|
||||
checkedAt.value = Date.now()
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
/** 距上次探测超过 ttl(默认 15s)才重新探测,避免频繁打扰本机服务 */
|
||||
async function probeIfStale(ttlMs = 15000): Promise<boolean> {
|
||||
if (state.value === 'connected' && Date.now() - checkedAt.value < ttlMs) return true
|
||||
if (state.value === 'checking' && inflight) return inflight
|
||||
return probe()
|
||||
}
|
||||
|
||||
export function usePrinterProbe() {
|
||||
return {
|
||||
state,
|
||||
health,
|
||||
printers,
|
||||
onlinePrinters,
|
||||
defaultPrinter,
|
||||
errorText,
|
||||
baseUrl,
|
||||
checkedAt,
|
||||
summary,
|
||||
probe,
|
||||
probeIfStale,
|
||||
}
|
||||
}
|
||||
+272
-101
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PrintDialog —— 打印配置弹窗
|
||||
* 功能:本机客户端 / 云打印切换、打印机下拉选择、打印任务名(随机/文件名,不可手填)、
|
||||
* 打印份数、方向、单面/双面(默认单面)、颜色(默认黑白)、打印机连接检测。
|
||||
*
|
||||
* 主任铁律:无后端全链路可用——打印机探测走 navigator API(本机)或 fetch(云打印),
|
||||
* 未配置后端时全部本地操作。
|
||||
* 本机客户端模式对接本地打印服务(默认 http://127.0.0.1:18888,地址可在设置里覆盖):
|
||||
* GET /health 判活 + 版本
|
||||
* GET /printers 打印机列表(能力/状态/纸盒)
|
||||
* POST /print 推送任务
|
||||
*
|
||||
* 推送格式(主任定):**单页 → SVG 原文;多页 → PDF 转 base64**。
|
||||
* 载荷由 `buildPrintPayload()` 统一构建,与预览/导出共用同一条 render 链路,保证三者一致。
|
||||
*
|
||||
* 主任铁律:无后端全链路可用 —— 客户端不可达时只是不能出纸,设计器功能不受影响。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NInputNumber,
|
||||
@@ -15,7 +20,6 @@ import {
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NTag,
|
||||
NText,
|
||||
@@ -23,29 +27,72 @@ import {
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useDesignerStore } from '@/design/stores/designer'
|
||||
import { useDataSourceStore } from '@/design/stores/dataSource'
|
||||
import { buildPreviewData } from '@/design/preview/preview-data'
|
||||
import { usePrinterProbe } from '@/design/composables/usePrinterProbe'
|
||||
import {
|
||||
buildPrintPayload,
|
||||
describePrintError,
|
||||
formatPayloadSize,
|
||||
submitPrintJob,
|
||||
type PrinterInfo,
|
||||
} from '@/core/print-client'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>()
|
||||
|
||||
const store = useDesignerStore()
|
||||
const dsStore = useDataSourceStore()
|
||||
const message = useMessage()
|
||||
|
||||
/* ------------------------------ 打印模式 ------------------------------ */
|
||||
type PrintTarget = 'local' | 'cloud'
|
||||
const target = ref<PrintTarget>('local')
|
||||
|
||||
/* ------------------------------ 打印机连接状态 ------------------------------ */
|
||||
/* ------------------------------ 本机客户端探测(共享单例) ------------------------------ */
|
||||
const {
|
||||
state: probeState,
|
||||
health: probeHealth,
|
||||
printers: localPrinters,
|
||||
defaultPrinter,
|
||||
errorText: probeError,
|
||||
baseUrl: printerBase,
|
||||
probe: runProbe,
|
||||
} = usePrinterProbe()
|
||||
|
||||
/* ------------------------------ 云打印探测 ------------------------------ */
|
||||
type PrinterStatus = 'checking' | 'connected' | 'disconnected'
|
||||
const printerStatus = ref<PrinterStatus>('checking')
|
||||
const cloudStatus = ref<PrinterStatus>('checking')
|
||||
const cloudPrinters = ref<Array<{ label: string; value: string }>>([])
|
||||
const cloudError = ref('')
|
||||
|
||||
/* ------------------------------ 统一状态 ------------------------------ */
|
||||
const printerStatus = computed<PrinterStatus>(() => {
|
||||
if (target.value === 'cloud') return cloudStatus.value
|
||||
switch (probeState.value) {
|
||||
case 'connected':
|
||||
return localPrinters.value.length > 0 ? 'connected' : 'disconnected'
|
||||
case 'disconnected':
|
||||
return 'disconnected'
|
||||
default:
|
||||
return 'checking'
|
||||
}
|
||||
})
|
||||
|
||||
const isConnected = computed(() => printerStatus.value === 'connected')
|
||||
/** 未连接时禁用下方所有配置选项(参考可看但不可选) */
|
||||
const formDisabled = computed(() => !isConnected.value)
|
||||
|
||||
const statusTagText = computed(() => {
|
||||
switch (printerStatus.value) {
|
||||
case 'checking':
|
||||
return '检测中…'
|
||||
case 'connected':
|
||||
return '已连接打印机'
|
||||
return target.value === 'local'
|
||||
? `已连接 · ${localPrinters.value.length} 台打印机`
|
||||
: '已连接云打印服务'
|
||||
case 'disconnected':
|
||||
return target.value === 'local' ? '未连接打印机' : '无法连接云打印服务'
|
||||
return target.value === 'local' ? '客户端不可达' : '无法连接云打印服务'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,27 +107,58 @@ const statusTagType = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isConnected = computed(() => printerStatus.value === 'connected')
|
||||
/** 未连接时禁用下方所有配置选项(参考可看但不可选) */
|
||||
const formDisabled = computed(() => !isConnected.value)
|
||||
/** 未连接时的详细原因 */
|
||||
const disconnectedTip = computed(() => {
|
||||
if (target.value === 'cloud') {
|
||||
return cloudError.value || '无法连接云打印服务。请检查服务地址和端口配置(设置 → 远程云打印)。'
|
||||
}
|
||||
if (probeState.value === 'connected' && localPrinters.value.length === 0) {
|
||||
return `已连上客户端(${printerBase.value}),但未枚举到任何打印机,请检查系统打印机安装情况。`
|
||||
}
|
||||
return `${probeError.value || '无法连接本机打印客户端'}。当前地址 ${printerBase.value},可在「设置 → 本地打印」修改 IP / 端口。`
|
||||
})
|
||||
|
||||
/* ------------------------------ 打印机列表 ------------------------------ */
|
||||
interface PrinterOption {
|
||||
label: string
|
||||
value: string
|
||||
const KIND_LABEL: Record<PrinterInfo['kind'], string> = {
|
||||
virtual: '虚拟',
|
||||
common: '普通',
|
||||
ticket: '票据',
|
||||
}
|
||||
const printerOptions = ref<PrinterOption[]>([])
|
||||
|
||||
const selectedPrinter = ref<string>('')
|
||||
|
||||
const printerOptions = computed(() => {
|
||||
if (target.value === 'cloud') return cloudPrinters.value
|
||||
return localPrinters.value.map((p) => ({
|
||||
label: `${p.name}${p.isDefault ? ' · 默认' : ''}${p.isOnline ? '' : '(离线)'}`,
|
||||
value: p.name,
|
||||
disabled: !p.isOnline,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 当前选中的本机打印机详情 */
|
||||
const currentPrinter = computed<PrinterInfo | null>(() => {
|
||||
if (target.value !== 'local') return null
|
||||
return localPrinters.value.find((p) => p.name === selectedPrinter.value) ?? null
|
||||
})
|
||||
|
||||
/** 选中打印机后自动收敛不支持的能力(不支持双面 → 强制单面) */
|
||||
watch(currentPrinter, (p) => {
|
||||
if (!p) return
|
||||
if (!p.supportsDuplex) duplex.value = 'single'
|
||||
if (!p.supportsColor) color.value = 'grayscale'
|
||||
})
|
||||
|
||||
/* ------------------------------ 打印任务名 ------------------------------ */
|
||||
type NamingMode = 'filename' | 'random'
|
||||
const namingMode = ref<NamingMode>('filename')
|
||||
const randomSeed = ref(0)
|
||||
|
||||
const taskName = computed(() => {
|
||||
if (namingMode.value === 'filename') {
|
||||
return store.templateName || '未命名模板'
|
||||
}
|
||||
// 随机命名:时间戳 + 4 位随机串
|
||||
void randomSeed.value // 依赖种子:每次打开弹窗换一个新随机名
|
||||
const ts = Date.now().toString(36).slice(-6)
|
||||
const rand = Math.random().toString(36).slice(2, 6)
|
||||
return `Print-${ts}-${rand}`
|
||||
@@ -95,72 +173,45 @@ const duplex = ref<Duplex>('single')
|
||||
type ColorMode = 'color' | 'grayscale'
|
||||
const color = ref<ColorMode>('grayscale')
|
||||
|
||||
/* ------------------------------ 打印机探测 ------------------------------ */
|
||||
/* ------------------------------ 探测 ------------------------------ */
|
||||
|
||||
/**
|
||||
* 本机打印机探测:使用 Web USB / Web Print API(实验性),失败回退到 navigator 检测。
|
||||
* 当前浏览器暂无标准打印 API,实际使用客户端程序或浏览器打印对话框。
|
||||
*/
|
||||
/** 本机:走共享探测单例 */
|
||||
async function detectLocalPrinters(): Promise<void> {
|
||||
printerStatus.value = 'checking'
|
||||
printerOptions.value = []
|
||||
|
||||
// 尝试获取系统打印机列表(Web Print API,Chrome 实验性功能)
|
||||
const nav = navigator as Navigator & {
|
||||
getPrinters?: () => Promise<Array<{ id: string; name: string }>>
|
||||
}
|
||||
|
||||
try {
|
||||
if (nav.getPrinters) {
|
||||
const printers = await nav.getPrinters()
|
||||
if (printers.length > 0) {
|
||||
printerOptions.value = printers.map((p) => ({ label: p.name, value: p.id || p.name }))
|
||||
selectedPrinter.value = printerOptions.value[0]?.value || ''
|
||||
printerStatus.value = 'connected'
|
||||
return
|
||||
}
|
||||
}
|
||||
// 无标准 API 或列表为空 → 用系统默认打印机(浏览器打印对话框模式)
|
||||
// 这种情况视为「可用」,用户将使用浏览器原生打印对话框
|
||||
printerOptions.value = [{ label: '系统默认打印机', value: 'default' }]
|
||||
selectedPrinter.value = 'default'
|
||||
printerStatus.value = 'connected'
|
||||
} catch {
|
||||
printerStatus.value = 'disconnected'
|
||||
await runProbe()
|
||||
const def = defaultPrinter.value
|
||||
if (def && !localPrinters.value.some((p) => p.name === selectedPrinter.value)) {
|
||||
selectedPrinter.value = def.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 云打印探测:从 print-settings 读配置,尝试连接远程打印服务获取打印机列表。
|
||||
*/
|
||||
/** 云打印探测:从 print-settings 读配置,尝试连接远程打印服务获取打印机列表。 */
|
||||
async function detectCloudPrinters(): Promise<void> {
|
||||
printerStatus.value = 'checking'
|
||||
printerOptions.value = []
|
||||
cloudStatus.value = 'checking'
|
||||
cloudPrinters.value = []
|
||||
cloudError.value = ''
|
||||
|
||||
// 读取云打印配置
|
||||
const settings = readCloudSettings()
|
||||
if (!settings.host) {
|
||||
printerStatus.value = 'disconnected'
|
||||
cloudStatus.value = 'disconnected'
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${settings.host.replace(/\/+$/, '')}:${settings.port}/api/printers`
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(5000) })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as Array<{ id: string; name: string }>
|
||||
if (data && data.length > 0) {
|
||||
printerOptions.value = data.map((p) => ({ label: p.name, value: p.id }))
|
||||
selectedPrinter.value = printerOptions.value[0]?.value || ''
|
||||
printerStatus.value = 'connected'
|
||||
cloudPrinters.value = data.map((p) => ({ label: p.name, value: p.id }))
|
||||
selectedPrinter.value = cloudPrinters.value[0]?.value || ''
|
||||
cloudStatus.value = 'connected'
|
||||
} else {
|
||||
printerStatus.value = 'disconnected'
|
||||
cloudError.value = '云打印服务未返回任何打印机'
|
||||
cloudStatus.value = 'disconnected'
|
||||
}
|
||||
} catch {
|
||||
printerStatus.value = 'disconnected'
|
||||
} catch (e) {
|
||||
cloudError.value = `无法连接 ${url}(${e instanceof Error ? e.message : String(e)})`
|
||||
cloudStatus.value = 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,51 +239,92 @@ function resetDefaults(): void {
|
||||
duplex.value = 'single'
|
||||
color.value = 'grayscale'
|
||||
namingMode.value = 'filename'
|
||||
randomSeed.value += 1
|
||||
}
|
||||
|
||||
/* ------------------------------ 生命周期 ------------------------------ */
|
||||
|
||||
/** 切换打印目标时重新探测 */
|
||||
watch(target, (val) => {
|
||||
if (val === 'local') {
|
||||
void detectLocalPrinters()
|
||||
} else {
|
||||
void detectCloudPrinters()
|
||||
}
|
||||
if (val === 'local') void detectLocalPrinters()
|
||||
else void detectCloudPrinters()
|
||||
})
|
||||
|
||||
/** 打开弹窗时首次探测 */
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
if (!open) return
|
||||
resetDefaults()
|
||||
if (target.value === 'local') {
|
||||
void detectLocalPrinters()
|
||||
} else {
|
||||
void detectCloudPrinters()
|
||||
}
|
||||
}
|
||||
if (target.value === 'local') void detectLocalPrinters()
|
||||
else void detectCloudPrinters()
|
||||
},
|
||||
)
|
||||
|
||||
/** 刷新打印机列表 */
|
||||
function refreshPrinters(): void {
|
||||
if (target.value === 'local') {
|
||||
void detectLocalPrinters()
|
||||
} else {
|
||||
void detectCloudPrinters()
|
||||
}
|
||||
if (target.value === 'local') void detectLocalPrinters()
|
||||
else void detectCloudPrinters()
|
||||
}
|
||||
|
||||
/** 执行打印 */
|
||||
/* ------------------------------ 推送打印 ------------------------------ */
|
||||
|
||||
const printing = ref(false)
|
||||
const printingHint = ref('')
|
||||
|
||||
async function doPrint(): Promise<void> {
|
||||
if (!isConnected.value) {
|
||||
message.warning('打印机未连接,无法打印')
|
||||
return
|
||||
}
|
||||
message.success(`打印任务已提交:${taskName.value}(${copies.value} 份)`)
|
||||
if (target.value === 'cloud') {
|
||||
message.info('云打印推送尚未接入,请切换到「本机客户端」')
|
||||
return
|
||||
}
|
||||
if (printing.value) return
|
||||
|
||||
printing.value = true
|
||||
try {
|
||||
printingHint.value = '正在排版渲染…'
|
||||
const template = store.buildTemplate()
|
||||
const data = buildPreviewData(dsStore.activeFields)
|
||||
const payload = await buildPrintPayload({
|
||||
template,
|
||||
data,
|
||||
output: {
|
||||
pageDecoration: {
|
||||
backgroundColor: store.pageSetup.backgroundColor ?? '#ffffff',
|
||||
watermark: store.pageSetup.watermark,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
printingHint.value = `正在推送 ${payload.format.toUpperCase()}(${payload.pages} 页 · ${formatPayloadSize(payload.bytes)})…`
|
||||
const res = await submitPrintJob(
|
||||
{
|
||||
taskName: taskName.value,
|
||||
printer: selectedPrinter.value,
|
||||
format: payload.format,
|
||||
encoding: payload.encoding,
|
||||
content: payload.content,
|
||||
pages: payload.pages,
|
||||
copies: copies.value,
|
||||
orientation: orientation.value,
|
||||
duplex: duplex.value === 'double',
|
||||
color: color.value === 'color',
|
||||
},
|
||||
printerBase.value,
|
||||
)
|
||||
|
||||
const job = res.jobId ? ` · 任务号 ${res.jobId}` : ''
|
||||
message.success(
|
||||
`已推送到「${selectedPrinter.value}」:${payload.pages} 页 · ${payload.format.toUpperCase()} · ${formatPayloadSize(payload.bytes)} · ${copies.value} 份${job}`,
|
||||
)
|
||||
emit('update:show', false)
|
||||
} catch (e) {
|
||||
message.error(`推送失败:${describePrintError(e)}`)
|
||||
} finally {
|
||||
printing.value = false
|
||||
printingHint.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
@@ -245,7 +337,7 @@ function close(): void {
|
||||
:show="props.show"
|
||||
preset="card"
|
||||
title="打印"
|
||||
style="width: 520px; max-width: 94vw"
|
||||
style="width: 560px; max-width: 94vw"
|
||||
:mask-closable="false"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
@@ -272,21 +364,24 @@ function close(): void {
|
||||
<div class="i-carbon-renew text-14px" />
|
||||
</NButton>
|
||||
</template>
|
||||
刷新打印机列表
|
||||
重新检测打印机
|
||||
</NTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务地址(本机模式) -->
|
||||
<div v-if="target === 'local'" class="print-base-line">
|
||||
<div class="i-carbon-plug text-13px" />
|
||||
<span>服务地址 {{ printerBase }}</span>
|
||||
<span v-if="probeHealth" class="op-70">
|
||||
· {{ probeHealth.app }} v{{ probeHealth.version }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 未连接提示 -->
|
||||
<div v-if="!isConnected && printerStatus !== 'checking'" class="print-disconnected-tip">
|
||||
<div class="i-carbon-warning text-16px" style="color: var(--brand-danger, #ef4444)" />
|
||||
<NText depth="3" class="text-12px">
|
||||
{{
|
||||
target === 'local'
|
||||
? '未检测到已连接的打印机。请检查打印机驱动和数据线连接后点击刷新。'
|
||||
: '无法连接云打印服务。请检查服务地址和端口配置(设置 → 远程云打印)。'
|
||||
}}
|
||||
</NText>
|
||||
<NText depth="3" class="text-12px">{{ disconnectedTip }}</NText>
|
||||
</div>
|
||||
|
||||
<!-- 打印配置区域 -->
|
||||
@@ -294,6 +389,7 @@ function close(): void {
|
||||
<!-- 打印机选择 -->
|
||||
<div class="print-field">
|
||||
<div class="print-field-label">打印机</div>
|
||||
<div class="flex-1">
|
||||
<NSelect
|
||||
v-model:value="selectedPrinter"
|
||||
:options="printerOptions"
|
||||
@@ -302,6 +398,27 @@ function close(): void {
|
||||
:placeholder="isConnected ? '选择打印机' : '未连接'"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<!-- 打印机能力详情 -->
|
||||
<div v-if="currentPrinter" class="printer-meta">
|
||||
<NTag size="tiny" round :type="currentPrinter.status === 'idle' ? 'success' : 'error'">
|
||||
{{ currentPrinter.status === 'idle' ? '空闲' : '异常' }}
|
||||
</NTag>
|
||||
<NTag size="tiny" round>{{ KIND_LABEL[currentPrinter.kind] }}</NTag>
|
||||
<NTag size="tiny" round>{{ currentPrinter.maxDpi }} DPI</NTag>
|
||||
<NTag size="tiny" round :type="currentPrinter.supportsColor ? 'info' : 'default'">
|
||||
{{ currentPrinter.supportsColor ? '支持彩色' : '仅黑白' }}
|
||||
</NTag>
|
||||
<NTag size="tiny" round :type="currentPrinter.supportsDuplex ? 'info' : 'default'">
|
||||
{{ currentPrinter.supportsDuplex ? '支持双面' : '仅单面' }}
|
||||
</NTag>
|
||||
<NTag v-if="currentPrinter.trays.length" size="tiny" round>
|
||||
纸盒:{{ currentPrinter.trays.join(' / ') }}
|
||||
</NTag>
|
||||
</div>
|
||||
<div v-if="currentPrinter?.driver" class="printer-driver">
|
||||
驱动:{{ currentPrinter.driver }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打印任务名 -->
|
||||
@@ -348,7 +465,11 @@ function close(): void {
|
||||
<!-- 单面/双面 -->
|
||||
<div class="print-field">
|
||||
<div class="print-field-label">双面打印</div>
|
||||
<NRadioGroup v-model:value="duplex" size="small" :disabled="formDisabled">
|
||||
<NRadioGroup
|
||||
v-model:value="duplex"
|
||||
size="small"
|
||||
:disabled="formDisabled || currentPrinter?.supportsDuplex === false"
|
||||
>
|
||||
<NRadioButton value="single">单面</NRadioButton>
|
||||
<NRadioButton value="double">双面</NRadioButton>
|
||||
</NRadioGroup>
|
||||
@@ -357,9 +478,13 @@ function close(): void {
|
||||
<!-- 颜色 -->
|
||||
<div class="print-field">
|
||||
<div class="print-field-label">颜色</div>
|
||||
<NRadioGroup v-model:value="color" size="small" :disabled="formDisabled">
|
||||
<NRadioGroup
|
||||
v-model:value="color"
|
||||
size="small"
|
||||
:disabled="formDisabled || currentPrinter?.supportsColor === false"
|
||||
>
|
||||
<NRadioButton value="grayscale">
|
||||
<div class="i-carbon-circle-half mr-1 inline-block text-14px align-middle" />
|
||||
<div class="i-carbon-contrast mr-1 inline-block text-14px align-middle" />
|
||||
黑白
|
||||
</NRadioButton>
|
||||
<NRadioButton value="color">
|
||||
@@ -370,13 +495,22 @@ function close(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推送格式说明 -->
|
||||
<div class="print-format-note">
|
||||
<div class="i-carbon-information text-13px" />
|
||||
<span>推送规则:单页文档以 <b>SVG</b> 矢量原文推送,多页文档转 <b>PDF(base64)</b> 推送。</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<NText depth="3" class="text-12px">{{ printingHint }}</NText>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton size="small" @click="close">取消</NButton>
|
||||
<NButton size="small" :disabled="printing" @click="close">取消</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!isConnected"
|
||||
:loading="printing"
|
||||
:disabled="!isConnected || printing"
|
||||
@click="doPrint"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -385,6 +519,7 @@ function close(): void {
|
||||
打印
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -394,13 +529,23 @@ function close(): void {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.print-target-group :deep(.n-radio-button) {
|
||||
--n-button-color: var(--brand-surface);
|
||||
}
|
||||
|
||||
.print-base-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--brand-text-3, var(--brand-text-2));
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.print-disconnected-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -437,6 +582,20 @@ function close(): void {
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.printer-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.printer-driver {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--brand-text-3, var(--brand-text-2));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.print-name-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -456,4 +615,16 @@ function close(): void {
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.print-format-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--brand-surface);
|
||||
font-size: 12px;
|
||||
color: var(--brand-text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SettingsModal —— 全局设置弹窗(naive-ui Modal + 左侧功能栏 + 右侧配置区)
|
||||
* 目前两个配置页:本地打印 / 远程云打印(配置存 localStorage,见 config/print-settings.ts)。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NInput,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
NModal,
|
||||
NSelect,
|
||||
NSwitch,
|
||||
NTag,
|
||||
NText,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
@@ -20,6 +21,13 @@ import {
|
||||
writePrintSettings,
|
||||
type PrintSettings,
|
||||
} from '@/config/print-settings'
|
||||
import {
|
||||
buildPrinterBase,
|
||||
envPrinterBase,
|
||||
FACTORY_PRINTER_BASE_URL,
|
||||
hasEnvPrinterBase,
|
||||
} from '@/config/printer'
|
||||
import { checkHealth, listPrinters, describePrintError } from '@/core/print-client'
|
||||
import {
|
||||
readAiSettings,
|
||||
writeAiSettings,
|
||||
@@ -101,6 +109,52 @@ async function testRemote(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- 本地打印客户端连接测试 --------------------------- */
|
||||
|
||||
/** 当前设置面板里填出来的基地址(实时预览,未保存也能看到) */
|
||||
const localBase = computed(() =>
|
||||
buildPrinterBase(settings.value.local.silent.host, settings.value.local.silent.port),
|
||||
)
|
||||
|
||||
const testingLocal = ref(false)
|
||||
type LocalTest = { ok: boolean; text: string }
|
||||
const localTestResult = ref<LocalTest | null>(null)
|
||||
|
||||
/** 自测本地打印客户端:/health 拿版本 → /printers 拿数量 */
|
||||
async function testLocalClient(): Promise<void> {
|
||||
const base = localBase.value || FACTORY_PRINTER_BASE_URL
|
||||
testingLocal.value = true
|
||||
localTestResult.value = null
|
||||
try {
|
||||
const health = await checkHealth(base)
|
||||
let count = health.printers
|
||||
try {
|
||||
count = (await listPrinters(base)).length
|
||||
} catch {
|
||||
/* 打印机枚举失败不影响健康判定,沿用 health.printers */
|
||||
}
|
||||
localTestResult.value = {
|
||||
ok: true,
|
||||
text: `连接正常 · ${health.app} v${health.version} · ${count} 台打印机`,
|
||||
}
|
||||
message.success(`打印客户端已连接(${count} 台打印机)`)
|
||||
} catch (e) {
|
||||
const reason = describePrintError(e)
|
||||
localTestResult.value = { ok: false, text: `${reason}(${base})` }
|
||||
message.warning(reason)
|
||||
} finally {
|
||||
testingLocal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复出厂地址 127.0.0.1:18888 */
|
||||
function resetLocalEndpoint(): void {
|
||||
settings.value.local.silent.host = '127.0.0.1'
|
||||
settings.value.local.silent.port = 18888
|
||||
localTestResult.value = null
|
||||
message.success('已恢复出厂地址 127.0.0.1:18888')
|
||||
}
|
||||
|
||||
/** 客户端下载占位:Windows 静默打印客户端发布后在此挂真实下载链接 */
|
||||
function onDownloadClient(): void {
|
||||
message.info('Windows 静默打印客户端即将发布,敬请期待')
|
||||
@@ -180,14 +234,20 @@ function copyToClipboard(value: string, label: string): void {
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<!-- 客户端静默打印:连接参数 + 客户端下载 -->
|
||||
<template v-if="settings.local.method === 'silent'">
|
||||
<!-- 打印客户端服务地址(/health · /printers · /print 共用) -->
|
||||
<div class="rounded-8px border border-brand-border bg-brand-surface p-3">
|
||||
<div class="config-title mb-2">客户端连接(静默打印)</div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="config-title" style="margin-bottom: 0">打印客户端服务地址</div>
|
||||
<NButton text size="tiny" @click="resetLocalEndpoint">恢复出厂 18888</NButton>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="config-label">客户端 IP 地址</div>
|
||||
<NInput v-model:value="settings.local.silent.host" size="small" placeholder="127.0.0.1" />
|
||||
<NInput
|
||||
v-model:value="settings.local.silent.host"
|
||||
size="small"
|
||||
placeholder="127.0.0.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-label">端口</div>
|
||||
@@ -196,16 +256,39 @@ function copyToClipboard(value: string, label: string): void {
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
placeholder="18888"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<NButton size="small" secondary :loading="testingLocal" @click="testLocalClient">
|
||||
<template #icon>
|
||||
<div class="i-carbon-plug text-14px" />
|
||||
</template>
|
||||
测试连接
|
||||
</NButton>
|
||||
<NTag v-if="localTestResult" size="small" round :type="localTestResult.ok ? 'success' : 'error'">
|
||||
{{ localTestResult.text }}
|
||||
</NTag>
|
||||
</div>
|
||||
|
||||
<NText depth="3" class="block text-12px mt-2">
|
||||
当前生效:<b>{{ localBase || FACTORY_PRINTER_BASE_URL }}</b>
|
||||
—— 打印机探测(/health、/printers)与任务推送(/print)都走这个地址。
|
||||
</NText>
|
||||
<NText depth="3" class="block text-12px mt-1">
|
||||
需在打印终端安装 Windows 静默打印客户端,浏览器通过该地址把打印任务发给客户端。
|
||||
出厂默认 {{ FACTORY_PRINTER_BASE_URL }};这里填的地址优先级最高,会覆盖构建期环境变量
|
||||
<code>VITE_OPENPRINT_PRINTER_BASE</code>。
|
||||
<template v-if="hasEnvPrinterBase">
|
||||
当前环境变量为 <b>{{ envPrinterBase }}</b>(仅在此处留空时生效)。
|
||||
</template>
|
||||
</NText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- 客户端静默打印:安装包下载 -->
|
||||
<div v-if="settings.local.method === 'silent'">
|
||||
<NButton size="small" secondary @click="onDownloadClient">
|
||||
<template #icon>
|
||||
<div class="i-carbon-download text-14px" />
|
||||
@@ -214,7 +297,6 @@ function copyToClipboard(value: string, label: string): void {
|
||||
</NButton>
|
||||
<NText depth="3" class="block text-12px mt-1">Windows 客户端安装包即将提供下载。</NText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="config-label">副本数</span>
|
||||
|
||||
@@ -27,6 +27,7 @@ const keyword = ref('')
|
||||
|
||||
const CATEGORY_ICON: Record<MarketCategory, string> = {
|
||||
invoice: 'i-carbon-document',
|
||||
report: 'i-carbon-analytics',
|
||||
receipt: 'i-carbon-receipt',
|
||||
thermal: 'i-carbon-document-horizontal',
|
||||
label: 'i-carbon-tag',
|
||||
|
||||
@@ -48,7 +48,7 @@ const DEFAULT_PAGE: PageSetup = {
|
||||
}
|
||||
|
||||
/** 画布网格默认配置(运行时视图状态,辅助设计,不持久化) */
|
||||
const DEFAULT_GRID: GridConfig = { visible: false, sizeMm: 5, color: 'rgba(120,130,145,0.5)' }
|
||||
const DEFAULT_GRID: GridConfig = { visible: false, sizeMm: 5, color: '#78829180' }
|
||||
|
||||
export const useDesignerStore = defineStore('designer', () => {
|
||||
/* ------------------------------- 状态 ------------------------------- */
|
||||
|
||||
@@ -28,6 +28,7 @@ import PreviewPanel from '@/design/preview/PreviewPanel.vue'
|
||||
import ExportDialog from '@/design/toolbar/ExportDialog.vue'
|
||||
import AiAssistantPanel from '@/design/ai/AiAssistantPanel.vue'
|
||||
import { useConfirm } from '@/design/composables/useConfirm'
|
||||
import { usePrinterProbe } from '@/design/composables/usePrinterProbe'
|
||||
import { createDemoTemplate, DEMO_TEMPLATE_NAME } from '@/repository/mock/data/demo-template'
|
||||
import { exportTemplateFile, importTemplateFile } from '@/design/utils/template-file'
|
||||
import { validateTemplate } from '@/core/spec/validator'
|
||||
@@ -105,6 +106,57 @@ function onHelpKey(e: KeyboardEvent): void {
|
||||
onMounted(() => window.addEventListener('keydown', onHelpKey))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onHelpKey))
|
||||
|
||||
/* --------------------------- 打印机连接自测 --------------------------- */
|
||||
|
||||
const {
|
||||
state: printerState,
|
||||
printers: printerList,
|
||||
health: printerHealth,
|
||||
errorText: printerError,
|
||||
baseUrl: printerBase,
|
||||
probe: runPrinterProbe,
|
||||
probeIfStale: runPrinterProbeIfStale,
|
||||
} = usePrinterProbe()
|
||||
|
||||
/** 状态灯颜色:绿=已连接 / 红=不可达 / 黄=检测中 / 灰=未检测 */
|
||||
const printerDotClass = computed(() => `is-${printerState.value}`)
|
||||
|
||||
/** 悬停提示:版本 + 打印机数量 / 失败原因 */
|
||||
const printerTooltip = computed(() => {
|
||||
switch (printerState.value) {
|
||||
case 'connected':
|
||||
return `打印 · 客户端已连接(${printerHealth.value?.app ?? 'OpenPrint'} v${
|
||||
printerHealth.value?.version ?? '?'
|
||||
} · ${printerList.value.length} 台打印机)`
|
||||
case 'checking':
|
||||
return '打印 · 正在检测打印客户端…'
|
||||
case 'disconnected':
|
||||
return `打印 · 客户端不可达:${printerError.value}(${printerBase.value})`
|
||||
default:
|
||||
return `打印 · 点击自测客户端连接(${printerBase.value})`
|
||||
}
|
||||
})
|
||||
|
||||
/** 启动后静默自测一次,让状态灯有初值(失败不打扰用户) */
|
||||
onMounted(() => {
|
||||
void runPrinterProbeIfStale()
|
||||
})
|
||||
|
||||
/** 点击打印按钮:先自测连接状态,再打开打印弹窗 */
|
||||
async function onPrintClick(): Promise<void> {
|
||||
showPrint.value = true
|
||||
const ok = await runPrinterProbe()
|
||||
if (ok) {
|
||||
message.success(
|
||||
`打印客户端已连接:${printerHealth.value?.app ?? 'OpenPrint'} v${
|
||||
printerHealth.value?.version ?? '?'
|
||||
} · ${printerList.value.length} 台打印机`,
|
||||
)
|
||||
} else {
|
||||
message.warning(`打印客户端不可达:${printerError.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onTemplateName(v: string): void {
|
||||
store.templateName = v
|
||||
store.dirty = true
|
||||
@@ -425,14 +477,20 @@ const IconChevronDown: Component = () =>
|
||||
<NButton size="small" type="primary" @click="onSave">保存</NButton>
|
||||
<NButton size="small" ghost class="toolbar-ghost-btn" @click="onExport">导出</NButton>
|
||||
|
||||
<!-- 打印按钮 -->
|
||||
<!-- 打印按钮(右上角状态灯:绿=已连接 / 红=不可达 / 黄=检测中 / 灰=未检测) -->
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" class="toolbar-icon-btn" @click="showPrint = true">
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
class="toolbar-icon-btn printer-btn"
|
||||
@click="onPrintClick"
|
||||
>
|
||||
<div class="i-carbon-printer text-16px" />
|
||||
<span class="printer-dot" :class="printerDotClass" />
|
||||
</NButton>
|
||||
</template>
|
||||
打印
|
||||
{{ printerTooltip }}
|
||||
</NTooltip>
|
||||
|
||||
<!-- JSON 查看 -->
|
||||
@@ -819,4 +877,49 @@ const IconChevronDown: Component = () =>
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* ================= 打印按钮连接状态灯 ================= */
|
||||
.printer-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.printer-dot {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--brand-nav-bg, #fff);
|
||||
background: #9ca3af;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.printer-dot.is-connected {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
|
||||
.printer-dot.is-disconnected {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 4px rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.printer-dot.is-checking {
|
||||
background: #f59e0b;
|
||||
animation: printer-dot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.printer-dot.is-idle {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes printer-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MARKET_TEMPLATES } from '@/repository/mock/data/market-templates'
|
||||
import { layout } from '@/core/layout-engine/pagination-engine'
|
||||
import { renderHtml } from '@/core/renderer-html'
|
||||
import { createCjkMeasurer } from '@/core/__tests__/cjk-measurer'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { MARKET_TEMPLATES } from './market-templates'
|
||||
import { render } from '@/core/sdk'
|
||||
import type { AnyControl, TableControl, ChartControl } from '@/types/control'
|
||||
|
||||
describe('market-templates —— 模板市场预设', () => {
|
||||
it('至少提供 6 个可用模板且覆盖主要品类', () => {
|
||||
expect(MARKET_TEMPLATES.length).toBeGreaterThanOrEqual(6)
|
||||
const cats = new Set(MARKET_TEMPLATES.map((t) => t.category))
|
||||
expect(cats.has('invoice')).toBe(true)
|
||||
expect(cats.has('receipt')).toBe(true)
|
||||
expect(cats.has('thermal')).toBe(true)
|
||||
expect(cats.has('label')).toBe(true)
|
||||
function findTemplate(id: string) {
|
||||
const t = MARKET_TEMPLATES.find((t) => t.id === id)
|
||||
if (!t) throw new Error(`template ${id} not found`)
|
||||
return t
|
||||
}
|
||||
|
||||
function bodyControls(id: string): AnyControl[] {
|
||||
const tpl = findTemplate(id).build()
|
||||
const body = tpl.document.sections.find((s) => s.type === 'body')
|
||||
if (!body) throw new Error('no body section')
|
||||
return body.components
|
||||
}
|
||||
|
||||
describe('market-financial-report 模板', () => {
|
||||
it('在模板市场中存在且为 A4', () => {
|
||||
const t = findTemplate('market-financial-report')
|
||||
expect(t.category).toBe('report')
|
||||
expect(t.pageW).toBe(210)
|
||||
expect(t.pageH).toBe(297)
|
||||
})
|
||||
|
||||
it('每个模板 build() 产出的协议结构合法', () => {
|
||||
for (const tpl of MARKET_TEMPLATES) {
|
||||
const data = tpl.build()
|
||||
expect(data.version).toBe('1.0')
|
||||
expect(data.document.type).toBe('report')
|
||||
const page = data.document.page
|
||||
expect(page.width).toBeGreaterThan(0)
|
||||
expect(page.height).toBeGreaterThan(0)
|
||||
expect(page.unit).toBe('mm')
|
||||
// 页面尺寸与卡片标注一致
|
||||
expect(page.width).toBe(tpl.pageW)
|
||||
expect(page.height).toBe(tpl.pageH)
|
||||
|
||||
const sections = data.document.sections
|
||||
expect(sections.some((s) => s.type === 'body')).toBe(true)
|
||||
|
||||
// 控件 id 全局唯一 + 几何合法
|
||||
const ids = new Set<string>()
|
||||
const walk = (comps: unknown[]): void => {
|
||||
for (const c of comps as Array<{ id: string; left: number; top: number; width: number; height: number }>) {
|
||||
expect(ids.has(c.id)).toBe(false)
|
||||
ids.add(c.id)
|
||||
expect(c.left).toBeGreaterThanOrEqual(0)
|
||||
expect(c.width).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
for (const s of sections) walk(s.components as unknown[])
|
||||
}
|
||||
it('包含 4 张 KPI 指标卡 + 3 张图表 + 1 张明细表', () => {
|
||||
const c = bodyControls('market-financial-report')
|
||||
const rects = c.filter((x) => x.type === 'rect')
|
||||
const charts = c.filter((x) => x.type === 'chart') as ChartControl[]
|
||||
const tables = c.filter((x) => x.type === 'table') as TableControl[]
|
||||
// 4 张 KPI 卡各 2 个色块(底 + 顶条)= 8,外加说明框 = 9
|
||||
expect(rects.length).toBeGreaterThanOrEqual(9)
|
||||
// bar / pie / line
|
||||
expect(charts.map((ch) => ch.kind).sort()).toEqual(['bar', 'line', 'pie'])
|
||||
expect(tables.length).toBe(1)
|
||||
})
|
||||
|
||||
it('含二维码的模板经 layout → renderHtml 能产出 SVG(开箱即用)', async () => {
|
||||
const tpl = MARKET_TEMPLATES.find((t) => t.id === 'market-qr-receipt')
|
||||
expect(tpl).toBeDefined()
|
||||
// 全部含 qrcode 控件的模板都验证一遍
|
||||
const qrTemplates = MARKET_TEMPLATES.filter((t) =>
|
||||
t.build().document.sections.some((s) =>
|
||||
(s.components as Array<{ type: string }>).some((c) => c.type === 'qrcode'),
|
||||
),
|
||||
)
|
||||
expect(qrTemplates.length).toBeGreaterThanOrEqual(3)
|
||||
it('明细表内嵌数据 + 自定义合计(含衍生利润率)', () => {
|
||||
const c = bodyControls('market-financial-report')
|
||||
const table = c.find((x) => x.type === 'table') as TableControl
|
||||
expect(table.data).toBeDefined()
|
||||
expect(table.data!.length).toBe(5)
|
||||
expect(table.options?.summaryRow?.type).toBe('custom')
|
||||
// 营收合计应为 5 个分部之和
|
||||
const rows = table.data ?? []
|
||||
const sumRevenue = rows.reduce((s, r) => s + (r.revenue as number), 0)
|
||||
expect(sumRevenue).toBe(12860)
|
||||
// 净利润 KPI 与图表数据自洽:bar 营收序列合计 = 12860
|
||||
const bar = c.find((x) => x.type === 'chart' && x.kind === 'bar') as ChartControl
|
||||
expect(bar.series[0]!.data.reduce((a, b) => a + b, 0)).toBe(12860)
|
||||
})
|
||||
|
||||
for (const qrTpl of qrTemplates) {
|
||||
const result = await layout(qrTpl.build(), {}, { measurer: createCjkMeasurer() })
|
||||
const html = renderHtml(result, { screen: false })
|
||||
// qrcode 库输出 SVG 路径;html-renderer 以 op-code 包裹
|
||||
expect(html).toContain('op-code')
|
||||
expect(html).toMatch(/<svg/)
|
||||
}
|
||||
it('端到端渲染为单页,且含合计行与图表 SVG', async () => {
|
||||
const tpl = findTemplate('market-financial-report').build()
|
||||
const { html, pages, warnings } = await render({ template: tpl })
|
||||
// 单页报表 → 打印管线走 SVG 单页推送
|
||||
expect(pages).toBe(1)
|
||||
expect(html).toContain('合计')
|
||||
// 三张图表均输出 SVG
|
||||
expect((html.match(/<svg/g) ?? []).length).toBeGreaterThanOrEqual(3)
|
||||
// 不应有"数据源非数组"类告警(明细表用内嵌 data,不依赖 dataSource)
|
||||
const fatal = warnings.filter((w) => w.code === 'DATASOURCE_NOT_ARRAY')
|
||||
expect(fatal).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
import type {
|
||||
AnyControl,
|
||||
BarcodeControl,
|
||||
ChartControl,
|
||||
QrcodeControl,
|
||||
RectControl,
|
||||
TableColumn,
|
||||
TableControl,
|
||||
TableOptions,
|
||||
TextControl,
|
||||
} from '@/types/control'
|
||||
@@ -20,7 +23,7 @@ import { createDemoTemplate } from './demo-template'
|
||||
|
||||
/* ------------------------------- 类型 ------------------------------- */
|
||||
|
||||
export type MarketCategory = 'invoice' | 'receipt' | 'thermal' | 'label'
|
||||
export type MarketCategory = 'invoice' | 'report' | 'receipt' | 'thermal' | 'label'
|
||||
|
||||
export interface MarketTemplate {
|
||||
id: string
|
||||
@@ -37,6 +40,7 @@ export interface MarketTemplate {
|
||||
|
||||
export const MARKET_CATEGORY_LABEL: Record<MarketCategory, string> = {
|
||||
invoice: '单据',
|
||||
report: '报表',
|
||||
receipt: '小票',
|
||||
thermal: '热敏',
|
||||
label: '标签',
|
||||
@@ -118,6 +122,69 @@ function qrcode(id: string, left: number, top: number, width: number, height: nu
|
||||
return { id, type: 'qrcode', left, top, width, height, value, errorLevel: 'M', printable: true }
|
||||
}
|
||||
|
||||
/** 圆角色块(KPI 卡片底 / 色条 / 分区标题条) */
|
||||
function box(
|
||||
id: string,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fill: string,
|
||||
stroke?: string,
|
||||
cornerRadius = 4,
|
||||
): AnyControl {
|
||||
const rect: RectControl = {
|
||||
id,
|
||||
type: 'rect',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
cornerRadius,
|
||||
shape: 'rect',
|
||||
printable: true,
|
||||
}
|
||||
if (stroke) {
|
||||
rect.stroke = stroke
|
||||
rect.strokeWidth = 0.6
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
/** 图表控件(chartkit 原生 SVG,导出 PDF 走矢量) */
|
||||
function chart(
|
||||
id: string,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
kind: ChartControl['kind'],
|
||||
categories: string[],
|
||||
series: ChartControl['series'],
|
||||
options: ChartControl['options'] = {},
|
||||
): AnyControl {
|
||||
return {
|
||||
id,
|
||||
type: 'chart',
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
kind,
|
||||
categories,
|
||||
series,
|
||||
printable: true,
|
||||
options: {
|
||||
showLegend: true,
|
||||
showAxis: true,
|
||||
showGrid: true,
|
||||
labelAlign: 'center',
|
||||
...options,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function table(
|
||||
id: string,
|
||||
left: number,
|
||||
@@ -147,6 +214,182 @@ function table(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 精美财务分析报表(A4)。
|
||||
* 结构:页眉(公司 + 大标题 + 报告期)→ 4 张 KPI 指标卡 → 三张矢量图表
|
||||
* (月度营收柱状 / 营收构成环形 / 月度净利润折线)→ 分部经营明细表(内嵌数据自带合计)
|
||||
* → 页脚。所有数据均为静态示例,开箱即用无需绑定数据源。
|
||||
*/
|
||||
function buildFinancialReport(): TemplateData<AnyControl> {
|
||||
const w = 186
|
||||
|
||||
/* —— 页眉 —— */
|
||||
const header: AnyControl[] = [
|
||||
txt('fr-co', 0, 0, 130, 6, '深圳某某集团股份有限公司', { fontSize: 10, fontWeight: 'bold', fill: '#1677ff' }),
|
||||
txt('fr-title', 0, 7, w, 11, '2026 年上半年财务分析报表', { fontSize: 18, fontWeight: 'bold', textAlign: 'center' }),
|
||||
txt('fr-period', 0, 19, w, 5, '报告期:2026-01-01 ~ 2026-06-30 币种:人民币 / 万元', {
|
||||
fontSize: 9,
|
||||
textAlign: 'center',
|
||||
fill: '#555555',
|
||||
}),
|
||||
rule('fr-rule', 0, 22, w, '#1677ff', 1),
|
||||
]
|
||||
|
||||
/* —— 4 张 KPI 指标卡 —— */
|
||||
const cardW = 43
|
||||
const cardGap = 4
|
||||
const cardTop = 0
|
||||
const cardH = 26
|
||||
const cardX = [1, 1 + (cardW + cardGap), 1 + 2 * (cardW + cardGap), 1 + 3 * (cardW + cardGap)]
|
||||
const cardAccent = ['#1677ff', '#52c41a', '#13c2c2', '#fa8c16']
|
||||
const cardLabel = ['营业收入', '营业利润', '经营现金流', '营收同比增长']
|
||||
const cardValue = ['12,860', '4,300', '3,420', '+18.6%']
|
||||
const cardUnit = ['万元', '万元', '万元', '同比']
|
||||
const cards: AnyControl[] = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const x = cardX[i]!
|
||||
const accent = cardAccent[i]!
|
||||
const label = cardLabel[i]!
|
||||
const value = cardValue[i]!
|
||||
const unit = cardUnit[i]!
|
||||
cards.push(box(`fr-kpi-${i}-bg`, x, cardTop, cardW, cardH, '#ffffff', '#e8e8e8', 4))
|
||||
cards.push(box(`fr-kpi-${i}-bar`, x, cardTop, cardW, 5, accent, undefined, 4))
|
||||
cards.push(txt(`fr-kpi-${i}-lbl`, x + 3, cardTop + 8, cardW - 6, 5, label, {
|
||||
fontSize: 8,
|
||||
fill: '#666666',
|
||||
}))
|
||||
cards.push(txt(`fr-kpi-${i}-val`, x + 3, cardTop + 12, cardW - 6, 9, value, {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: accent,
|
||||
}))
|
||||
cards.push(txt(`fr-kpi-${i}-unit`, x + 3, cardTop + 21, cardW - 6, 4, unit, {
|
||||
fontSize: 8,
|
||||
fill: '#999999',
|
||||
}))
|
||||
}
|
||||
|
||||
/* —— 图表标题 —— */
|
||||
const chartTop = 32
|
||||
cards.push(txt('fr-chart-title', 0, chartTop - 2, w, 5, '二、经营分析', { fontSize: 11, fontWeight: 'bold', fill: '#333333' }))
|
||||
|
||||
/* —— 三张矢量图表 —— */
|
||||
const chartH = 70
|
||||
const barW = 88
|
||||
const sideW = 47
|
||||
const donutX = barW + 4
|
||||
const lineX = barW + 4 + sideW + 4
|
||||
const charts: AnyControl[] = [
|
||||
chart('fr-bar', 0, chartTop, barW, chartH, 'bar', ['1月', '2月', '3月', '4月', '5月', '6月'], [
|
||||
{ name: '营收', data: [1980, 2100, 2240, 2050, 2180, 2310], color: '#1677ff' },
|
||||
], { title: '月度营收(万元)', showLegend: false, showAxis: true, showGrid: true, valueLabel: true, palette: ['#1677ff'] }),
|
||||
chart('fr-donut', donutX, chartTop, sideW, chartH, 'pie', ['华东', '华南', '华北', '西南', '海外'], [
|
||||
{ name: '营收构成', data: [4860, 3260, 2510, 1430, 800] },
|
||||
], {
|
||||
title: '营收构成',
|
||||
donut: true,
|
||||
showLegend: true,
|
||||
showAxis: false,
|
||||
showGrid: false,
|
||||
valueLabel: false,
|
||||
palette: ['#1677ff', '#52c41a', '#13c2c2', '#fa8c16', '#9254de'],
|
||||
}),
|
||||
chart('fr-line', lineX, chartTop, sideW, chartH, 'line', ['1月', '2月', '3月', '4月', '5月', '6月'], [
|
||||
{ name: '净利润', data: [300, 330, 360, 340, 360, 490], color: '#fa8c16' },
|
||||
], {
|
||||
title: '月度净利润',
|
||||
showLegend: false,
|
||||
smooth: true,
|
||||
area: true,
|
||||
showAxis: true,
|
||||
showGrid: true,
|
||||
valueLabel: true,
|
||||
palette: ['#fa8c16'],
|
||||
}),
|
||||
]
|
||||
|
||||
/* —— 分部经营明细表(内嵌数据 + 合计) —— */
|
||||
const tableTop = chartTop + chartH + 8
|
||||
const detailRows: Array<Record<string, unknown>> = [
|
||||
{ name: '华东大区', revenue: 4860, cost: 3120, profit: 1740, margin: 0.358 },
|
||||
{ name: '华南大区', revenue: 3260, cost: 2180, profit: 1080, margin: 0.331 },
|
||||
{ name: '华北大区', revenue: 2510, cost: 1760, profit: 750, margin: 0.299 },
|
||||
{ name: '西南大区', revenue: 1430, cost: 980, profit: 450, margin: 0.315 },
|
||||
{ name: '海外事业部', revenue: 800, cost: 520, profit: 280, margin: 0.35 },
|
||||
]
|
||||
const detailTable: TableControl = {
|
||||
id: 'fr-detail',
|
||||
type: 'table',
|
||||
left: 0,
|
||||
top: tableTop,
|
||||
width: w,
|
||||
height: 56,
|
||||
printable: true,
|
||||
data: detailRows,
|
||||
columns: [
|
||||
{ title: '分部', field: 'name', width: 40, align: 'left', headerAlign: 'center' },
|
||||
{ title: '营收(万元)', field: 'revenue', width: 36, align: 'right', headerAlign: 'center', format: { kind: 'int' } },
|
||||
{ title: '成本(万元)', field: 'cost', width: 36, align: 'right', headerAlign: 'center', format: { kind: 'int' } },
|
||||
{ title: '利润(万元)', field: 'profit', width: 34, align: 'right', headerAlign: 'center', format: { kind: 'int' } },
|
||||
{ title: '利润率', field: 'margin', width: 40, align: 'right', headerAlign: 'center', format: { kind: 'percent', digits: 1 } },
|
||||
],
|
||||
options: {
|
||||
repeatHeader: true,
|
||||
pageRows: 'auto',
|
||||
rowHeightMode: 'auto',
|
||||
borders: 'three-line',
|
||||
tableStyle: 'report',
|
||||
verticalAlign: 'middle',
|
||||
summaryRow: {
|
||||
type: 'custom',
|
||||
fields: ['revenue', 'cost', 'profit', 'margin'],
|
||||
label: '合计',
|
||||
expressions: {
|
||||
revenue: 'sum.revenue',
|
||||
cost: 'sum.cost',
|
||||
profit: 'sum.profit',
|
||||
margin: 'sum.profit / sum.revenue',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/* —— 说明 —— */
|
||||
const noteTop = tableTop + 56 + 6
|
||||
const notes: AnyControl[] = [
|
||||
box('fr-note-bg', 0, noteTop, w, 22, '#fafafa', '#e8e8e8', 4),
|
||||
txt('fr-note-title', 4, noteTop + 3, w - 8, 5, '指标说明', { fontSize: 9, fontWeight: 'bold', fill: '#333333' }),
|
||||
txt(
|
||||
'fr-note-1',
|
||||
4,
|
||||
noteTop + 9,
|
||||
w - 8,
|
||||
4,
|
||||
'1)金额单位均为万元;2)营业利润 = 营收 − 成本(分部合计 4,300 万元);',
|
||||
{ fontSize: 8, fill: '#555555' },
|
||||
),
|
||||
txt(
|
||||
'fr-note-2',
|
||||
4,
|
||||
noteTop + 14,
|
||||
w - 8,
|
||||
4,
|
||||
'3)净利润 2,180 万元(归母净利润,已扣所得税及少数股东损益);4)增长率对比 2025 年同期。',
|
||||
{ fontSize: 8, fill: '#555555' },
|
||||
),
|
||||
]
|
||||
|
||||
const body: AnyControl[] = [...cards, ...charts, detailTable, ...notes]
|
||||
|
||||
/* —— 页脚 —— */
|
||||
const footer: AnyControl[] = [
|
||||
txt('fr-brand', 0, 3, 90, 6, '本报表由 OpenPrint 生成', { fontSize: 8, fill: '#888888' }),
|
||||
txt('fr-page', 96, 3, 90, 6, '第 {{page}} 页 / 共 {{pages}} 页', { fontSize: 9, textAlign: 'right', fill: '#555555' }),
|
||||
]
|
||||
|
||||
return fullPage(paper(210, 297), header, body, footer)
|
||||
}
|
||||
|
||||
/* ------------------------------ 模板定义 ------------------------------ */
|
||||
|
||||
export const MARKET_TEMPLATES: MarketTemplate[] = [
|
||||
@@ -275,6 +518,18 @@ export const MARKET_TEMPLATES: MarketTemplate[] = [
|
||||
},
|
||||
},
|
||||
|
||||
/* ============ 报表(A4 · 图表 + 内嵌数据) ============ */
|
||||
{
|
||||
id: 'market-financial-report',
|
||||
name: '精美财务分析报表',
|
||||
category: 'report',
|
||||
desc: 'A4 财务分析报表:4 张 KPI 指标卡 + 柱状/环形/折线三张矢量图表 + 分部经营明细表(内嵌数据,自带合计),开箱即用无需绑定数据源',
|
||||
sizeLabel: 'A4 · 210×297',
|
||||
pageW: 210,
|
||||
pageH: 297,
|
||||
build: buildFinancialReport,
|
||||
},
|
||||
|
||||
/* ============ 小票(58 / 80 热敏) ============ */
|
||||
{
|
||||
id: 'market-pos-58',
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
// opentype.js 自带类型声明缺失,统一声明为 any(仅导出路径在浏览器内按需动态 import,
|
||||
// 用于把图表 SVG 的 <text> 展开为矢量字形轮廓,无需类型约束)。
|
||||
declare module 'opentype.js'
|
||||
Reference in New Issue
Block a user