更新打印机连接
This commit is contained in:
@@ -5,6 +5,12 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OPENPRINT_API_BASE?: string
|
readonly VITE_OPENPRINT_API_BASE?: string
|
||||||
/** 可选:Bearer 鉴权 token */
|
/** 可选:Bearer 鉴权 token */
|
||||||
readonly VITE_OPENPRINT_API_TOKEN?: string
|
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 {
|
interface ImportMeta {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
@@ -42,7 +42,7 @@ describe('print-settings —— 打印设置读写', () => {
|
|||||||
const s = readPrintSettings()
|
const s = readPrintSettings()
|
||||||
expect(s.local.method).toBe('silent')
|
expect(s.local.method).toBe('silent')
|
||||||
expect(s.local.copies).toBe(DEFAULT_PRINT_SETTINGS.local.copies)
|
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)
|
expect(s.remote.enabled).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,11 +6,15 @@
|
|||||||
export interface LocalPrintConfig {
|
export interface LocalPrintConfig {
|
||||||
/** 打印方式:browser=浏览器打印对话框 / silent=客户端静默打印(需本地客户端服务) */
|
/** 打印方式:browser=浏览器打印对话框 / silent=客户端静默打印(需本地客户端服务) */
|
||||||
method: 'browser' | 'silent'
|
method: 'browser' | 'silent'
|
||||||
/** 静默打印客户端连接地址(仅 method=silent 生效) */
|
/**
|
||||||
|
* 本地打印客户端连接地址。
|
||||||
|
* 同时也是 /health、/printers、/print 三个接口的基地址来源,
|
||||||
|
* 解析优先级见 `@/config/printer` 的 resolvePrinterBaseUrl()。
|
||||||
|
*/
|
||||||
silent: {
|
silent: {
|
||||||
/** 客户端所在主机 IP(默认本机回环) */
|
/** 客户端所在主机 IP(默认本机回环 127.0.0.1) */
|
||||||
host: string
|
host: string
|
||||||
/** 客户端服务端口(默认 9122) */
|
/** 客户端服务端口(默认 18888) */
|
||||||
port: number
|
port: number
|
||||||
}
|
}
|
||||||
/** 副本数 */
|
/** 副本数 */
|
||||||
@@ -40,7 +44,7 @@ export const PRINT_SETTINGS_KEY = 'openprint:print-settings'
|
|||||||
export const DEFAULT_PRINT_SETTINGS: PrintSettings = {
|
export const DEFAULT_PRINT_SETTINGS: PrintSettings = {
|
||||||
local: {
|
local: {
|
||||||
method: 'browser',
|
method: 'browser',
|
||||||
silent: { host: '127.0.0.1', port: 9122 },
|
silent: { host: '127.0.0.1', port: 18888 },
|
||||||
copies: 1,
|
copies: 1,
|
||||||
closeAfterPrint: true,
|
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 titleH = title ? 22 : 0
|
||||||
const mTop = 10 + titleH
|
const mTop = 10 + titleH
|
||||||
const mRight = 16
|
const mRight = 16
|
||||||
const mBottom = (showAxis ? 38 : 22) + legendH
|
const mBottom = (showAxis ? 42 : 22) + legendH
|
||||||
const mLeft = showAxis ? 44 : 16
|
const mLeft = showAxis ? 44 : 16
|
||||||
|
|
||||||
const px0 = mLeft
|
const px0 = mLeft
|
||||||
@@ -64,7 +64,7 @@ export function renderBar(model: ChartModel): string {
|
|||||||
if (showGrid && i > 0) {
|
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(`<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"/>`)
|
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)
|
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"/>`)
|
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) {
|
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 轴标签:始终以类目为基准居中于对应条带正下方,并与图表留一点间距
|
// x 轴标签:始终以类目为基准居中于对应条带正下方,并与图表留一点间距
|
||||||
@@ -102,7 +102,7 @@ export function renderBar(model: ChartModel): string {
|
|||||||
}
|
}
|
||||||
const startX = legendStartX(align, W, totalW, px0)
|
const startX = legendStartX(align, W, totalW, px0)
|
||||||
let lx = startX
|
let lx = startX
|
||||||
let ly = py1 + (showAxis ? 30 : 16)
|
let ly = py1 + (showAxis ? 40 : 16)
|
||||||
for (let s = 0; s < sCount; s++) {
|
for (let s = 0; s < sCount; s++) {
|
||||||
const name = model.series[s]!.name || `系列${s + 1}`
|
const name = model.series[s]!.name || `系列${s + 1}`
|
||||||
const color = seriesColor(model.series[s]!, s, opt)
|
const color = seriesColor(model.series[s]!, s, opt)
|
||||||
@@ -112,7 +112,7 @@ export function renderBar(model: ChartModel): string {
|
|||||||
ly += 16
|
ly += 16
|
||||||
}
|
}
|
||||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
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
|
lx += itemW
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function renderLine(model: ChartModel): string {
|
|||||||
const titleH = title ? 22 : 0
|
const titleH = title ? 22 : 0
|
||||||
const mTop = 10 + titleH
|
const mTop = 10 + titleH
|
||||||
const mRight = 16
|
const mRight = 16
|
||||||
const mBottom = (showAxis ? 38 : 22) + legendH
|
const mBottom = (showAxis ? 42 : 22) + legendH
|
||||||
const mLeft = showAxis ? 44 : 16
|
const mLeft = showAxis ? 44 : 16
|
||||||
|
|
||||||
const px0 = mLeft
|
const px0 = mLeft
|
||||||
@@ -95,7 +95,7 @@ export function renderLine(model: ChartModel): string {
|
|||||||
if (showGrid && i > 0) {
|
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(`<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="${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"/>`)
|
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]!
|
const series = model.series[si]!
|
||||||
for (let c = 0; c < sp.pts.length; c++) {
|
for (let c = 0; c < sp.pts.length; c++) {
|
||||||
const v = series.data[c] ?? 0
|
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)
|
const startX = legendStartX(align, W, totalW, px0)
|
||||||
let lx = startX
|
let lx = startX
|
||||||
let ly = py1 + (showAxis ? 30 : 16)
|
let ly = py1 + (showAxis ? 40 : 16)
|
||||||
for (let s = 0; s < seriesPts.length; s++) {
|
for (let s = 0; s < seriesPts.length; s++) {
|
||||||
const name = seriesPts[s]!.name || `系列${s + 1}`
|
const name = seriesPts[s]!.name || `系列${s + 1}`
|
||||||
const color = seriesPts[s]!.color
|
const color = seriesPts[s]!.color
|
||||||
@@ -150,7 +150,7 @@ export function renderLine(model: ChartModel): string {
|
|||||||
ly += 16
|
ly += 16
|
||||||
}
|
}
|
||||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
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
|
lx += itemW
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function renderPie(model: ChartModel): string {
|
|||||||
const lr = donut ? (rOuter + rInner) / 2 : rOuter * 0.62
|
const lr = donut ? (rOuter + rInner) / 2 : rOuter * 0.62
|
||||||
const p = polarL(lr, mid)
|
const p = polarL(lr, mid)
|
||||||
const pct = ((v / total) * 100).toFixed(0) + '%'
|
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
|
ly += 16
|
||||||
}
|
}
|
||||||
parts.push(`<rect x="${lx}" y="${ly - 9}" width="11" height="11" rx="2" fill="${color}"/>`)
|
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
|
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)
|
* ## 图表走矢量(svg2pdf)
|
||||||
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
|
* 设计器一切以 SVG 为真相源。为让图表在 PDF 里达到「印刷级矢量」:
|
||||||
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
|
* 1. 每页先**剥离 chart 控件** → 栅格化(文本/表格位图底图)→ addImage 作背景;
|
||||||
* 2. 再遍历该页 chart 节点,用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
|
* 2. 再遍历该页 chart 节点,把 SVG 里的 `<text>` 用 opentype.js 展开为**矢量字形轮廓**,
|
||||||
* 3. 思源宋体 TTF 注册进 jsPDF('SourceHanSerifCN'),图表中文标签才不会变空白/方块。
|
* 再用 `svg2pdf` 把其 SVG 以**矢量**注入 jsPDF 对应 mm 盒;
|
||||||
|
* (关键:jsPDF 只能内嵌 TrueType 字体、无法内嵌 CFF/OpenType,而思源宋体恰为 CFF,
|
||||||
|
* 直接注册会静默失败导致中文标签空白——转轮廓后文字与字体完全解耦,中文必现。)
|
||||||
*
|
*
|
||||||
* 兜底:
|
* 兜底:
|
||||||
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
|
* - 图表被旋转 → 整页退回旧的全栅格化(旋转在矢量叠加里不易对齐);
|
||||||
* - 单个图表 svg2pdf 失败 → 该图表单独栅格化后叠加(不丢图);
|
* - 单个图表矢量失败/字形展开失败 → 该图表单独栅格化后叠加(不丢图);
|
||||||
* - 字体加载失败(极端环境)→ 整页退回全栅格化(仍是位图,但保证有图)。
|
* - 整页矢量叠加彻底失败 → 整页退回全栅格化(仍是位图,但保证有图)。
|
||||||
*
|
*
|
||||||
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
|
* 非图表页(无 chart 控件)仍走原全栅格化位图路径,行为与旧版一致。
|
||||||
*/
|
*/
|
||||||
@@ -20,14 +22,12 @@ import { embedFontsInSvg, type FontFaceDef } from './fonts'
|
|||||||
import type { PageDecoration } from '@/types/template'
|
import type { PageDecoration } from '@/types/template'
|
||||||
import { blobToDataURL } from './util'
|
import { blobToDataURL } from './util'
|
||||||
import { mmv } from '@/core/renderer-html/css-generator'
|
import { mmv } from '@/core/renderer-html/css-generator'
|
||||||
|
import { outlineChartSvgText } from './chart-svg-to-path'
|
||||||
|
|
||||||
/** 思源宋体 —— PDF 唯一内联字体(写死 TTF,位图 PDF 保证中文衬线效果;矢量图表也注册它) */
|
/** 思源宋体 —— 位图底图(含非图表文本)内联字体,保证中文衬线效果 */
|
||||||
const PDF_FONTS: FontFaceDef[] = [
|
const PDF_FONTS: FontFaceDef[] = [
|
||||||
{ family: '思源宋体', src: '/fonts/SourceHanSerifCN-Regular.ttf', weight: 400 },
|
{ 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 {
|
export interface PdfOptions {
|
||||||
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi,默认 2 */
|
/** 高清倍率,1=96dpi / 2=192dpi(推荐)/ 3=288dpi,默认 2 */
|
||||||
@@ -76,31 +76,6 @@ function chartsOf(page: LayoutPage): ChartBox[] {
|
|||||||
return out
|
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 失败时的兜底,保证不丢图) */
|
/** 单图表栅格化(svg2pdf 失败时的兜底,保证不丢图) */
|
||||||
async function rasterizeChart(chartSvg: string, wMm: number, hMm: number, scale: number): Promise<string> {
|
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>`
|
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 })
|
const doc = new JsPDF({ unit: 'mm', format: [w, h], orientation })
|
||||||
|
|
||||||
let serifReady = false
|
|
||||||
|
|
||||||
for (let i = 0; i < result.pages.length; i++) {
|
for (let i = 0; i < result.pages.length; i++) {
|
||||||
if (i > 0) doc.addPage([w, h], orientation)
|
if (i > 0) doc.addPage([w, h], orientation)
|
||||||
|
|
||||||
@@ -143,8 +116,7 @@ export async function documentToPdf(result: LayoutResult, opts: PdfOptions = {})
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加
|
// 矢量路径:先栅格化"剥掉图表"的底图,再把图表以矢量叠加(文字已转轮廓,无需内嵌字体)
|
||||||
if (!serifReady) serifReady = await registerSerifFont(doc)
|
|
||||||
const strippedResult: LayoutResult = {
|
const strippedResult: LayoutResult = {
|
||||||
...result,
|
...result,
|
||||||
pages: result.pages.map((p, idx) => (idx === i ? stripCharts(p) : p)),
|
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
|
let allVectorOk = true
|
||||||
for (const ch of charts) {
|
for (const ch of charts) {
|
||||||
try {
|
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.position = 'absolute'
|
||||||
el.style.left = '-99999px'
|
el.style.left = '-99999px'
|
||||||
el.style.top = '0'
|
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">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* PrintDialog —— 打印配置弹窗
|
* 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 {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NInputNumber,
|
NInputNumber,
|
||||||
@@ -15,7 +20,6 @@ import {
|
|||||||
NRadioButton,
|
NRadioButton,
|
||||||
NRadioGroup,
|
NRadioGroup,
|
||||||
NSelect,
|
NSelect,
|
||||||
NSpace,
|
|
||||||
NSpin,
|
NSpin,
|
||||||
NTag,
|
NTag,
|
||||||
NText,
|
NText,
|
||||||
@@ -23,29 +27,72 @@ import {
|
|||||||
useMessage,
|
useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { useDesignerStore } from '@/design/stores/designer'
|
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 props = defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>()
|
const emit = defineEmits<{ (e: 'update:show', v: boolean): void }>()
|
||||||
|
|
||||||
const store = useDesignerStore()
|
const store = useDesignerStore()
|
||||||
|
const dsStore = useDataSourceStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
/* ------------------------------ 打印模式 ------------------------------ */
|
/* ------------------------------ 打印模式 ------------------------------ */
|
||||||
type PrintTarget = 'local' | 'cloud'
|
type PrintTarget = 'local' | 'cloud'
|
||||||
const target = ref<PrintTarget>('local')
|
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'
|
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(() => {
|
const statusTagText = computed(() => {
|
||||||
switch (printerStatus.value) {
|
switch (printerStatus.value) {
|
||||||
case 'checking':
|
case 'checking':
|
||||||
return '检测中…'
|
return '检测中…'
|
||||||
case 'connected':
|
case 'connected':
|
||||||
return '已连接打印机'
|
return target.value === 'local'
|
||||||
|
? `已连接 · ${localPrinters.value.length} 台打印机`
|
||||||
|
: '已连接云打印服务'
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
return target.value === 'local' ? '未连接打印机' : '无法连接云打印服务'
|
return target.value === 'local' ? '客户端不可达' : '无法连接云打印服务'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,27 +107,58 @@ const statusTagType = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const isConnected = computed(() => printerStatus.value === 'connected')
|
/** 未连接时的详细原因 */
|
||||||
/** 未连接时禁用下方所有配置选项(参考可看但不可选) */
|
const disconnectedTip = computed(() => {
|
||||||
const formDisabled = computed(() => !isConnected.value)
|
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 {
|
const KIND_LABEL: Record<PrinterInfo['kind'], string> = {
|
||||||
label: string
|
virtual: '虚拟',
|
||||||
value: string
|
common: '普通',
|
||||||
|
ticket: '票据',
|
||||||
}
|
}
|
||||||
const printerOptions = ref<PrinterOption[]>([])
|
|
||||||
const selectedPrinter = ref<string>('')
|
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'
|
type NamingMode = 'filename' | 'random'
|
||||||
const namingMode = ref<NamingMode>('filename')
|
const namingMode = ref<NamingMode>('filename')
|
||||||
|
const randomSeed = ref(0)
|
||||||
|
|
||||||
const taskName = computed(() => {
|
const taskName = computed(() => {
|
||||||
if (namingMode.value === 'filename') {
|
if (namingMode.value === 'filename') {
|
||||||
return store.templateName || '未命名模板'
|
return store.templateName || '未命名模板'
|
||||||
}
|
}
|
||||||
// 随机命名:时间戳 + 4 位随机串
|
void randomSeed.value // 依赖种子:每次打开弹窗换一个新随机名
|
||||||
const ts = Date.now().toString(36).slice(-6)
|
const ts = Date.now().toString(36).slice(-6)
|
||||||
const rand = Math.random().toString(36).slice(2, 6)
|
const rand = Math.random().toString(36).slice(2, 6)
|
||||||
return `Print-${ts}-${rand}`
|
return `Print-${ts}-${rand}`
|
||||||
@@ -95,72 +173,45 @@ const duplex = ref<Duplex>('single')
|
|||||||
type ColorMode = 'color' | 'grayscale'
|
type ColorMode = 'color' | 'grayscale'
|
||||||
const color = ref<ColorMode>('grayscale')
|
const color = ref<ColorMode>('grayscale')
|
||||||
|
|
||||||
/* ------------------------------ 打印机探测 ------------------------------ */
|
/* ------------------------------ 探测 ------------------------------ */
|
||||||
|
|
||||||
/**
|
/** 本机:走共享探测单例 */
|
||||||
* 本机打印机探测:使用 Web USB / Web Print API(实验性),失败回退到 navigator 检测。
|
|
||||||
* 当前浏览器暂无标准打印 API,实际使用客户端程序或浏览器打印对话框。
|
|
||||||
*/
|
|
||||||
async function detectLocalPrinters(): Promise<void> {
|
async function detectLocalPrinters(): Promise<void> {
|
||||||
printerStatus.value = 'checking'
|
await runProbe()
|
||||||
printerOptions.value = []
|
const def = defaultPrinter.value
|
||||||
|
if (def && !localPrinters.value.some((p) => p.name === selectedPrinter.value)) {
|
||||||
// 尝试获取系统打印机列表(Web Print API,Chrome 实验性功能)
|
selectedPrinter.value = def.name
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 云打印探测:从 print-settings 读配置,尝试连接远程打印服务获取打印机列表。 */
|
||||||
* 云打印探测:从 print-settings 读配置,尝试连接远程打印服务获取打印机列表。
|
|
||||||
*/
|
|
||||||
async function detectCloudPrinters(): Promise<void> {
|
async function detectCloudPrinters(): Promise<void> {
|
||||||
printerStatus.value = 'checking'
|
cloudStatus.value = 'checking'
|
||||||
printerOptions.value = []
|
cloudPrinters.value = []
|
||||||
|
cloudError.value = ''
|
||||||
|
|
||||||
// 读取云打印配置
|
|
||||||
const settings = readCloudSettings()
|
const settings = readCloudSettings()
|
||||||
if (!settings.host) {
|
if (!settings.host) {
|
||||||
printerStatus.value = 'disconnected'
|
cloudStatus.value = 'disconnected'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${settings.host.replace(/\/+$/, '')}:${settings.port}/api/printers`
|
const url = `${settings.host.replace(/\/+$/, '')}:${settings.port}/api/printers`
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(5000) })
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const data = (await res.json()) as Array<{ id: string; name: string }>
|
const data = (await res.json()) as Array<{ id: string; name: string }>
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
printerOptions.value = data.map((p) => ({ label: p.name, value: p.id }))
|
cloudPrinters.value = data.map((p) => ({ label: p.name, value: p.id }))
|
||||||
selectedPrinter.value = printerOptions.value[0]?.value || ''
|
selectedPrinter.value = cloudPrinters.value[0]?.value || ''
|
||||||
printerStatus.value = 'connected'
|
cloudStatus.value = 'connected'
|
||||||
} else {
|
} else {
|
||||||
printerStatus.value = 'disconnected'
|
cloudError.value = '云打印服务未返回任何打印机'
|
||||||
|
cloudStatus.value = 'disconnected'
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
printerStatus.value = 'disconnected'
|
cloudError.value = `无法连接 ${url}(${e instanceof Error ? e.message : String(e)})`
|
||||||
|
cloudStatus.value = 'disconnected'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,51 +239,92 @@ function resetDefaults(): void {
|
|||||||
duplex.value = 'single'
|
duplex.value = 'single'
|
||||||
color.value = 'grayscale'
|
color.value = 'grayscale'
|
||||||
namingMode.value = 'filename'
|
namingMode.value = 'filename'
|
||||||
|
randomSeed.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------ 生命周期 ------------------------------ */
|
/* ------------------------------ 生命周期 ------------------------------ */
|
||||||
|
|
||||||
/** 切换打印目标时重新探测 */
|
|
||||||
watch(target, (val) => {
|
watch(target, (val) => {
|
||||||
if (val === 'local') {
|
if (val === 'local') void detectLocalPrinters()
|
||||||
void detectLocalPrinters()
|
else void detectCloudPrinters()
|
||||||
} else {
|
|
||||||
void detectCloudPrinters()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 打开弹窗时首次探测 */
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (open) {
|
if (!open) return
|
||||||
resetDefaults()
|
resetDefaults()
|
||||||
if (target.value === 'local') {
|
if (target.value === 'local') void detectLocalPrinters()
|
||||||
void detectLocalPrinters()
|
else void detectCloudPrinters()
|
||||||
} else {
|
|
||||||
void detectCloudPrinters()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 刷新打印机列表 */
|
/** 刷新打印机列表 */
|
||||||
function refreshPrinters(): void {
|
function refreshPrinters(): void {
|
||||||
if (target.value === 'local') {
|
if (target.value === 'local') void detectLocalPrinters()
|
||||||
void detectLocalPrinters()
|
else void detectCloudPrinters()
|
||||||
} else {
|
|
||||||
void detectCloudPrinters()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行打印 */
|
/* ------------------------------ 推送打印 ------------------------------ */
|
||||||
|
|
||||||
|
const printing = ref(false)
|
||||||
|
const printingHint = ref('')
|
||||||
|
|
||||||
async function doPrint(): Promise<void> {
|
async function doPrint(): Promise<void> {
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
message.warning('打印机未连接,无法打印')
|
message.warning('打印机未连接,无法打印')
|
||||||
return
|
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)
|
emit('update:show', false)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(`推送失败:${describePrintError(e)}`)
|
||||||
|
} finally {
|
||||||
|
printing.value = false
|
||||||
|
printingHint.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
@@ -245,7 +337,7 @@ function close(): void {
|
|||||||
:show="props.show"
|
:show="props.show"
|
||||||
preset="card"
|
preset="card"
|
||||||
title="打印"
|
title="打印"
|
||||||
style="width: 520px; max-width: 94vw"
|
style="width: 560px; max-width: 94vw"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
@update:show="emit('update:show', $event)"
|
@update:show="emit('update:show', $event)"
|
||||||
>
|
>
|
||||||
@@ -272,21 +364,24 @@ function close(): void {
|
|||||||
<div class="i-carbon-renew text-14px" />
|
<div class="i-carbon-renew text-14px" />
|
||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
刷新打印机列表
|
重新检测打印机
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
</div>
|
</div>
|
||||||
</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 v-if="!isConnected && printerStatus !== 'checking'" class="print-disconnected-tip">
|
||||||
<div class="i-carbon-warning text-16px" style="color: var(--brand-danger, #ef4444)" />
|
<div class="i-carbon-warning text-16px" style="color: var(--brand-danger, #ef4444)" />
|
||||||
<NText depth="3" class="text-12px">
|
<NText depth="3" class="text-12px">{{ disconnectedTip }}</NText>
|
||||||
{{
|
|
||||||
target === 'local'
|
|
||||||
? '未检测到已连接的打印机。请检查打印机驱动和数据线连接后点击刷新。'
|
|
||||||
: '无法连接云打印服务。请检查服务地址和端口配置(设置 → 远程云打印)。'
|
|
||||||
}}
|
|
||||||
</NText>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 打印配置区域 -->
|
<!-- 打印配置区域 -->
|
||||||
@@ -294,6 +389,7 @@ function close(): void {
|
|||||||
<!-- 打印机选择 -->
|
<!-- 打印机选择 -->
|
||||||
<div class="print-field">
|
<div class="print-field">
|
||||||
<div class="print-field-label">打印机</div>
|
<div class="print-field-label">打印机</div>
|
||||||
|
<div class="flex-1">
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="selectedPrinter"
|
v-model:value="selectedPrinter"
|
||||||
:options="printerOptions"
|
:options="printerOptions"
|
||||||
@@ -302,6 +398,27 @@ function close(): void {
|
|||||||
:placeholder="isConnected ? '选择打印机' : '未连接'"
|
:placeholder="isConnected ? '选择打印机' : '未连接'"
|
||||||
style="width: 100%"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 打印任务名 -->
|
<!-- 打印任务名 -->
|
||||||
@@ -348,7 +465,11 @@ function close(): void {
|
|||||||
<!-- 单面/双面 -->
|
<!-- 单面/双面 -->
|
||||||
<div class="print-field">
|
<div class="print-field">
|
||||||
<div class="print-field-label">双面打印</div>
|
<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="single">单面</NRadioButton>
|
||||||
<NRadioButton value="double">双面</NRadioButton>
|
<NRadioButton value="double">双面</NRadioButton>
|
||||||
</NRadioGroup>
|
</NRadioGroup>
|
||||||
@@ -357,9 +478,13 @@ function close(): void {
|
|||||||
<!-- 颜色 -->
|
<!-- 颜色 -->
|
||||||
<div class="print-field">
|
<div class="print-field">
|
||||||
<div class="print-field-label">颜色</div>
|
<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">
|
<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>
|
||||||
<NRadioButton value="color">
|
<NRadioButton value="color">
|
||||||
@@ -370,13 +495,22 @@ function close(): void {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<NText depth="3" class="text-12px">{{ printingHint }}</NText>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<NButton size="small" @click="close">取消</NButton>
|
<NButton size="small" :disabled="printing" @click="close">取消</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!isConnected"
|
:loading="printing"
|
||||||
|
:disabled="!isConnected || printing"
|
||||||
@click="doPrint"
|
@click="doPrint"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -385,6 +519,7 @@ function close(): void {
|
|||||||
打印
|
打印
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NModal>
|
</NModal>
|
||||||
</template>
|
</template>
|
||||||
@@ -394,13 +529,23 @@ function close(): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.print-target-group :deep(.n-radio-button) {
|
.print-target-group :deep(.n-radio-button) {
|
||||||
--n-button-color: var(--brand-surface);
|
--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 {
|
.print-disconnected-tip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -437,6 +582,20 @@ function close(): void {
|
|||||||
line-height: 28px;
|
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 {
|
.print-name-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -456,4 +615,16 @@ function close(): void {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 240px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* SettingsModal —— 全局设置弹窗(naive-ui Modal + 左侧功能栏 + 右侧配置区)
|
* SettingsModal —— 全局设置弹窗(naive-ui Modal + 左侧功能栏 + 右侧配置区)
|
||||||
* 目前两个配置页:本地打印 / 远程云打印(配置存 localStorage,见 config/print-settings.ts)。
|
* 目前两个配置页:本地打印 / 远程云打印(配置存 localStorage,见 config/print-settings.ts)。
|
||||||
*/
|
*/
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
NButton,
|
NButton,
|
||||||
NInput,
|
NInput,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
NModal,
|
NModal,
|
||||||
NSelect,
|
NSelect,
|
||||||
NSwitch,
|
NSwitch,
|
||||||
|
NTag,
|
||||||
NText,
|
NText,
|
||||||
useMessage,
|
useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
@@ -20,6 +21,13 @@ import {
|
|||||||
writePrintSettings,
|
writePrintSettings,
|
||||||
type PrintSettings,
|
type PrintSettings,
|
||||||
} from '@/config/print-settings'
|
} from '@/config/print-settings'
|
||||||
|
import {
|
||||||
|
buildPrinterBase,
|
||||||
|
envPrinterBase,
|
||||||
|
FACTORY_PRINTER_BASE_URL,
|
||||||
|
hasEnvPrinterBase,
|
||||||
|
} from '@/config/printer'
|
||||||
|
import { checkHealth, listPrinters, describePrintError } from '@/core/print-client'
|
||||||
import {
|
import {
|
||||||
readAiSettings,
|
readAiSettings,
|
||||||
writeAiSettings,
|
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 静默打印客户端发布后在此挂真实下载链接 */
|
/** 客户端下载占位:Windows 静默打印客户端发布后在此挂真实下载链接 */
|
||||||
function onDownloadClient(): void {
|
function onDownloadClient(): void {
|
||||||
message.info('Windows 静默打印客户端即将发布,敬请期待')
|
message.info('Windows 静默打印客户端即将发布,敬请期待')
|
||||||
@@ -180,14 +234,20 @@ function copyToClipboard(value: string, label: string): void {
|
|||||||
</NText>
|
</NText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 客户端静默打印:连接参数 + 客户端下载 -->
|
<!-- 打印客户端服务地址(/health · /printers · /print 共用) -->
|
||||||
<template v-if="settings.local.method === 'silent'">
|
|
||||||
<div class="rounded-8px border border-brand-border bg-brand-surface p-3">
|
<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 class="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="config-label">客户端 IP 地址</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>
|
<div>
|
||||||
<div class="config-label">端口</div>
|
<div class="config-label">端口</div>
|
||||||
@@ -196,16 +256,39 @@ function copyToClipboard(value: string, label: string): void {
|
|||||||
size="small"
|
size="small"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="65535"
|
:max="65535"
|
||||||
|
placeholder="18888"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
</NText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- 客户端静默打印:安装包下载 -->
|
||||||
|
<div v-if="settings.local.method === 'silent'">
|
||||||
<NButton size="small" secondary @click="onDownloadClient">
|
<NButton size="small" secondary @click="onDownloadClient">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div class="i-carbon-download text-14px" />
|
<div class="i-carbon-download text-14px" />
|
||||||
@@ -214,7 +297,6 @@ function copyToClipboard(value: string, label: string): void {
|
|||||||
</NButton>
|
</NButton>
|
||||||
<NText depth="3" class="block text-12px mt-1">Windows 客户端安装包即将提供下载。</NText>
|
<NText depth="3" class="block text-12px mt-1">Windows 客户端安装包即将提供下载。</NText>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="config-label">副本数</span>
|
<span class="config-label">副本数</span>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const keyword = ref('')
|
|||||||
|
|
||||||
const CATEGORY_ICON: Record<MarketCategory, string> = {
|
const CATEGORY_ICON: Record<MarketCategory, string> = {
|
||||||
invoice: 'i-carbon-document',
|
invoice: 'i-carbon-document',
|
||||||
|
report: 'i-carbon-analytics',
|
||||||
receipt: 'i-carbon-receipt',
|
receipt: 'i-carbon-receipt',
|
||||||
thermal: 'i-carbon-document-horizontal',
|
thermal: 'i-carbon-document-horizontal',
|
||||||
label: 'i-carbon-tag',
|
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', () => {
|
export const useDesignerStore = defineStore('designer', () => {
|
||||||
/* ------------------------------- 状态 ------------------------------- */
|
/* ------------------------------- 状态 ------------------------------- */
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import PreviewPanel from '@/design/preview/PreviewPanel.vue'
|
|||||||
import ExportDialog from '@/design/toolbar/ExportDialog.vue'
|
import ExportDialog from '@/design/toolbar/ExportDialog.vue'
|
||||||
import AiAssistantPanel from '@/design/ai/AiAssistantPanel.vue'
|
import AiAssistantPanel from '@/design/ai/AiAssistantPanel.vue'
|
||||||
import { useConfirm } from '@/design/composables/useConfirm'
|
import { useConfirm } from '@/design/composables/useConfirm'
|
||||||
|
import { usePrinterProbe } from '@/design/composables/usePrinterProbe'
|
||||||
import { createDemoTemplate, DEMO_TEMPLATE_NAME } from '@/repository/mock/data/demo-template'
|
import { createDemoTemplate, DEMO_TEMPLATE_NAME } from '@/repository/mock/data/demo-template'
|
||||||
import { exportTemplateFile, importTemplateFile } from '@/design/utils/template-file'
|
import { exportTemplateFile, importTemplateFile } from '@/design/utils/template-file'
|
||||||
import { validateTemplate } from '@/core/spec/validator'
|
import { validateTemplate } from '@/core/spec/validator'
|
||||||
@@ -105,6 +106,57 @@ function onHelpKey(e: KeyboardEvent): void {
|
|||||||
onMounted(() => window.addEventListener('keydown', onHelpKey))
|
onMounted(() => window.addEventListener('keydown', onHelpKey))
|
||||||
onBeforeUnmount(() => window.removeEventListener('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 {
|
function onTemplateName(v: string): void {
|
||||||
store.templateName = v
|
store.templateName = v
|
||||||
store.dirty = true
|
store.dirty = true
|
||||||
@@ -425,14 +477,20 @@ const IconChevronDown: Component = () =>
|
|||||||
<NButton size="small" type="primary" @click="onSave">保存</NButton>
|
<NButton size="small" type="primary" @click="onSave">保存</NButton>
|
||||||
<NButton size="small" ghost class="toolbar-ghost-btn" @click="onExport">导出</NButton>
|
<NButton size="small" ghost class="toolbar-ghost-btn" @click="onExport">导出</NButton>
|
||||||
|
|
||||||
<!-- 打印按钮 -->
|
<!-- 打印按钮(右上角状态灯:绿=已连接 / 红=不可达 / 黄=检测中 / 灰=未检测) -->
|
||||||
<NTooltip>
|
<NTooltip>
|
||||||
<template #trigger>
|
<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" />
|
<div class="i-carbon-printer text-16px" />
|
||||||
|
<span class="printer-dot" :class="printerDotClass" />
|
||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</template>
|
||||||
打印
|
{{ printerTooltip }}
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
|
|
||||||
<!-- JSON 查看 -->
|
<!-- JSON 查看 -->
|
||||||
@@ -819,4 +877,49 @@ const IconChevronDown: Component = () =>
|
|||||||
padding-top: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { MARKET_TEMPLATES } from '@/repository/mock/data/market-templates'
|
import { MARKET_TEMPLATES } from './market-templates'
|
||||||
import { layout } from '@/core/layout-engine/pagination-engine'
|
import { render } from '@/core/sdk'
|
||||||
import { renderHtml } from '@/core/renderer-html'
|
import type { AnyControl, TableControl, ChartControl } from '@/types/control'
|
||||||
import { createCjkMeasurer } from '@/core/__tests__/cjk-measurer'
|
|
||||||
|
|
||||||
describe('market-templates —— 模板市场预设', () => {
|
function findTemplate(id: string) {
|
||||||
it('至少提供 6 个可用模板且覆盖主要品类', () => {
|
const t = MARKET_TEMPLATES.find((t) => t.id === id)
|
||||||
expect(MARKET_TEMPLATES.length).toBeGreaterThanOrEqual(6)
|
if (!t) throw new Error(`template ${id} not found`)
|
||||||
const cats = new Set(MARKET_TEMPLATES.map((t) => t.category))
|
return t
|
||||||
expect(cats.has('invoice')).toBe(true)
|
}
|
||||||
expect(cats.has('receipt')).toBe(true)
|
|
||||||
expect(cats.has('thermal')).toBe(true)
|
function bodyControls(id: string): AnyControl[] {
|
||||||
expect(cats.has('label')).toBe(true)
|
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() 产出的协议结构合法', () => {
|
it('包含 4 张 KPI 指标卡 + 3 张图表 + 1 张明细表', () => {
|
||||||
for (const tpl of MARKET_TEMPLATES) {
|
const c = bodyControls('market-financial-report')
|
||||||
const data = tpl.build()
|
const rects = c.filter((x) => x.type === 'rect')
|
||||||
expect(data.version).toBe('1.0')
|
const charts = c.filter((x) => x.type === 'chart') as ChartControl[]
|
||||||
expect(data.document.type).toBe('report')
|
const tables = c.filter((x) => x.type === 'table') as TableControl[]
|
||||||
const page = data.document.page
|
// 4 张 KPI 卡各 2 个色块(底 + 顶条)= 8,外加说明框 = 9
|
||||||
expect(page.width).toBeGreaterThan(0)
|
expect(rects.length).toBeGreaterThanOrEqual(9)
|
||||||
expect(page.height).toBeGreaterThan(0)
|
// bar / pie / line
|
||||||
expect(page.unit).toBe('mm')
|
expect(charts.map((ch) => ch.kind).sort()).toEqual(['bar', 'line', 'pie'])
|
||||||
// 页面尺寸与卡片标注一致
|
expect(tables.length).toBe(1)
|
||||||
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('含二维码的模板经 layout → renderHtml 能产出 SVG(开箱即用)', async () => {
|
it('明细表内嵌数据 + 自定义合计(含衍生利润率)', () => {
|
||||||
const tpl = MARKET_TEMPLATES.find((t) => t.id === 'market-qr-receipt')
|
const c = bodyControls('market-financial-report')
|
||||||
expect(tpl).toBeDefined()
|
const table = c.find((x) => x.type === 'table') as TableControl
|
||||||
// 全部含 qrcode 控件的模板都验证一遍
|
expect(table.data).toBeDefined()
|
||||||
const qrTemplates = MARKET_TEMPLATES.filter((t) =>
|
expect(table.data!.length).toBe(5)
|
||||||
t.build().document.sections.some((s) =>
|
expect(table.options?.summaryRow?.type).toBe('custom')
|
||||||
(s.components as Array<{ type: string }>).some((c) => c.type === 'qrcode'),
|
// 营收合计应为 5 个分部之和
|
||||||
),
|
const rows = table.data ?? []
|
||||||
)
|
const sumRevenue = rows.reduce((s, r) => s + (r.revenue as number), 0)
|
||||||
expect(qrTemplates.length).toBeGreaterThanOrEqual(3)
|
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) {
|
it('端到端渲染为单页,且含合计行与图表 SVG', async () => {
|
||||||
const result = await layout(qrTpl.build(), {}, { measurer: createCjkMeasurer() })
|
const tpl = findTemplate('market-financial-report').build()
|
||||||
const html = renderHtml(result, { screen: false })
|
const { html, pages, warnings } = await render({ template: tpl })
|
||||||
// qrcode 库输出 SVG 路径;html-renderer 以 op-code 包裹
|
// 单页报表 → 打印管线走 SVG 单页推送
|
||||||
expect(html).toContain('op-code')
|
expect(pages).toBe(1)
|
||||||
expect(html).toMatch(/<svg/)
|
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 {
|
import type {
|
||||||
AnyControl,
|
AnyControl,
|
||||||
BarcodeControl,
|
BarcodeControl,
|
||||||
|
ChartControl,
|
||||||
QrcodeControl,
|
QrcodeControl,
|
||||||
|
RectControl,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
|
TableControl,
|
||||||
TableOptions,
|
TableOptions,
|
||||||
TextControl,
|
TextControl,
|
||||||
} from '@/types/control'
|
} 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 {
|
export interface MarketTemplate {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,6 +40,7 @@ export interface MarketTemplate {
|
|||||||
|
|
||||||
export const MARKET_CATEGORY_LABEL: Record<MarketCategory, string> = {
|
export const MARKET_CATEGORY_LABEL: Record<MarketCategory, string> = {
|
||||||
invoice: '单据',
|
invoice: '单据',
|
||||||
|
report: '报表',
|
||||||
receipt: '小票',
|
receipt: '小票',
|
||||||
thermal: '热敏',
|
thermal: '热敏',
|
||||||
label: '标签',
|
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 }
|
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(
|
function table(
|
||||||
id: string,
|
id: string,
|
||||||
left: number,
|
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[] = [
|
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 热敏) ============ */
|
/* ============ 小票(58 / 80 热敏) ============ */
|
||||||
{
|
{
|
||||||
id: 'market-pos-58',
|
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