目标
- Rust 端:
labelprinter能启动一个 Tauri 窗口。 - 前端:6 个输入框、自动保存、预览/打印两按钮。
- 打印:调用 dtpweb,先跑通本地链路。
一、目录结构
text
labelprinter/
├── Cargo.toml
├── build.rs
├── tauri.conf.json
├── icons/
├── src/
│ └── main.rs
└── ui/ # 前端项目
├── package.json
├── vite.config.ts
├── index.html
└── src/
├── main.ts
├── App.vue
├── signal.ts
├── components/
│ └── LabelForm.vue
└── utils/
├── storage.ts
└── printAssetLabel.ts
二、Rust 端
1. labelprinter/Cargo.toml
toml
[package]
name = "labelprinter"
version = "0.1.0"
edition = "2024"
description = "资产标签打印"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
[[bin]]
name = "labelprinter"
path = "src/main.rs"
2. labelprinter/build.rs
rust
fn main() {
tauri_build::build();
}
3. labelprinter/src/main.rs
rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
4. labelprinter/tauri.conf.json
关键改动 :frontendDist 从远程 URL 改为本地前端产物。
json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "labelprinter",
"version": "0.1.0",
"identifier": "com.example.labelprinter",
"build": {
"frontendDist": "ui/dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm --prefix ui run dev",
"beforeBuildCommand": "npm --prefix ui run build"
},
"app": {
"windows": [
{
"title": "资产标签打印",
"width": 1000,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.ico",
"icons/icon.icns"
]
}
}
三、初始化前端
在 labelprinter 目录下执行:
powershell
cd D:\a11\labelprinter
npm create vite@latest ui -- --template vue-ts
cd ui
npm install
npm install element-plus dtpweb @tauri-apps/api
ui/ 里会自动生成 package.json、vite.config.ts、index.html 和 src/。
ui/vite.config.ts
typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
})
ui/index.html
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>资产标签打印</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
ui/src/main.ts
typescript
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
createApp(App).use(ElementPlus).mount('#app')
四、前端代码
ui/src/signal.ts
typescript
export type SignalStatus =
| 'red' // 0 否定 / 未验证
| 'yellow' // 1 警告 / 待核实
| 'black' // 2 退场 / 触及不到
| 'blue' // 3 告知
| 'green' // 4 确认
| 'white' // 5 平常 / 无过
export const SIGNAL_CODE: Record<SignalStatus, number> = {
red: 0, yellow: 1, black: 2, blue: 3, green: 4, white: 5,
}
export function isPassable(s: SignalStatus): boolean {
return SIGNAL_CODE[s] > 1
}
export function mostSevere(signals: SignalStatus[]): SignalStatus {
if (signals.length === 0) return 'white'
return signals.reduce((a, b) =>
SIGNAL_CODE[a] <= SIGNAL_CODE[b] ? a : b
)
}
/** 临时:单字段判定,后续由 Rust 接管 */
export function evaluateField(_key: string, value: string): SignalStatus {
if (!value.trim()) return 'red' // 空 → 红(内部防漏)
return 'white' // 默认白
}
ui/src/utils/storage.ts
typescript
import type { SignalStatus } from '../signal'
export interface FieldData {
value: string
status: SignalStatus
}
export interface LabelFormData {
assetId: FieldData
model: FieldData
spec: FieldData
mac: FieldData
hdsn: FieldData
owner: FieldData
}
const KEY = 'label_form_draft'
export function saveDraft(data: LabelFormData): void {
try { localStorage.setItem(KEY, JSON.stringify(data)) }
catch (e) { console.warn('保存失败', e) }
}
export function loadDraft(): LabelFormData | null {
try {
const raw = localStorage.getItem(KEY)
return raw ? JSON.parse(raw) : null
} catch { return null }
}
export function clearDraft(): void {
localStorage.removeItem(KEY)
}
ui/src/utils/printAssetLabel.ts
typescript
import { DTPWeb } from 'dtpweb'
const PRINTER_NAME = 'DT-350-CC60501263'
const LABEL_WIDTH = 60
const LABEL_HEIGHT = 40
export interface AssetLabelData {
assetId: string
model: string
spec: string
mac: string
hdsn: string
owner: string
}
async function getApi(): Promise<any> {
return new Promise((resolve, reject) => {
DTPWeb.checkServer((value: any) => {
if (!value) reject(new Error('打印助手未就绪'))
else resolve(value)
})
})
}
async function drawLabelContent(api: any, data: AssetLabelData) {
await api.drawText({
text: '十一科技合肥分院资产标签',
x: 1.0, y: 1.5, width: 57.0, height: 4.342,
fontHeight: 3.704,
})
const drawField = async (label: string, value: string, y: number) => {
await api.drawText({
text: label, x: 1.0, y,
width: 8.0, height: 3.722, fontHeight: 3.175,
})
await api.drawText({
text: value, x: 9.0, y,
width: 48.5, height: 3.722, fontHeight: 3.175,
})
}
await drawField('编号:', data.assetId, 9.0)
await drawField('品名:', data.model, 14.0)
await drawField('配置:', data.spec, 19.0)
await drawField('MAC:', data.mac, 24.0)
await drawField('HDSN:', data.hdsn, 29.0)
await api.drawText({
text: '部门及责任人:',
x: 1.0, y: 34.5, width: 20.0, height: 3.722, fontHeight: 3.175,
})
await api.drawText({
text: data.owner,
x: 21.0, y: 34.5, width: 36.0, height: 3.722, fontHeight: 3.175,
})
}
/** 预览:返回 BASE64 图片 */
export async function previewLabel(data: AssetLabelData): Promise<string> {
const api = await getApi()
return new Promise((resolve, reject) => {
api.openPrinter((ok: boolean) => {
if (!ok) return reject(new Error('打印机连接失败'))
api.startJob({ width: LABEL_WIDTH, height: LABEL_HEIGHT })
drawLabelContent(api, data).then(() => {
api.commitJob((res: any) => {
api.closePrinter()
if (res?.imageData) resolve(`data:image/png;base64,${res.imageData}`)
else reject(new Error('预览生成失败'))
}, { format: 1 })
})
})
})
}
/** 打印 */
export async function printLabel(data: AssetLabelData): Promise<void> {
const api = await getApi()
return new Promise((resolve, reject) => {
api.openPrinter((ok: boolean) => {
if (!ok) return reject(new Error('打印机连接失败'))
api.startJob({ width: LABEL_WIDTH, height: LABEL_HEIGHT })
drawLabelContent(api, data).then(() => {
api.commitJob((res: any) => {
api.closePrinter()
if (res?.statusCode === 0) resolve()
else reject(new Error(res?.errMsg || '打印失败'))
})
})
})
})
}
ui/src/components/LabelForm.vue
vue
<template>
<el-form :model="form" class="label-form">
<div
v-for="field in fields"
:key="field.key"
class="signal-field"
:class="`bg-${form[field.key].status}`"
>
<span class="dot" :class="`dot-${form[field.key].status}`"></span>
<label class="label">{{ field.label }}</label>
<el-input
v-model="form[field.key].value"
class="value"
@input="onInput(field.key)"
/>
</div>
</el-form>
</template>
<script setup lang="ts">
import { reactive, watch, onMounted } from 'vue'
import { evaluateField } from '../signal'
import { saveDraft, loadDraft, type LabelFormData } from '../utils/storage'
const fields = [
{ key: 'assetId', label: '编号' },
{ key: 'model', label: '品名' },
{ key: 'spec', label: '配置' },
{ key: 'mac', label: 'MAC' },
{ key: 'hdsn', label: 'HDSN' },
{ key: 'owner', label: '部门及责任人' },
] as const
type FieldKey = typeof fields[number]['key']
const empty = (): LabelFormData => ({
assetId: { value: '', status: 'white' },
model: { value: '', status: 'white' },
spec: { value: '', status: 'white' },
mac: { value: '', status: 'white' },
hdsn: { value: '', status: 'white' },
owner: { value: '', status: 'white' },
})
const form = reactive<LabelFormData>(empty())
onMounted(() => {
const draft = loadDraft()
if (draft) Object.assign(form, draft)
})
function onInput(key: FieldKey) {
form[key].status = evaluateField(key, form[key].value)
saveDraft(form)
}
watch(form, () => saveDraft(form), { deep: true })
defineExpose({ form, clear })
function clear() { Object.assign(form, empty()) }
</script>
<style scoped>
.label-form {
max-width: 720px;
margin: 0 auto;
}
.signal-field {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 6px;
margin-bottom: 10px;
color: #1a1a1a;
transition: background 0.2s ease;
}
.bg-red { background: #f5d5d5; }
.bg-yellow { background: #f5e8c8; }
.bg-blue { background: #d5e3f5; }
.bg-green { background: #d5eed5; }
.bg-white { background: #fafafa; }
.bg-black { background: #ececec; border: 1px dashed #888; }
.label {
min-width: 110px;
font-weight: 500;
color: #4a4a4a;
}
.value { flex: 1; }
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-red { background: #d97a7a; }
.dot-yellow { background: #d4a34a; }
.dot-blue { background: #7aa8d9; }
.dot-green { background: #7ab87a; }
.dot-white { display: none; }
.dot-black { background: #1a1a1a; }
</style>
ui/src/App.vue
vue
<template>
<div class="app">
<h2>资产标签打印</h2>
<LabelForm ref="formRef" />
<div class="actions">
<el-button
type="primary"
:disabled="!canPass"
@click="handlePreview"
>预览</el-button>
<el-button
type="success"
:disabled="!canPass"
@click="handlePrint"
>打印</el-button>
<el-button @click="handleClear">清空</el-button>
</div>
<div v-if="!canPass && blockReason" class="block-hint">
{{ blockReason }}
</div>
<el-dialog v-model="previewVisible" title="标签预览" width="640px">
<img v-if="previewImage" :src="previewImage" class="preview-img" />
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
<el-button type="primary" @click="handlePrintFromPreview">确认打印</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import LabelForm from './components/LabelForm.vue'
import { previewLabel, printLabel, type AssetLabelData } from './utils/printAssetLabel'
import { isPassable, type SignalStatus } from './signal'
import type { LabelFormData } from './utils/storage'
const formRef = ref<InstanceType<typeof LabelForm>>()
const previewVisible = ref(false)
const previewImage = ref('')
function unwrap(f: LabelFormData): AssetLabelData {
return {
assetId: f.assetId.value,
model: f.model.value,
spec: f.spec.value,
mac: f.mac.value,
hdsn: f.hdsn.value,
owner: f.owner.value,
}
}
function check(f: LabelFormData): { ok: boolean; reason?: string } {
const items: [string, SignalStatus][] = [
['编号', f.assetId.status],
['品名', f.model.status],
['配置', f.spec.status],
['MAC', f.mac.status],
['HDSN', f.hdsn.status],
['部门及责任人', f.owner.status],
]
const blockers = items
.filter(([_, s]) => !isPassable(s))
.map(([label, s]) => `${label}(${s})`)
if (blockers.length) return { ok: false, reason: `未通过:${blockers.join('、')}` }
return { ok: true }
}
const canPass = computed(() => {
const f = formRef.value?.form
return f ? check(f).ok : false
})
const blockReason = computed(() => {
const f = formRef.value?.form
return f ? (check(f).reason || '') : ''
})
async function handlePreview() {
const f = formRef.value?.form
if (!f) return
const c = check(f)
if (!c.ok) return ElMessage.error(c.reason)
try {
previewImage.value = await previewLabel(unwrap(f))
previewVisible.value = true
} catch (e) {
ElMessage.error('预览失败:' + e)
}
}
async function handlePrint() {
const f = formRef.value?.form
if (!f) return
const c = check(f)
if (!c.ok) return ElMessage.error(c.reason)
try {
await printLabel(unwrap(f))
ElMessage.success('打印成功')
} catch (e) {
ElMessage.error('打印失败:' + e)
}
}
async function handlePrintFromPreview() {
previewVisible.value = false
await nextTick()
await handlePrint()
}
function handleClear() {
formRef.value?.clear()
previewImage.value = ''
}
</script>
<style scoped>
.app {
max-width: 800px;
margin: 40px auto;
padding: 20px;
}
.actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
}
.block-hint {
margin-top: 12px;
text-align: center;
color: #d97a7a;
font-size: 14px;
}
.preview-img {
width: 100%;
max-width: 500px;
display: block;
margin: 0 auto;
}
</style>
五、启动
开发模式
powershell
cd D:\a11\labelprinter
cargo tauri dev
Tauri CLI 会自动:
- 跑
npm --prefix ui run dev启动 Vite(端口 1420)。 - 等 dev server 就绪。
- 编译 Rust 并打开窗口。
第一次编译时间较长。
只跑前端(调试 UI)
powershell
cd D:\a11\labelprinter\ui
npm run dev
浏览器打开 http://localhost:1420,可以看到界面,但 dtpweb 调用会失败(因为不在 Tauri 环境里,window.__TAURI__ 不可用,且 DTPWeb.checkServer 可能不同)。只用于调 UI。
生产构建
powershell
cd D:\a11\labelprinter
cargo tauri build
六、验证清单
-
cargo tauri dev能打开窗口。 - 窗口里能看到 6 个输入框。
- 输入内容,刷新窗口,内容还在(localStorage 生效)。
- 空字段显示红背景 + 红点。
- 全部填写后,预览/打印按钮可点。
- 点预览能弹窗显示标签图(需 dtpweb 助手就绪)。
- 点打印能出纸。
七、之后的对接路径
| 阶段 | 改动 |
|---|---|
| 现在 | 前端临时 evaluateField 判定信号 |
| 下一步 | Rust 端加 evaluate_field 命令,前端改调它 |
| 再下一步 | Rust 端加 get_asset_data 命令,自动填充 6 项 |
| 再下一步 | 打印后调 log_label_print 写备查库 |
previewLabel / printLabel 两函数整个过程中不变,它们是纯执行器。
八、一句话
Rust 端三个文件(
Cargo.toml/build.rs/main.rs)+tauri.conf.json指向本地前端。前端 Vue 3 + Element Plus,6 输入框带信号色、自动保存到 localStorage、预览/打印两按钮。跑通后再让 Rust 接管信号、数据和备查。previewLabel/printLabel两函数保持不变。