367 lines
8.8 KiB
Vue
367 lines
8.8 KiB
Vue
<template>
|
||
<div v-can="permissionCodes.qrCode.create" class="qr-page">
|
||
<t-form class="form" label-align="left" v-entrance-transition>
|
||
<t-form-item label="编码前缀">
|
||
<t-input v-model.trim="formModel.prefix" placeholder="如 GAO" borderless />
|
||
</t-form-item>
|
||
<t-form-item label="起始编号">
|
||
<t-input
|
||
:model-value="formModel.start"
|
||
type="number"
|
||
inputmode="numeric"
|
||
borderless
|
||
@change="value => handleNumberChange('start', value)"
|
||
/>
|
||
</t-form-item>
|
||
<t-form-item label="编号位数" contentAlign="right">
|
||
<t-stepper
|
||
v-model="formModel.digits"
|
||
:min="startMinDigits"
|
||
:max="maxDigits"
|
||
default-value="3"
|
||
theme="outline"
|
||
/>
|
||
</t-form-item>
|
||
<t-form-item label="生成数量">
|
||
<t-input
|
||
:model-value="formModel.quantity"
|
||
type="number"
|
||
inputmode="numeric"
|
||
borderless
|
||
@change="value => handleNumberChange('quantity', value)"
|
||
/>
|
||
</t-form-item>
|
||
</t-form>
|
||
<t-cell-group theme="card" v-entrance-transition="70">
|
||
<t-cell title="最终起始编码" :note="finalStartCode" />
|
||
<t-cell title="最终结束编码" :note="finalEndCode" />
|
||
</t-cell-group>
|
||
<div class="actions" v-entrance-transition="140">
|
||
<t-button
|
||
class="bg-white"
|
||
theme="primary"
|
||
variant="outline"
|
||
block
|
||
:loading="printing"
|
||
@click="printQrCode"
|
||
>打印</t-button>
|
||
<t-button
|
||
theme="primary"
|
||
block
|
||
:loading="downloading"
|
||
@click="downloadImages"
|
||
>
|
||
<span v-text="downloadText" />
|
||
</t-button>
|
||
</div>
|
||
<div class="actions orange tips" v-entrance-transition="140">微信内可能无法发起打印或批量下载,建议使用默认浏览器打开</div>
|
||
<section class="preview" v-entrance-transition="210">
|
||
<div class="header">
|
||
<h2 class="title">预览</h2>
|
||
<p class="tips" v-text="previewSummary" />
|
||
</div>
|
||
<div class="paper" :style="paperStyle">
|
||
<div
|
||
class="item"
|
||
v-for="item in visiblePreviewPages[0].codes"
|
||
:key="item"
|
||
>
|
||
<div class="qr-code" />
|
||
<div class="text-mark bg-gray" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Message } from "@/utils/message";
|
||
import { calculateQrCodeGrid, downloadQrCodeImages, printQrCodeImages, splitQrCodePages } from "@/utils/qrCode";
|
||
import { permissionCodes } from "@/utils/permission";
|
||
|
||
defineOptions({
|
||
name: "CustomerQrCode"
|
||
});
|
||
|
||
interface QrCodeFormModel {
|
||
prefix: string;
|
||
start: string;
|
||
digits: number;
|
||
quantity: string;
|
||
}
|
||
|
||
const minDigits = 1;
|
||
const maxDigits = 16;
|
||
const defaultDigits = 3;
|
||
const storageKey = "management-qr-code-config";
|
||
|
||
const formModel = reactive<QrCodeFormModel>({
|
||
prefix: "GAO",
|
||
start: "1",
|
||
digits: defaultDigits,
|
||
quantity: "22"
|
||
});
|
||
const downloading = ref(false);
|
||
const printing = ref(false);
|
||
|
||
const codeList = computed(() => {
|
||
const start = Number(formModel.start || 0);
|
||
const digits = effectiveDigits.value;
|
||
const quantity = Number(formModel.quantity || 1);
|
||
|
||
return Array.from({ length: quantity }, (_, index) => {
|
||
const codeNumber = String(start + index).padStart(digits, "0");
|
||
return `${formModel.prefix}${codeNumber}`;
|
||
});
|
||
});
|
||
const startMinDigits = computed(() => Math.max(minDigits, Math.min(maxDigits, normalizeNumber(formModel.start).length || minDigits)));
|
||
const effectiveDigits = computed(() => Math.max(startMinDigits.value, Math.min(maxDigits, formModel.digits || defaultDigits)));
|
||
const maxCodeLength = computed(() => formModel.prefix.length + effectiveDigits.value);
|
||
const previewPages = computed(() => splitQrCodePages(codeList.value, maxCodeLength.value));
|
||
const visiblePreviewPages = computed(() => previewPages.value.slice(0, 1));
|
||
const paperGrid = computed(() => calculateQrCodeGrid(maxCodeLength.value));
|
||
const paperStyle = computed(() => ({
|
||
gridTemplateRows: `repeat(${paperGrid.value.rows}, minmax(0, 1fr))`,
|
||
gridTemplateColumns: `repeat(${paperGrid.value.columns}, minmax(0, 1fr))`
|
||
}));
|
||
const paperCount = computed(() => previewPages.value.length);
|
||
const finalStartCode = computed(() => codeList.value[0] || "");
|
||
const finalEndCode = computed(() => codeList.value[codeList.value.length - 1] || "");
|
||
const downloadText = computed(() => `下载图片(${paperCount.value} 张)`);
|
||
const previewSummary = computed(() => `共 ${paperCount.value} 张 A4,每张最多 ${paperGrid.value.pageSize} 枚,${paperGrid.value.columns} × ${paperGrid.value.rows} 网格`);
|
||
|
||
function normalizeNumber(value: string | number | undefined): string {
|
||
return String(value ?? "").replace(/\D/g, "");
|
||
}
|
||
|
||
function normalizeDigits(value: string | number | undefined, minValue = minDigits): number {
|
||
const nextValue = Number(normalizeNumber(value));
|
||
const validValue = Number.isFinite(nextValue) && 0 < nextValue ? nextValue : defaultDigits;
|
||
return Math.max(minValue, Math.min(maxDigits, validValue));
|
||
}
|
||
|
||
function loadLocalConfig(): void {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
const cache = window.localStorage.getItem(storageKey);
|
||
if (!cache) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const config = JSON.parse(cache) as Partial<QrCodeFormModel>;
|
||
const start = normalizeNumber(config.start);
|
||
const startDigits = Math.max(minDigits, Math.min(maxDigits, start.length || minDigits));
|
||
|
||
formModel.prefix = String(config.prefix ?? formModel.prefix);
|
||
formModel.start = start;
|
||
formModel.digits = normalizeDigits(config.digits, startDigits);
|
||
formModel.quantity = normalizeNumber(config.quantity) || formModel.quantity;
|
||
} catch {
|
||
window.localStorage.removeItem(storageKey);
|
||
}
|
||
}
|
||
|
||
function saveLocalConfig(): void {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
|
||
window.localStorage.setItem(storageKey, JSON.stringify({
|
||
prefix: formModel.prefix,
|
||
start: formModel.start,
|
||
digits: formModel.digits,
|
||
quantity: formModel.quantity
|
||
}));
|
||
}
|
||
|
||
function handleNumberChange(key: "start" | "quantity", value: string | number | undefined): void {
|
||
const nextValue = normalizeNumber(value);
|
||
formModel[key] = nextValue;
|
||
}
|
||
|
||
loadLocalConfig();
|
||
|
||
watch(startMinDigits, value => {
|
||
if (formModel.digits < value) {
|
||
formModel.digits = value;
|
||
}
|
||
});
|
||
|
||
watch(formModel, () => {
|
||
saveLocalConfig();
|
||
}, {
|
||
deep: true
|
||
});
|
||
|
||
async function downloadImages(): Promise<void> {
|
||
if (downloading.value) {
|
||
return;
|
||
}
|
||
|
||
downloading.value = true;
|
||
|
||
try {
|
||
await downloadQrCodeImages({
|
||
codes: codeList.value,
|
||
filePrefix: `${finalStartCode.value}-${finalEndCode.value}`,
|
||
maxCodeLength: maxCodeLength.value
|
||
});
|
||
} finally {
|
||
downloading.value = false;
|
||
}
|
||
}
|
||
|
||
async function printQrCode(): Promise<void> {
|
||
if (printing.value) {
|
||
return;
|
||
}
|
||
|
||
printing.value = true;
|
||
try {
|
||
await printQrCodeImages({
|
||
codes: codeList.value,
|
||
maxCodeLength: maxCodeLength.value,
|
||
title: `${finalStartCode.value}-${finalEndCode.value}`
|
||
});
|
||
} catch (err) {
|
||
Message.warning(err instanceof Error ? err.message : "打印失败");
|
||
} finally {
|
||
printing.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.qr-page {
|
||
gap: 1rem;
|
||
min-height: 100vh;
|
||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.form {
|
||
padding: .5rem 0;
|
||
background: rgba(255, 255, 255, .92);
|
||
box-shadow: var(--td-shadow-1);
|
||
border-radius: 1.4rem;
|
||
|
||
:deep(.t-input__control) {
|
||
text-align: right;
|
||
}
|
||
}
|
||
|
||
.actions {
|
||
--td-button-border-radius: 1rem;
|
||
|
||
&.tips {
|
||
font-size: .875rem;
|
||
}
|
||
|
||
gap: .75rem;
|
||
display: flex;
|
||
margin: 0 1rem;
|
||
}
|
||
|
||
.preview {
|
||
padding: 0 1rem;
|
||
|
||
.header {
|
||
margin: 0 0 .75rem 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
|
||
.title {
|
||
color: #0f172a;
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.tips {
|
||
color: #52607a;
|
||
margin: 0;
|
||
font-size: .8125rem;
|
||
}
|
||
}
|
||
|
||
.paper {
|
||
gap: 0;
|
||
width: min(100%, 28rem);
|
||
padding: .75rem;
|
||
display: grid;
|
||
margin: 0 auto;
|
||
aspect-ratio: 210 / 297;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .2);
|
||
|
||
.item {
|
||
gap: .1875rem;
|
||
border: 1px dashed #cbd5e1;
|
||
padding: .25rem;
|
||
display: flex;
|
||
min-width: 0;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
|
||
.qr-code {
|
||
width: 58%;
|
||
background: repeating-linear-gradient(45deg, #000 0 .125rem, #000 .125rem .25rem, #fff .25rem .375rem, #fff .375rem .5rem);
|
||
aspect-ratio: 1;
|
||
}
|
||
|
||
.text-mark {
|
||
width: 50%;
|
||
height: 3px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
:global(html.theme-dark) {
|
||
.qr-page {
|
||
.form {
|
||
background: rgba(15, 23, 42, .88);
|
||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||
}
|
||
|
||
.code-range {
|
||
div {
|
||
background: rgba(15, 23, 42, .88);
|
||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||
}
|
||
|
||
span {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
strong {
|
||
color: #e2e8f0;
|
||
}
|
||
}
|
||
|
||
.preview-head {
|
||
h2 {
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
p {
|
||
color: #94a3b8;
|
||
}
|
||
}
|
||
|
||
.paper {
|
||
background: #f8fafc;
|
||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|