
🏷️ 通用产品标签打印
一个基于 Vue 3 的纯前端产品标签打印工具,支持灵活的字段配置、批量打印、模板管理和数据导入导出。
✨ 功能特性
产品管理
-
录入产品 --- 支持 12 个可自定义字段,可通过模板快速填充
-
编辑产品 --- 修改字段值,支持单张卡片独立覆盖字段名称
-
删除产品 --- 单条删除
-
搜索过滤 --- 实时搜索所有字段,匹配内容高亮显示
-
热门标签 --- 自动从已有数据中提取高频值作为快捷搜索入口
字段配置
-
全局字段设置 --- 自定义每个字段的默认标签名、提示文字
-
卡片显示控制 --- 勾选决定哪些字段展示在产品卡片上
-
单卡片覆盖 --- 每张产品卡片可单独修改字段名称(如将
FILE 1改为SKU)
模板系统
-
从卡片创建模板 --- 一键将现有产品存为模板
-
录入时选择模板 --- 快速填充常用字段值
-
模板管理 --- 重命名、删除、查看模板填充项数
打印功能
-
字段勾选 --- 按需选择打印哪些字段行
-
字段合并 --- 支持
FILE 7+8、FILE 9+10、FILE 11+12两两合并为一行 -
水印存根 --- 可选右侧水印,支持自定义字段和透明度(1%--60%)
-
打印数量 --- 设置打印份数
-
每产品独立设置 --- 每个产品的打印布局配置可单独保存
-
自适应排版 --- 字体大小自动缩放,超长内容横向压缩适配 A4 横版
-
实时预览 --- 打印前预览标签效果
数据管理
-
导入 --- 支持 JSON 文件导入(兼容旧版数据格式自动迁移)
-
导出 --- 导出包含字段配置、产品数据和模板的完整 JSON
-
清除 --- 一键清空所有产品数据
-
本地持久化 --- 所有数据存储在浏览器
localStorage中,刷新不丢失
🚀 快速使用
直接打开
本项目是单文件 HTML 应用,无需构建:
# 用浏览器直接打开
open index.html
# 或使用任意本地服务器
npx serve .
python -m http.server 8080
依赖
| 依赖 | 来源 | 说明 |
|---|---|---|
| Vue 3 | unpkg.com/vue@3/dist/vue.global.js |
CDN 引入,需联网 |
无需 npm install,无需构建工具。
📖 使用指南
1. 配置字段
点击顶部 ⚙️ 字段 按钮,进入全局字段配置:
-
修改默认标签名(如将
FILE 1→SKU,FILE 2→类别) -
设置输入提示文字
-
通过复选框控制是否在卡片上显示
-
随时点击「恢复默认」重置
2. 录入产品
点击 + 录入:
-
可从下拉菜单选择已有模板自动填充
-
填写各字段值
-
点击「添加」或「添加并继续」(连续录入模式)
-
可随时点击 ⭐ 存为模板 保存当前表单为模板
3. 管理卡片
将鼠标悬停在产品卡片上,出现操作按钮:
| 按钮 | 功能 |
|---|---|
| ✏️ | 编辑字段值和自定义标签名 |
| ⭐ | 将该产品存为模板 |
| 🖨️ | 打开打印配置 |
| 🗑️ | 删除该产品 |
4. 打印标签
点击卡片上的 🖨️ 进入打印面板:
-
左侧 --- 实时预览标签效果
-
右侧 --- 勾选需要打印的字段行
-
启用「合并」开关将相邻字段合并为一行
-
启用「水印存根」添加右侧垂直水印
-
设置打印数量后点击 🖨️ 打印
-
产品级打印设置会自动保存(标签显示「已保存设置」)
5. 导入导出
| 操作 | 说明 |
|---|---|
| 📥 导入 | 选择 JSON 文件,追加到现有数据 |
| 📤 导出 | 下载包含字段、产品、模板的完整 JSON |
| 🗑️ 清除 | 清空所有产品数据(需确认) |
📁 数据格式
导出的 JSON 文件结构:
{
"version": 3,
"fields": [
{ "key": "file1", "label": "SKU", "ph": "输入SKU", "show": true }
],
"products": [
{
"id": 1716000000000,
"file1": "ABC-001",
"file2": "服装",
"labels": { "file1": "自定义名" },
"ps": { "vis": {}, "comb78": false, "useWm": false, "wmOp": 12, "prtCnt": 1 }
}
],
"templates": [
{ "id": 1716000000001, "name": "常用模板", "vals": {}, "labels": {}, "ps": {} }
]
}
兼容性
导入时自动识别并迁移旧版数据格式(label_products_v3、label_app_v2、label_app),旧字段名映射:
| 旧字段 | 新字段 |
|---|---|
sku |
file1 |
category |
file2 |
style |
file3 |
color |
file4 |
size |
file5 |
qty |
file6 |
boxNo |
file7 |
boxTotal |
file8 |
🖨️ 打印规格
| 项目 | 值 |
|---|---|
| 纸张 | A4 横版(297mm × 210mm) |
| 默认字号 | 80px |
| 字体 | Arial Bold |
| 自适应 | 字段 >6 行时自动缩小字号;超长内容自动横向缩放 |
| 水印 | 右侧垂直排列,支持透明度 1%--60% |
🛠️ 技术栈
-
Vue 3 --- Composition API(
setup) -
纯 CSS --- 无外部 UI 框架
-
localStorage --- 本地数据持久化
-
零构建 --- 单 HTML 文件,CDN 引入 Vue
📌 注意事项
-
数据存储在浏览器本地,清除浏览器缓存会丢失数据,建议定期导出备份
-
打印功能依赖浏览器的
window.print(),建议使用 Chrome / Edge 获得最佳效果 -
需联网加载 Vue 3 CDN,离线使用需将
vue.global.js下载到本地并替换引用路径
代码:
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>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:Arial,sans-serif;background:#f8f6f3;color:#1a1a1a;padding:24px}
.tb{max-width:1200px;margin:0 auto;background:#fff;border-radius:12px;padding:14px 20px;box-shadow:0 1px 4px rgba(0,0,0,.04);display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.tw{flex:1;position:relative;min-width:0}
.ti{width:100%;border:2px solid #e5e2dc;border-radius:8px;padding:10px 40px 10px 14px;font-size:14px;transition:.2s}
.ti:focus{outline:none;border-color:#c41e3a;box-shadow:0 0 0 3px rgba(196,30,58,.1)}
.cl{position:absolute;right:8px;top:50%;transform:translateY(-50%);width:28px;height:28px;border:none;background:#eee;border-radius:50%;cursor:pointer;font-size:16px;color:#555}
.tc{font-size:13px;color:#555;white-space:nowrap}.tc b{color:#c41e3a}
.tg{display:flex;gap:6px;flex-wrap:wrap;max-width:1200px;margin:10px auto 0}
.tg span{padding:4px 12px;background:#fff;border:1px solid #e5e2dc;border-radius:16px;font-size:12px;cursor:pointer;transition:.15s}
.tg span:hover{background:#c41e3a;color:#fff;border-color:#c41e3a}
.head{display:flex;justify-content:space-between;align-items:center;max-width:1200px;margin:20px auto 16px;flex-wrap:wrap;gap:10px}
.head h1{font-size:24px;font-weight:700}
.ha{display:flex;gap:8px;flex-wrap:wrap}
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;max-width:1200px;margin:0 auto}
.cd{background:#fff;border:1.5px solid #1a1a1a;border-radius:2px;overflow:hidden;position:relative;transition:.2s}
.cd:hover{box-shadow:0 8px 24px rgba(0,0,0,.12)}
.cd .bd{padding:10px 12px}
.cd .rw{display:flex;font-size:10px;line-height:1.6;border-bottom:1px dotted #eee}
.cd .rw:last-child{border-bottom:none}
.cd .lb{font-weight:700;width:80px;flex-shrink:0}
.cd .vl{color:#555;text-transform:uppercase;white-space:pre-wrap;word-break:break-all}
.hl{background:#fef08a;padding:0 2px;border-radius:2px}
.cd .ac{position:absolute;top:8px;right:8px;opacity:0;transition:.2s;display:flex;gap:3px}
.cd:hover .ac{opacity:1}
.ab{width:28px;height:28px;border:none;border-radius:6px;cursor:pointer;font-size:12px}
.ab-prt{background:#dcfce7;color:#16a34a}.ab-prt.has-ps{background:#fef3c7;color:#d97706}
.ab-edt{background:#dbeafe;color:#2563eb}
.ab-del{background:#fee2e2;color:#dc2626}
.ab-tpl{background:#fef3c7;color:#d97706}
.mo{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(4px)}
.pm{background:#fff;border-radius:16px;max-width:560px;width:100%;overflow:hidden;display:flex;flex-direction:column;max-height:92vh}
.pm.wd{max-width:700px}
.mh{padding:20px 24px;border-bottom:1px solid #e5e2dc;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
.mt{font-size:20px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:400px}
.xb{width:36px;height:36px;border:none;background:#f8f6f3;border-radius:8px;cursor:pointer;font-size:20px;flex-shrink:0}
.prv{padding:24px;display:flex;flex-direction:column;align-items:center;gap:20px;background:#f0f0f0}
.lpv{width:400px;background:#fff;padding:20px;font-family:Arial,sans-serif;font-weight:700;position:relative;overflow:hidden;min-height:260px}
.lr{font-weight:700;text-transform:uppercase;white-space:pre;letter-spacing:1px;margin-bottom:8px;font-size:24px;position:relative;z-index:2;overflow:hidden;text-overflow:ellipsis}
.lrc{display:flex;gap:20px}.lrc span{white-space:pre}
.wm{position:absolute;right:0;top:0;bottom:0;width:40px;z-index:1;display:flex;align-items:center;justify-content:center;pointer-events:none;border-left:1px dashed rgba(0,0,0,.12)}
.wm span{writing-mode:vertical-rl;transform:rotate(180deg);font-weight:800;white-space:pre;text-transform:uppercase;letter-spacing:4px}
.ck{display:flex;align-items:center;gap:8px;padding:10px 24px;border-bottom:1px solid #eee;cursor:pointer}
.ck input[type="checkbox"]{width:18px;height:18px;accent-color:#c41e3a;cursor:pointer}
.opt{display:flex;align-items:center;gap:10px;padding:12px 24px;border-bottom:1px solid #eee}
.opt label{font-size:13px;font-weight:500;white-space:nowrap}
.opt input[type="range"]{flex:1;accent-color:#c41e3a;cursor:pointer}
.opt input[type="number"]{width:56px;padding:4px 6px;border:1.5px solid #e5e2dc;border-radius:6px;font-size:13px;text-align:center}
.opt .ut{font-size:12px;color:#999}
.opt select{padding:6px 10px;border:1.5px solid #e5e2dc;border-radius:6px;font-size:13px;background:#fff;cursor:pointer}
.qty{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;border-bottom:1px solid #eee}
.ql{font-size:13px;font-weight:500}
.qn{width:80px;padding:6px 10px;border:1.5px solid #e5e2dc;border-radius:6px;font-size:14px;text-align:center}
.ft{padding:16px 24px;border-top:1px solid #e5e2dc;display:flex;justify-content:center;gap:10px;background:#fafafa;flex-shrink:0;flex-wrap:wrap}
.btn{padding:10px 16px;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.btn-o{background:#fff;border:1.5px solid #e5e2dc;color:#1a1a1a}.btn-o:hover{border-color:#c41e3a;color:#c41e3a}
.btn-d{background:#1a1a1a;color:#fff}.btn-d:hover{background:#333}
.btn-p{background:#c41e3a;color:#fff}.btn-p:hover{background:#a01830}
.btn-w{background:#fff;border:1.5px solid #fbbf24;color:#d97706}.btn-w:hover{background:#fef3c7}
.btn-s{background:#fff;border:1.5px solid #a78bfa;color:#7c3aed}.btn-s:hover{background:#f5f3ff}
.em{text-align:center;padding:60px;color:#999;grid-column:1/-1}
.fg{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px;padding:20px 24px;overflow-y:auto}
.ff{display:flex;flex-direction:column;gap:4px}
.fl{font-size:11px;font-weight:700;color:#888;text-transform:uppercase;letter-spacing:.5px}
.fv{padding:10px 12px;border:1.5px solid #e5e2dc;border-radius:8px;font-size:14px;transition:.2s}
.fv:focus{outline:none;border-color:#c41e3a;box-shadow:0 0 0 3px rgba(196,30,58,.08)}
.rc{display:flex;align-items:center;gap:8px;padding:10px 24px;border-bottom:1px solid #eee;font-size:13px}
.rc input[type="checkbox"]{width:16px;height:16px;accent-color:#c41e3a;cursor:pointer}
.rc .rn{font-weight:600;flex:1}.rc .rv{color:#555;text-transform:uppercase;white-space:pre}.rc.off{opacity:.45}
.sep{height:1px;background:#e5e2dc;margin:0 24px}
.mg{display:flex;align-items:center;gap:8px;padding:8px 24px;background:#fef9c3;border-bottom:1px solid #eee;font-size:12px;font-weight:600}
.mg label{display:flex;align-items:center;gap:6px;cursor:pointer}
.mg input[type="checkbox"]{width:15px;height:15px;accent-color:#d97706;cursor:pointer}
.sf{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #eee}
.sk{font-size:11px;color:#999;width:58px;flex-shrink:0;font-weight:700}
.si{flex:1;padding:7px 10px;border:1.5px solid #e5e2dc;border-radius:6px;font-size:13px;transition:.2s;min-width:0}
.si:focus{outline:none;border-color:#c41e3a;box-shadow:0 0 0 3px rgba(196,30,58,.08)}
.scb{width:18px;height:18px;accent-color:#c41e3a;cursor:pointer;flex-shrink:0}
.sh{display:flex;align-items:center;gap:12px;padding:6px 0 10px;font-size:11px;font-weight:700;color:#aaa;text-transform:uppercase;letter-spacing:.5px}
.ef{display:flex;gap:8px;padding:8px 0;border-bottom:1px solid #eee;align-items:center}
.ek{font-size:10px;color:#999;width:42px;flex-shrink:0;font-weight:700}
.el{width:130px;padding:7px 10px;border:1.5px solid #d97706;border-radius:6px;font-size:13px;background:#fffbeb}
.el:focus{outline:none;border-color:#d97706;box-shadow:0 0 0 3px rgba(217,119,6,.12)}
.ev{flex:1;padding:7px 10px;border:1.5px solid #e5e2dc;border-radius:6px;font-size:13px;transition:.2s}
.ev:focus{outline:none;border-color:#c41e3a;box-shadow:0 0 0 3px rgba(196,30,58,.08)}
.eh{display:flex;gap:8px;padding:4px 0 8px;font-size:11px;font-weight:700;color:#aaa;text-transform:uppercase;letter-spacing:.5px}
.lbl-custom{color:#d97706;font-style:italic}
.ps-tag{font-size:9px;padding:1px 6px;border-radius:8px;background:#fef3c7;color:#d97706;margin-left:6px;font-weight:700}
.tpl-bar{padding:10px 24px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #eee;background:#faf5ff}
.tpl-bar label{font-size:12px;font-weight:700;color:#7c3aed;white-space:nowrap}
.tpl-bar select{flex:1;padding:7px 10px;border:1.5px solid #a78bfa;border-radius:6px;font-size:13px;background:#fff;cursor:pointer}
.tpl-bar select:focus{outline:none;border-color:#7c3aed}
.tpl-item{display:flex;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid #eee}
.tpl-nm{flex:1;font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tpl-info{font-size:11px;color:#999;white-space:nowrap}
.tpl-info b{color:#7c3aed}
</style>
</head>
<body>
<div id="app">
<div class="tb">
<div class="tw">
<input class="ti" v-model="q" placeholder="🔍 搜索任意字段 ...">
<button class="cl" v-if="q" @click="q=''">×</button>
</div>
<span class="tc" v-if="q">找到 <b>{{filtered.length}}</b> / {{products.length}}</span>
</div>
<div class="tg" v-if="hotTags.length">
<span v-for="t in hotTags" :key="t" @click="q=t">{{t}}</span>
</div>
<div class="head">
<h1>🏷️ 通用标签打印</h1>
<div class="ha">
<button class="btn btn-p" @click="openAdd()">+ 录入</button>
<button class="btn btn-s" @click="dlgTmpl=true">📋 模板</button>
<button class="btn btn-o" @click="openCfg()">⚙️ 字段</button>
<button class="btn btn-o" @click="doImportBtn()">📥</button>
<button class="btn btn-o" @click="doExport()">📤</button>
<button class="btn btn-o" @click="doClear()" style="color:#dc2626">🗑️</button>
</div>
</div>
<div class="g">
<div class="cd" v-for="p in filtered" :key="p.id">
<div class="ac">
<button class="ab ab-edt" @click="openEdit(p)" title="编辑">✏️</button>
<button class="ab ab-tpl" @click="saveAsTmpl(p)" title="存为模板">⭐</button>
<button class="ab ab-prt" :class="{'has-ps':p.ps}" @click="openPrt(p)" title="打印">🖨️</button>
<button class="ab ab-del" @click="delP(p)" title="删除">🗑️</button>
</div>
<div class="bd">
<div class="rw" v-for="f in cardFlds" :key="f.key">
<span class="lb" :class="{'lbl-custom':hasCustom(p,f.key)}">{{getLabel(p,f.key)}}</span>
<span class="vl" v-html="hl(p[f.key])"></span>
</div>
<div class="rw" v-if="!cardFlds.length" style="color:#aaa;font-size:11px">⚙️ 配置字段</div>
</div>
</div>
<div class="em" v-if="!filtered.length && q">🔍 未找到</div>
<div class="em" v-if="!products.length">暂无数据 --- 点击「+ 录入」</div>
</div>
<!-- 录入 -->
<div class="mo" v-if="dlgAdd" @click.self="dlgAdd=false">
<div class="pm">
<div class="mh"><span class="mt">📝 录入产品</span><button class="xb" @click="dlgAdd=false">×</button></div>
<div class="tpl-bar" v-if="tmpls.length">
<label>📋 模板</label>
<select v-model="selTmpl" @change="onTmplSel()">
<option value="">不使用模板</option>
<option v-for="t in tmpls" :key="t.id" :value="String(t.id)">{{t.name}}</option>
</select>
</div>
<div class="fg">
<div class="ff" v-for="f in flds" :key="f.key">
<span class="fl">{{f.label}}</span>
<input class="fv" v-model="form[f.key]" :placeholder="f.ph||('输入 '+f.label)">
</div>
</div>
<div class="ft">
<button class="btn btn-o" @click="dlgAdd=false">取消</button>
<button class="btn btn-w" @click="saveFormAsTmpl()">⭐ 存为模板</button>
<button class="btn btn-p" @click="addP(false)">添加</button>
<button class="btn btn-d" @click="addP(true)">添加并继续</button>
</div>
</div>
</div>
<!-- 编辑 -->
<div class="mo" v-if="dlgEdit" @click.self="dlgEdit=false">
<div class="pm wd">
<div class="mh"><span class="mt">✏️ 编辑产品</span><button class="xb" @click="dlgEdit=false">×</button></div>
<div style="overflow-y:auto;flex:1;padding:12px 24px">
<div class="eh"><span style="width:42px">KEY</span><span style="width:130px">字段名(可改)</span><span style="flex:1">值</span></div>
<div class="ef" v-for="f in flds" :key="f.key">
<span class="ek">{{f.key}}</span>
<input class="el" v-model="editLabels[f.key]" :placeholder="f.label">
<input class="ev" v-model="editVals[f.key]" :placeholder="editLabels[f.key]||f.label">
</div>
</div>
<div class="ft">
<button class="btn btn-w" @click="rstLabels()">↺ 名称恢复默认</button>
<button class="btn btn-o" @click="dlgEdit=false">取消</button>
<button class="btn btn-p" @click="saveEdit()">✓ 保存</button>
</div>
</div>
</div>
<!-- 打印 -->
<div class="mo" v-if="dlgPrt" @click.self="dlgPrt=false">
<div class="pm">
<div class="mh">
<span class="mt">🖨️ {{printTitle}} <span class="ps-tag" v-if="hasCustomPS">已保存设置</span></span>
<button class="xb" @click="dlgPrt=false">×</button>
</div>
<div style="overflow-y:auto;flex:1">
<div class="prv">
<div class="lpv">
<div class="wm" v-if="useWm"><span :style="{fontSize:'16px',color:'rgba(0,0,0,'+wmOp/100+')'}">{{pi[wmFld]||''}}</span></div>
<template v-for="r in pvRows" :key="r.id">
<div v-if="r.t==='s'" class="lr">{{r.lbl}}: {{pi[r.f.key]||''}}</div>
<div v-else class="lr lrc">
<span v-if="r.s1">{{r.lbl1}}: {{pi[r.f1.key]||''}}</span>
<span v-if="r.s2">{{r.lbl2}}: {{pi[r.f2.key]||''}}</span>
</div>
</template>
<div class="em" v-if="!pvRows.length" style="padding:30px;font-size:12px">请勾选字段 ↓</div>
</div>
</div>
<div style="background:#fafafa;border-top:1px solid #e5e2dc">
<div style="padding:10px 24px 4px;font-size:12px;font-weight:700;color:#888;text-transform:uppercase;letter-spacing:.5px">勾选打印行</div>
<div class="rc" v-for="i in 6" :key="'c'+i" :class="{off:!pv['file'+i]}">
<input type="checkbox" v-model="pv['file'+i]">
<span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file'+i)}">{{prtLabel('file'+i)}}</span>
<span class="rv">{{displayVal(pi['file'+i])}}</span>
</div>
<div class="sep"></div>
<div class="mg"><label><input type="checkbox" v-model="cm78"> {{prtLabel('file7')}} + {{prtLabel('file8')}} 合并</label></div>
<div class="rc" :class="{off:!pv.file7}"><input type="checkbox" v-model="pv.file7"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file7')}">{{prtLabel('file7')}}</span><span class="rv">{{displayVal(pi.file7)}}</span></div>
<div class="rc" :class="{off:!pv.file8}"><input type="checkbox" v-model="pv.file8"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file8')}">{{prtLabel('file8')}}</span><span class="rv">{{displayVal(pi.file8)}}</span></div>
<div class="sep"></div>
<div class="mg"><label><input type="checkbox" v-model="cm910"> {{prtLabel('file9')}} + {{prtLabel('file10')}} 合并</label></div>
<div class="rc" :class="{off:!pv.file9}"><input type="checkbox" v-model="pv.file9"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file9')}">{{prtLabel('file9')}}</span><span class="rv">{{displayVal(pi.file9)}}</span></div>
<div class="rc" :class="{off:!pv.file10}"><input type="checkbox" v-model="pv.file10"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file10')}">{{prtLabel('file10')}}</span><span class="rv">{{displayVal(pi.file10)}}</span></div>
<div class="sep"></div>
<div class="mg"><label><input type="checkbox" v-model="cm1112"> {{prtLabel('file11')}} + {{prtLabel('file12')}} 合并</label></div>
<div class="rc" :class="{off:!pv.file11}"><input type="checkbox" v-model="pv.file11"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file11')}">{{prtLabel('file11')}}</span><span class="rv">{{displayVal(pi.file11)}}</span></div>
<div class="rc" :class="{off:!pv.file12}"><input type="checkbox" v-model="pv.file12"><span class="rn" :class="{'lbl-custom':hasCustom(prtProd,'file12')}">{{prtLabel('file12')}}</span><span class="rv">{{displayVal(pi.file12)}}</span></div>
<div class="sep"></div>
<label class="ck"><input type="checkbox" v-model="useWm">水印存根</label>
<div class="opt" v-if="useWm"><label>水印字段</label><select v-model="wmFld"><option v-for="f in flds" :key="f.key" :value="f.key">{{prtLabel(f.key)}}</option></select></div>
<div class="opt" v-if="useWm"><label>透明度</label><input type="range" min="1" max="60" v-model.number="wmOp"><input type="number" min="1" max="60" v-model.number="wmOp"><span class="ut">%</span></div>
</div>
<div class="qty"><span class="ql">打印数量</span><input class="qn" type="number" v-model.number="prtCnt" min="1"></div>
</div>
<div class="ft">
<button class="btn btn-o" @click="dlgPrt=false">取消</button>
<button class="btn btn-w" @click="rstPS()">↺ 恢复默认</button>
<button class="btn btn-d" @click="doPrt()">🖨️ 打印</button>
</div>
</div>
</div>
<!-- 模板管理 -->
<div class="mo" v-if="dlgTmpl" @click.self="dlgTmpl=false">
<div class="pm">
<div class="mh"><span class="mt">📋 模板管理</span><button class="xb" @click="dlgTmpl=false">×</button></div>
<div style="overflow-y:auto;flex:1;padding:12px 24px">
<div v-if="!tmpls.length" style="text-align:center;padding:40px;color:#999;font-size:13px">
暂无模板<br><span style="font-size:12px">在卡片上点击 ⭐ 或录入时点击「存为模板」</span>
</div>
<div class="tpl-item" v-for="t in tmpls" :key="t.id">
<span class="tpl-nm">{{t.name}}</span>
<span class="tpl-info"><b>{{countFilled(t)}}</b> 项有值</span>
<button class="ab ab-edt" @click="renameTmpl(t)" title="重命名">✏️</button>
<button class="ab ab-del" @click="delTmpl(t)" title="删除">🗑️</button>
</div>
</div>
<div class="ft"><button class="btn btn-o" @click="dlgTmpl=false">关闭</button></div>
</div>
</div>
<!-- 全局字段配置 -->
<div class="mo" v-if="dlgCfg" @click.self="dlgCfg=false">
<div class="pm wd">
<div class="mh"><span class="mt">⚙️ 全局字段配置</span><button class="xb" @click="dlgCfg=false">×</button></div>
<div style="overflow-y:auto;flex:1;padding:12px 24px">
<div style="font-size:12px;color:#888;margin-bottom:12px">此处修改全局默认。单张卡片可在 ✏️ 编辑中单独覆盖字段名。</div>
<div class="sh"><span style="width:58px">KEY</span><span style="width:18px">卡片</span><span style="flex:1">默认标签名</span><span style="flex:1">默认提示</span></div>
<div class="sf" v-for="(f,idx) in flds" :key="f.key">
<span class="sk">{{f.key}}</span>
<input class="scb" type="checkbox" v-model="f.show">
<input class="si" v-model="f.label" :placeholder="'FILE '+(idx+1)">
<input class="si" v-model="f.ph" placeholder="提示文字">
</div>
</div>
<div class="ft">
<button class="btn btn-o" @click="rstCfg()">恢复默认</button>
<button class="btn btn-p" @click="savCfg()">✓ 保存</button>
</div>
</div>
</div>
<input type="file" ref="fiRef" style="display:none" accept=".json" @change="onFile">
</div>
<script>
var V = Vue;
V.createApp({
setup: function() {
function mkDefs() {
var a = [];
for (var i = 1; i <= 12; i++) a.push({ key: 'file' + i, label: 'FILE ' + i, ph: '', show: true });
return a;
}
function mkPS() {
var vis = {};
for (var i = 1; i <= 12; i++) vis['file' + i] = true;
return { vis: vis, comb78: false, comb910: false, comb1112: false, useWm: false, wmOp: 12, wmFld: 'file1', prtCnt: 1 };
}
function isDefaultPS(ps) {
if (!ps) return true;
if (ps.comb78 || ps.comb910 || ps.comb1112 || ps.useWm) return false;
if ((ps.prtCnt || 1) !== 1) return false;
for (var i = 1; i <= 12; i++) if ((ps.vis['file' + i] || false) !== true) return false;
return true;
}
function cloneDeep(o) { return JSON.parse(JSON.stringify(o)); }
var flds = V.ref(mkDefs());
var products = V.ref([]);
var tmpls = V.ref([]);
var dlgAdd = V.ref(false), dlgEdit = V.ref(false), dlgPrt = V.ref(false), dlgCfg = V.ref(false), dlgTmpl = V.ref(false);
var prtId = V.ref(0), editId = V.ref(0);
var prtCnt = V.ref(1), useWm = V.ref(false), wmOp = V.ref(12), wmFld = V.ref('file1');
var cm78 = V.ref(false), cm910 = V.ref(false), cm1112 = V.ref(false);
var q = V.ref(''), selTmpl = V.ref('');
var pi = V.reactive({}), form = V.reactive({}), pv = V.reactive({});
var editLabels = V.reactive({}), editVals = V.reactive({});
var fiRef = V.ref(null);
var keys = [];
for (var i = 1; i <= 12; i++) { var k = 'file' + i; keys.push(k); form[k] = ''; pv[k] = true; }
/* ═══ 空格处理 ═══ */
function escH(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ');
}
function escPrint(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\u00A0');
}
function displayVal(v) { return v || '---'; }
function getLabel(p, key) {
if (p && p.labels && p.labels[key]) return p.labels[key];
for (var i = 0; i < flds.value.length; i++) if (flds.value[i].key === key) return flds.value[i].label;
return key;
}
function hasCustom(p, key) { return p && p.labels && p.labels[key] && p.labels[key].trim(); }
function findProd(id) { for (var i = 0; i < products.value.length; i++) if (products.value[i].id === id) return products.value[i]; return null; }
function findTmpl(id) { var n = Number(id); for (var i = 0; i < tmpls.value.length; i++) if (tmpls.value[i].id === n) return tmpls.value[i]; return null; }
function countFilled(t) { var c = 0; for (var i = 0; i < keys.length; i++) if (t.vals[keys[i]]) c++; return c; }
/* ═══ 计算 ═══ */
var prtProd = V.computed(function() { return findProd(prtId.value); });
var prtLabel = function(key) { return getLabel(prtProd.value, key); };
var cardFlds = V.computed(function() { return flds.value.filter(function(f) { return f.show; }); });
var hotTags = V.computed(function() { var s = {}; products.value.forEach(function(p) { flds.value.forEach(function(f) { if (p[f.key]) s[p[f.key]] = 1; }); }); return Object.keys(s).slice(0, 16); });
var filtered = V.computed(function() {
if (!q.value.trim()) return products.value;
var t = q.value.trim().toLowerCase();
return products.value.filter(function(p) { return flds.value.some(function(f) { return (p[f.key] || '').toLowerCase().indexOf(t) > -1; }); });
});
var printTitle = V.computed(function() {
var p = prtProd.value; if (!p) return '打印标签';
for (var i = 0; i < keys.length; i++) { if (p[keys[i]]) return getLabel(p, keys[i]) + ': ' + p[keys[i]]; }
return '打印标签';
});
var hasCustomPS = V.computed(function() { var p = prtProd.value; return p && p.ps && !isDefaultPS(p.ps); });
var pvRows = V.computed(function() {
var rows = [], rid = 0, F = flds.value, pp = prtProd.value || {};
for (var i = 0; i < 6; i++) { if (pv[F[i].key]) rows.push({ id: rid++, t: 's', f: F[i], lbl: getLabel(pp, F[i].key) }); }
if (cm78.value) { if (pv.file7 || pv.file8) rows.push({ id: rid++, t: 'c', f1: F[6], f2: F[7], s1: pv.file7, s2: pv.file8, lbl1: getLabel(pp, 'file7'), lbl2: getLabel(pp, 'file8') }); }
else { if (pv.file7) rows.push({ id: rid++, t: 's', f: F[6], lbl: getLabel(pp, 'file7') }); if (pv.file8) rows.push({ id: rid++, t: 's', f: F[7], lbl: getLabel(pp, 'file8') }); }
if (cm910.value) { if (pv.file9 || pv.file10) rows.push({ id: rid++, t: 'c', f1: F[8], f2: F[9], s1: pv.file9, s2: pv.file10, lbl1: getLabel(pp, 'file9'), lbl2: getLabel(pp, 'file10') }); }
else { if (pv.file9) rows.push({ id: rid++, t: 's', f: F[8], lbl: getLabel(pp, 'file9') }); if (pv.file10) rows.push({ id: rid++, t: 's', f: F[9], lbl: getLabel(pp, 'file10') }); }
if (cm1112.value) { if (pv.file11 || pv.file12) rows.push({ id: rid++, t: 'c', f1: F[10], f2: F[11], s1: pv.file11, s2: pv.file12, lbl1: getLabel(pp, 'file11'), lbl2: getLabel(pp, 'file12') }); }
else { if (pv.file11) rows.push({ id: rid++, t: 's', f: F[10], lbl: getLabel(pp, 'file11') }); if (pv.file12) rows.push({ id: rid++, t: 's', f: F[11], lbl: getLabel(pp, 'file12') }); }
return rows;
});
function hl(text) {
if (!q.value.trim() || !text) return escH(text || '---');
var esc = q.value.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var safe = escH(text);
var safeQ = escH(q.value.trim()).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return safe.replace(new RegExp('(' + safeQ + ')', 'gi'), '<span class="hl">$1</span>');
}
/* ═══ 模板 ═══ */
function saveAsTmpl(p) {
var suggest = '';
for (var i = 0; i < keys.length; i++) { if (p[keys[i]]) { suggest = p[keys[i]]; break; } }
var name = prompt('输入模板名称:', suggest || '');
if (!name || !name.trim()) return;
var vals = {};
for (var i = 0; i < keys.length; i++) vals[keys[i]] = p[keys[i]] || '';
tmpls.value.push({ id: Date.now(), name: name.trim(), vals: vals, labels: p.labels ? cloneDeep(p.labels) : {}, ps: p.ps ? cloneDeep(p.ps) : mkPS() });
saveTmpls();
}
function saveFormAsTmpl() {
var name = prompt('输入模板名称:');
if (!name || !name.trim()) return;
var vals = {};
for (var i = 0; i < keys.length; i++) vals[keys[i]] = form[keys[i]] || '';
tmpls.value.push({ id: Date.now(), name: name.trim(), vals: vals, labels: {}, ps: mkPS() });
saveTmpls();
}
function onTmplSel() {
if (!selTmpl.value) return;
var t = findTmpl(selTmpl.value);
if (!t) return;
for (var i = 0; i < keys.length; i++) form[keys[i]] = t.vals[keys[i]] || '';
}
function renameTmpl(t) {
var name = prompt('重命名模板:', t.name);
if (!name || !name.trim()) return;
t.name = name.trim(); saveTmpls();
}
function delTmpl(t) {
if (!confirm('删除模板「' + t.name + '」?')) return;
for (var i = 0; i < tmpls.value.length; i++) { if (tmpls.value[i].id === t.id) { tmpls.value.splice(i, 1); break; } }
saveTmpls();
}
/* ═══ 录入 ═══ */
function openAdd() { for (var i = 0; i < keys.length; i++) form[keys[i]] = ''; selTmpl.value = ''; dlgAdd.value = true; }
function addP(cont) {
var has = false;
for (var i = 0; i < keys.length; i++) { if (form[keys[i]] && form[keys[i]].trim()) { has = true; break; } }
if (!has) return;
var row = { id: Date.now(), ps: mkPS(), labels: {} };
for (var i = 0; i < keys.length; i++) row[keys[i]] = form[keys[i]];
if (selTmpl.value) {
var t = findTmpl(selTmpl.value);
if (t) { if (t.labels) row.labels = cloneDeep(t.labels); if (t.ps) row.ps = cloneDeep(t.ps); }
}
products.value.push(row); saveProd();
for (var i = 0; i < keys.length; i++) form[keys[i]] = '';
selTmpl.value = '';
if (!cont) dlgAdd.value = false;
}
function delP(p) {
for (var i = 0; i < products.value.length; i++) { if (products.value[i].id === p.id) { products.value.splice(i, 1); saveProd(); return; } }
}
/* ═══ 编辑 ═══ */
function openEdit(p) {
editId.value = p.id;
for (var i = 0; i < keys.length; i++) { editLabels[keys[i]] = (p.labels && p.labels[keys[i]]) || ''; editVals[keys[i]] = p[keys[i]] || ''; }
dlgEdit.value = true;
}
function saveEdit() {
var p = findProd(editId.value); if (!p) return;
if (!p.labels) p.labels = {};
for (var i = 0; i < keys.length; i++) {
p[keys[i]] = editVals[keys[i]];
var lb = editLabels[keys[i]];
if (lb && lb.trim()) p.labels[keys[i]] = lb.trim(); else delete p.labels[keys[i]];
}
saveProd(); dlgEdit.value = false;
}
function rstLabels() { for (var i = 0; i < keys.length; i++) editLabels[keys[i]] = ''; }
/* ═══ 打印 ═══ */
function loadPS(ps) {
var s = ps || mkPS();
for (var i = 0; i < keys.length; i++) pv[keys[i]] = s.vis[keys[i]] !== undefined ? !!s.vis[keys[i]] : true;
cm78.value = !!s.comb78; cm910.value = !!s.comb910; cm1112.value = !!s.comb1112;
useWm.value = !!s.useWm; wmOp.value = s.wmOp || 12; wmFld.value = s.wmFld || 'file1'; prtCnt.value = s.prtCnt || 1;
}
function savePS() {
var p = findProd(prtId.value); if (!p) return;
var vis = {}; for (var i = 0; i < keys.length; i++) vis[keys[i]] = !!pv[keys[i]];
p.ps = { vis: vis, comb78: cm78.value, comb910: cm910.value, comb1112: cm1112.value, useWm: useWm.value, wmOp: wmOp.value, wmFld: wmFld.value, prtCnt: prtCnt.value };
saveProd();
}
function openPrt(p) {
prtId.value = p.id;
for (var i = 0; i < keys.length; i++) pi[keys[i]] = p[keys[i]] || '';
loadPS(p.ps); dlgPrt.value = true;
}
function rstPS() { loadPS(null); }
function doPrt() {
savePS();
var rows = pvRows.value;
if (!rows.length) { alert('请至少勾选一个字段'); return; }
var alpha = wmOp.value / 100, wmText = escPrint(pi[wmFld.value] || '');
var pagesHtml = '';
for (var n = 0; n < prtCnt.value; n++) {
var wmDiv = useWm.value ? '<div class="wm"><span style="font-size:28px;color:rgba(0,0,0,' + alpha + ')">' + wmText + '</span></div>' : '';
var linesHtml = '';
for (var ri = 0; ri < rows.length; ri++) {
var r = rows[ri];
if (r.t === 's') {
linesHtml += '<div class="lr">' + escPrint(r.lbl) + ': ' + escPrint(pi[r.f.key] || '') + '</div>';
} else {
var p1 = r.s1 ? escPrint(r.lbl1) + ': ' + escPrint(pi[r.f1.key] || '') : '';
var p2 = r.s2 ? escPrint(r.lbl2) + ': ' + escPrint(pi[r.f2.key] || '') : '';
linesHtml += '<div class="lr">' + p1 + (p1 && p2 ? ' ' : '') + p2 + '</div>';
}
}
pagesHtml += '<div class="page">' + wmDiv + linesHtml + '</div>';
}
var css = '*{margin:0;padding:0;box-sizing:border-box}body{font-family:Arial,sans-serif;font-weight:700}.page{width:297mm;height:210mm;background:#fff;padding:15mm 25mm 15mm 15mm;page-break-after:always;display:flex;flex-direction:column;justify-content:center;position:relative;overflow:hidden}.wm{position:absolute;right:0;top:0;bottom:0;width:18mm;display:flex;align-items:center;justify-content:center;pointer-events:none;border-left:1px dashed rgba(0,0,0,.15)}.wm span{writing-mode:vertical-rl;transform:rotate(180deg);font-weight:800;white-space:pre;text-transform:uppercase;letter-spacing:4px}.lr{font-weight:700;text-transform:uppercase;white-space:pre;letter-spacing:1px;margin-bottom:3mm;font-size:80px;line-height:1.2;transform-origin:left center;position:relative;z-index:2}@page{size:A4 landscape;margin:0}';
var ifm = document.createElement('iframe');
ifm.style.cssText = 'position:absolute!important;top:-9999px!important;left:-9999px!important;width:800px!important;height:600px!important;border:none!important;';
document.body.appendChild(ifm);
var doc = ifm.contentDocument || ifm.contentWindow.document;
doc.open(); doc.write('<!DOCTYPE html><html><head><meta charset="UTF-8"><style>' + css + '</style></head><body>' + pagesHtml + '</body></html>'); doc.close();
setTimeout(function() {
try {
var elems = doc.querySelectorAll('.lr'), cnt = elems.length;
if (cnt > 6) { var fs = Math.max(24, Math.round(500 / cnt)); for (var i = 0; i < cnt; i++) elems[i].style.fontSize = fs + 'px'; }
for (var i = 0; i < cnt; i++) { var maxW = 237 * 3.78; if (elems[i].scrollWidth > maxW) elems[i].style.transform = 'scaleX(' + (maxW / elems[i].scrollWidth) + ')'; }
ifm.contentWindow.focus(); ifm.contentWindow.print();
} catch (err) { alert('打印出错: ' + err.message); }
setTimeout(function() { if (ifm.parentNode) ifm.parentNode.removeChild(ifm); }, 2000);
}, 500);
}
/* ═══ 全局配置 ═══ */
function openCfg() { dlgCfg.value = true; }
function savCfg() { for (var i = 0; i < keys.length; i++) if (!(keys[i] in form)) form[keys[i]] = ''; try { localStorage.setItem('lbl_flds', JSON.stringify(flds.value)); } catch(e) {} dlgCfg.value = false; }
function rstCfg() { flds.value = mkDefs(); }
/* ═══ 持久化 ═══ */
function saveProd() { try { localStorage.setItem('lbl_prods', JSON.stringify(products.value)); } catch(e) {} }
function saveTmpls() { try { localStorage.setItem('lbl_tmpls', JSON.stringify(tmpls.value)); } catch(e) {} }
function loadAll() {
try { var s = localStorage.getItem('lbl_flds'); if (s) { var saved = JSON.parse(s); if (Array.isArray(saved) && saved.length) { flds.value = mkDefs().map(function(d) { var f = null; for (var i = 0; i < saved.length; i++) if (saved[i].key === d.key) { f = saved[i]; break; } return f ? { key: d.key, label: f.label || d.label, ph: f.ph || '', show: f.show !== undefined ? f.show : true } : d; }); }}} catch(e) {}
for (var i = 0; i < flds.value.length; i++) if (!(flds.value[i].key in form)) form[flds.value[i].key] = '';
try { var st = localStorage.getItem('lbl_tmpls'); if (st) tmpls.value = JSON.parse(st); } catch(e) {}
try {
var s2 = localStorage.getItem('lbl_prods');
if (s2) { products.value = JSON.parse(s2); for (var i = 0; i < products.value.length; i++) { if (!products.value[i].ps) products.value[i].ps = mkPS(); if (!products.value[i].labels) products.value[i].labels = {}; } return; }
var oks = ['label_products_v3', 'label_app_v2', 'label_app'];
for (var oi = 0; oi < oks.length; oi++) { var raw = localStorage.getItem(oks[oi]); if (raw) { var data = JSON.parse(raw); if (Array.isArray(data)) {
var km = { sku:'file1',category:'file2',style:'file3',color:'file4',size:'file5',qty:'file6',boxNo:'file7',boxTotal:'file8' }, kmK = Object.keys(km);
products.value = data.map(function(item, idx) { var n = { id: item.id || Date.now() + idx, ps: mkPS(), labels: {} }; for (var j = 1; j <= 12; j++) n['file' + j] = item['file' + j] || ''; for (var j = 0; j < kmK.length; j++) if (item[kmK[j]] !== undefined && !n[km[kmK[j]]]) n[km[kmK[j]]] = item[kmK[j]]; return n; });
saveProd(); return;
}}}
} catch(e) {}
}
loadAll();
/* ═══ 导入导出 ═══ */
function doExport() {
if (!products.value.length) return;
var blob = new Blob([JSON.stringify({ version: 3, fields: flds.value, products: products.value, templates: tmpls.value }, null, 2)], { type: 'application/json' });
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'products_' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
function doImportBtn() { fiRef.value.click(); }
function onFile(e) {
var file = e.target.files[0]; if (!file) return;
var reader = new FileReader(); reader.onload = function(ev) {
try {
var d = JSON.parse(ev.target.result);
if (d.fields && d.products && Array.isArray(d.products)) {
flds.value = mkDefs().map(function(df) { var f = null; for (var i = 0; i < d.fields.length; i++) if (d.fields[i].key === df.key) { f = d.fields[i]; break; } return f ? { key: df.key, label: f.label || df.label, ph: f.ph || '', show: f.show !== undefined ? f.show : true } : df; });
try { localStorage.setItem('lbl_flds', JSON.stringify(flds.value)); } catch(ex) {}
if (d.templates && Array.isArray(d.templates)) { for (var i = 0; i < d.templates.length; i++) tmpls.value.push(d.templates[i]); saveTmpls(); }
var mx = 0; for (var i = 0; i < products.value.length; i++) if (products.value[i].id > mx) mx = products.value[i].id;
for (var i = 0; i < d.products.length; i++) { d.products[i].id = mx + i + 1; if (!d.products[i].ps) d.products[i].ps = mkPS(); if (!d.products[i].labels) d.products[i].labels = {}; }
products.value = products.value.concat(d.products);
} else if (Array.isArray(d)) {
var mx2 = 0; for (var i = 0; i < products.value.length; i++) if (products.value[i].id > mx2) mx2 = products.value[i].id;
for (var i = 0; i < d.length; i++) { d[i].id = mx2 + i + 1; if (!d[i].ps) d[i].ps = mkPS(); if (!d[i].labels) d[i].labels = {}; }
products.value = products.value.concat(d);
} else throw 0;
saveProd();
} catch(er) { alert('JSON 格式错误'); }
};
reader.readAsText(file); e.target.value = '';
}
function doClear() { if (confirm('确定清除所有产品数据?')) { products.value = []; saveProd(); } }
return {
flds, products, tmpls, filtered, hotTags, cardFlds, pvRows, printTitle, hasCustomPS, prtProd,
dlgAdd, dlgEdit, dlgPrt, dlgCfg, dlgTmpl,
prtCnt, useWm, wmOp, wmFld, cm78, cm910, cm1112, selTmpl,
q, pi, form, pv, editLabels, editVals, fiRef,
getLabel, hasCustom, prtLabel, hl, displayVal, countFilled,
openAdd, addP, delP, openEdit, saveEdit, rstLabels,
openPrt, doPrt, rstPS,
openCfg, savCfg, rstCfg,
saveAsTmpl, saveFormAsTmpl, onTmplSel, renameTmpl, delTmpl,
doExport, doImportBtn, onFile, doClear
};
}
}).mount('#app');
</script>
</body>
</html>