【A11】6 输入框界面:前端 + Rust 基本配置

目标

  • 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.jsonvite.config.tsindex.htmlsrc/

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 会自动:

  1. npm --prefix ui run dev 启动 Vite(端口 1420)。
  2. 等 dev server 就绪。
  3. 编译 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 两函数保持不变。

相关推荐
Kapaseker1 小时前
Rust 编译器总是在抱怨什么?
rust
Ramble_Naylor17 小时前
错误是一种值:Result、? 与 panic!
rust
柯南466818 小时前
【AI开发之Rust】第 10 课:Cargo 工程化 —— 模块、依赖与测试(基础篇收尾)
rust·编程语言
qq_4523962318 小时前
第九篇:《并发编程:线程、Channel 与共享状态》
rust
Source.Liu19 小时前
【A11】 labelprinter(Tauri 版)新建步骤
windows·rust
孙启超20 小时前
【AI开发之Rust】第 8 课:泛型、trait 与 trait 对象
开发语言·后端·rust
蓝宝石的傻话20 小时前
MiBee Eye Notebook:把闲置笔记本变成一台带麦克风的网络摄像头
数码相机·rust
小灰灰搞电子21 小时前
Rust Once 、OnceLock、LazyLock 一次性初始化详解
开发语言·后端·rust
qq_452396231 天前
第八篇:《智能指针与内部可变性:Rust 的进阶内存管理》
rust