更新打印机连接

This commit is contained in:
海明
2026-08-12 23:49:53 +08:00
parent af98593e6b
commit 71c2bc5d40
24 changed files with 1609 additions and 272 deletions
+1 -3
View File
File diff suppressed because one or more lines are too long
Vendored
+6
View File
@@ -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

+1 -1
View File
@@ -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)
})
})
+8 -4
View File
@@ -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,
},
+93
View File
@@ -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
+5 -5
View File
@@ -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
}
}
+5 -5
View File
@@ -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
}
}
+2 -2
View File
@@ -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 CFFjsPDF
* 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)
}
+12 -38
View File
@@ -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'
+192
View File
@@ -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()
}
+41
View File
@@ -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'
+67
View File
@@ -0,0 +1,67 @@
/**
* +
*
* 2026-08-12
* - **** SVGutf8 EMF
* - **** PDFbase64PDF 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 位图底图倍率,默认 2192dpi) */
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`
}
+153
View File
@@ -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)
}
+109
View File
@@ -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,
}
}
+293 -122
View File
@@ -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 APIChrome
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) {
resetDefaults()
if (target.value === 'local') {
void detectLocalPrinters()
} else {
void detectCloudPrinters()
}
}
if (!open) return
resetDefaults()
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} 份)`)
emit('update:show', false)
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,14 +389,36 @@ function close(): void {
<!-- 打印机选择 -->
<div class="print-field">
<div class="print-field-label">打印机</div>
<NSelect
v-model:value="selectedPrinter"
:options="printerOptions"
size="small"
:disabled="formDisabled"
:placeholder="isConnected ? '选择打印机' : '未连接'"
style="width: 100%"
/>
<div class="flex-1">
<NSelect
v-model:value="selectedPrinter"
:options="printerOptions"
size="small"
:disabled="formDisabled"
: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,20 +495,30 @@ function close(): void {
</div>
</div>
<!-- 推送格式说明 -->
<div class="print-format-note">
<div class="i-carbon-information text-13px" />
<span>推送规则单页文档以 <b>SVG</b> 矢量原文推送多页文档转 <b>PDFbase64</b> 推送</span>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<NButton size="small" @click="close">取消</NButton>
<NButton
size="small"
type="primary"
:disabled="!isConnected"
@click="doPrint"
>
<template #icon>
<div class="i-carbon-printer text-14px" />
</template>
打印
</NButton>
<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" :disabled="printing" @click="close">取消</NButton>
<NButton
size="small"
type="primary"
:loading="printing"
:disabled="!isConnected || printing"
@click="doPrint"
>
<template #icon>
<div class="i-carbon-printer text-14px" />
</template>
打印
</NButton>
</div>
</div>
</template>
</NModal>
@@ -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>
+111 -29
View File
@@ -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,41 +234,69 @@ function copyToClipboard(value: string, label: string): void {
</NText>
</div>
<!-- 客户端静默打印连接参数 + 客户端下载 -->
<template v-if="settings.local.method === 'silent'">
<div class="rounded-8px border border-brand-border bg-brand-surface p-3">
<div class="config-title mb-2">客户端连接静默打印</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" />
</div>
<div>
<div class="config-label">端口</div>
<NInputNumber
v-model:value="settings.local.silent.port"
size="small"
:min="1"
:max="65535"
style="width: 100%"
/>
</div>
<!-- 打印客户端服务地址/health · /printers · /print 共用 -->
<div class="rounded-8px border border-brand-border bg-brand-surface p-3">
<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"
/>
</div>
<div>
<div class="config-label">端口</div>
<NInputNumber
v-model:value="settings.local.silent.port"
size="small"
:min="1"
:max="65535"
placeholder="18888"
style="width: 100%"
/>
</div>
<NText depth="3" class="block text-12px mt-1">
需在打印终端安装 Windows 静默打印客户端浏览器通过该地址把打印任务发给客户端
</NText>
</div>
<div>
<NButton size="small" secondary @click="onDownloadClient">
<div class="mt-2 flex items-center gap-2">
<NButton size="small" secondary :loading="testingLocal" @click="testLocalClient">
<template #icon>
<div class="i-carbon-download text-14px" />
<div class="i-carbon-plug text-14px" />
</template>
下载客户端
测试连接
</NButton>
<NText depth="3" class="block text-12px mt-1">Windows 客户端安装包即将提供下载</NText>
<NTag v-if="localTestResult" size="small" round :type="localTestResult.ok ? 'success' : 'error'">
{{ localTestResult.text }}
</NTag>
</div>
</template>
<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">
出厂默认 {{ FACTORY_PRINTER_BASE_URL }}这里填的地址优先级最高会覆盖构建期环境变量
<code>VITE_OPENPRINT_PRINTER_BASE</code>
<template v-if="hasEnvPrinterBase">
当前环境变量为 <b>{{ envPrinterBase }}</b>仅在此处留空时生效
</template>
</NText>
</div>
<!-- 客户端静默打印安装包下载 -->
<div v-if="settings.local.method === 'silent'">
<NButton size="small" secondary @click="onDownloadClient">
<template #icon>
<div class="i-carbon-download text-14px" />
</template>
下载客户端
</NButton>
<NText depth="3" class="block text-12px mt-1">Windows 客户端安装包即将提供下载</NText>
</div>
<div class="flex items-center justify-between">
<span class="config-label">副本数</span>
+1
View File
@@ -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',
+1 -1
View File
@@ -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', () => {
/* ------------------------------- 状态 ------------------------------- */
+106 -3
View File
@@ -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([])
})
})
+256 -1
View File
@@ -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',
+3
View File
@@ -0,0 +1,3 @@
// opentype.js 自带类型声明缺失,统一声明为 any(仅导出路径在浏览器内按需动态 import,
// 用于把图表 SVG 的 <text> 展开为矢量字形轮廓,无需类型约束)。
declare module 'opentype.js'