Remove unused code display form widgets

This commit is contained in:
2026-06-09 08:52:55 +08:00
parent 959624240f
commit ad040ac6f9
21 changed files with 27 additions and 5977 deletions
@@ -1,315 +0,0 @@
<script setup lang="ts">
import type { BarcodeGeneratorEmits, BarcodeGeneratorProps } from './types';
import { computed, nextTick, ref, watch } from 'vue';
import { Barcode, Copy, Download } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
import JsBarcode from 'jsbarcode';
import { useCodeContent } from '../shared/useCodeContent';
import {
getBarcodeValidationErrorKey,
validateBarcodeContent,
} from '../shared/validateBarcode';
defineOptions({
name: 'BarcodeGenerator',
inheritAttrs: false,
});
const props = withDefaults(defineProps<BarcodeGeneratorProps>(), {
dataSource: 'static',
format: 'code128',
height: 80,
width: 2,
margin: 10,
displayValue: true,
lineColor: '#000000',
backgroundColor: '#FFFFFF',
showContent: false,
enableDownload: false,
enableCopy: false,
downloadFilename: 'barcode',
disabled: false,
readonly: false,
placeholder: '',
});
const emit = defineEmits<BarcodeGeneratorEmits>();
const barcodeUrl = ref<string>('');
const isGenerating = ref(false);
const svgRef = ref<SVGElement>();
const contentOptions = computed(() => ({
dataSource: props.dataSource,
modelValue: props.modelValue,
boundField: props.boundField,
formula: props.formula,
formData: props.formData,
contentType: 'barcode' as const,
}));
const barcodeContent = useCodeContent(contentOptions);
const isValid = computed(() =>
validateBarcodeContent(props.format ?? 'code128', barcodeContent.value),
);
const invalidMessageKey = computed(() =>
getBarcodeValidationErrorKey(props.format ?? 'code128', barcodeContent.value),
);
function generateBarcode() {
const content = barcodeContent.value;
if (!content || !isValid.value) {
barcodeUrl.value = '';
return;
}
isGenerating.value = true;
try {
const svg = svgRef.value;
if (!svg) return;
JsBarcode(svg, content, {
format: props.format,
height: props.height,
width: props.width,
margin: props.margin,
displayValue: props.displayValue,
lineColor: props.lineColor,
background: props.backgroundColor,
font: 'monospace',
fontSize: 14,
});
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svg);
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
barcodeUrl.value = URL.createObjectURL(blob);
emit('generated', content);
} catch (error) {
console.warn('Generate barcode failed:', error);
barcodeUrl.value = '';
} finally {
isGenerating.value = false;
}
}
function downloadBarcode() {
if (!barcodeUrl.value) return;
const link = document.createElement('a');
link.download = `${props.downloadFilename}.svg`;
link.href = barcodeUrl.value;
link.click();
emit('downloaded');
ElMessage.success($t('form-design.barcode.downloadSuccess'));
}
async function copyContent() {
const content = barcodeContent.value;
if (!content) return;
try {
await navigator.clipboard.writeText(content);
emit('copied');
ElMessage.success($t('form-design.barcode.copySuccess'));
} catch (error) {
console.error('Copy failed:', error);
ElMessage.error($t('form-design.barcode.copyError'));
}
}
const placeholderText = computed(
() => props.placeholder || $t('form-design.barcode.placeholder'),
);
watch(
() => [
barcodeContent.value,
props.format,
props.height,
props.width,
props.margin,
props.displayValue,
props.lineColor,
props.backgroundColor,
],
() => {
nextTick(() => {
generateBarcode();
});
},
{ immediate: true },
);
</script>
<template>
<div class="barcode-generator">
<div
class="barcode-generator__container"
:class="{
'barcode-generator__container--empty': !barcodeContent || !isValid,
'barcode-generator__container--disabled': disabled,
}"
:style="{ backgroundColor }"
>
<svg ref="svgRef" style="display: none"></svg>
<img
v-if="barcodeUrl && isValid"
:src="barcodeUrl"
alt="Barcode"
class="barcode-generator__image"
/>
<div v-else class="barcode-generator__placeholder">
<Barcode class="barcode-generator__placeholder-icon" />
<span class="barcode-generator__placeholder-text">{{
invalidMessageKey
? $t(`form-design.barcode.${invalidMessageKey}`)
: placeholderText
}}</span>
</div>
<div v-if="isGenerating" class="barcode-generator__loading">
<div class="barcode-generator__spinner"></div>
</div>
</div>
<div v-if="showContent && barcodeContent" class="barcode-generator__content">
<ElTooltip :content="barcodeContent" placement="top" :show-after="300">
<span class="barcode-generator__content-text">{{ barcodeContent }}</span>
</ElTooltip>
</div>
<div
v-if="(enableDownload || enableCopy) && barcodeUrl && !disabled && isValid"
class="barcode-generator__actions"
>
<ElButton
v-if="enableDownload"
type="primary"
size="small"
:icon="Download"
@click="downloadBarcode"
>
{{ $t('form-design.barcode.download') }}
</ElButton>
<ElButton
v-if="enableCopy"
size="small"
:icon="Copy"
@click="copyContent"
>
{{ $t('form-design.barcode.copy') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.barcode-generator {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.barcode-generator__container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 80px;
padding: 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
}
.barcode-generator__container--empty {
border-style: dashed;
}
.barcode-generator__container--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.barcode-generator__image {
display: block;
max-width: 100%;
height: auto;
}
.barcode-generator__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--el-text-color-placeholder);
}
.barcode-generator__placeholder-icon {
width: 48px;
height: 48px;
opacity: 0.5;
}
.barcode-generator__placeholder-text {
font-size: 12px;
text-align: center;
max-width: 80%;
}
.barcode-generator__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: color-mix(in srgb, var(--el-bg-color) 80%, transparent);
}
.barcode-generator__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--el-border-color);
border-top-color: var(--el-color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.barcode-generator__content {
max-width: 100%;
padding: 0 8px;
}
.barcode-generator__content-text {
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.barcode-generator__actions {
display: flex;
gap: 8px;
}
</style>
@@ -1,2 +0,0 @@
export { default as BarcodeGenerator } from './barcode-generator.vue';
export type * from './types';
@@ -1,23 +0,0 @@
import type {
BarcodeFormat,
CodeDisplayBaseProps,
} from '../shared/types';
export interface BarcodeGeneratorProps extends CodeDisplayBaseProps {
modelValue?: null | string;
format?: BarcodeFormat;
height?: number;
width?: number;
margin?: number;
displayValue?: boolean;
lineColor?: string;
backgroundColor?: string;
}
export interface BarcodeGeneratorEmits {
(e: 'update:modelValue', value: null | string): void;
(e: 'change', value: null | string): void;
(e: 'generated', content: string): void;
(e: 'downloaded'): void;
(e: 'copied'): void;
}
@@ -1,2 +0,0 @@
export { default as QRCodeGenerator } from './qrcode-generator.vue';
export * from './types';
@@ -1,341 +0,0 @@
<script setup lang="ts">
import type { QRCodeGeneratorEmits, QRCodeGeneratorProps } from './types';
import { computed, nextTick, ref, watch } from 'vue';
import { Copy, Download, QrCode } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
import QRCode from 'qrcode';
import { useCodeContent } from '../shared/useCodeContent';
defineOptions({
name: 'QRCodeGenerator',
inheritAttrs: false,
});
const props = withDefaults(defineProps<QRCodeGeneratorProps>(), {
dataSource: 'static',
qrcodeType: 'text',
size: 200,
errorCorrectionLevel: 'M',
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
logoSize: 40,
margin: 2,
showContent: false,
enableDownload: false,
enableCopy: false,
downloadFilename: 'qrcode',
disabled: false,
readonly: false,
placeholder: '',
});
const emit = defineEmits<QRCodeGeneratorEmits>();
const qrcodeUrl = ref<string>('');
const isGenerating = ref(false);
const canvasRef = ref<HTMLCanvasElement>();
const contentOptions = computed(() => ({
dataSource: props.dataSource,
modelValue: props.modelValue,
boundField: props.boundField,
formula: props.formula,
formData: props.formData,
contentType: props.qrcodeType,
vcardInfo: props.vcardInfo,
wifiInfo: props.wifiInfo,
}));
const qrcodeContent = useCodeContent(contentOptions);
async function generateQRCode() {
const content = qrcodeContent.value;
if (!content) {
qrcodeUrl.value = '';
return;
}
isGenerating.value = true;
try {
const canvas = canvasRef.value;
if (!canvas) return;
await QRCode.toCanvas(canvas, content, {
width: props.size,
margin: props.margin,
errorCorrectionLevel: props.errorCorrectionLevel,
color: {
dark: props.foregroundColor,
light: props.backgroundColor,
},
});
if (props.logoUrl) {
await drawLogo(canvas, props.logoUrl);
}
qrcodeUrl.value = canvas.toDataURL('image/png');
emit('generated', content);
} catch (error) {
console.error('Generate QRCode failed:', error);
ElMessage.error($t('form-design.qrcode.generateError'));
} finally {
isGenerating.value = false;
}
}
async function drawLogo(
canvas: HTMLCanvasElement,
logoUrl: string,
): Promise<void> {
return new Promise((resolve) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve();
return;
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.addEventListener('load', () => {
const logoSize = props.logoSize;
const x = (canvas.width - logoSize) / 2;
const y = (canvas.height - logoSize) / 2;
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(x - 2, y - 2, logoSize + 4, logoSize + 4);
ctx.drawImage(img, x, y, logoSize, logoSize);
resolve();
});
img.onerror = () => {
console.warn('Load logo failed');
resolve();
};
img.src = logoUrl;
});
}
function downloadQRCode() {
if (!qrcodeUrl.value) return;
const link = document.createElement('a');
link.download = `${props.downloadFilename}.png`;
link.href = qrcodeUrl.value;
link.click();
emit('downloaded');
ElMessage.success($t('form-design.qrcode.downloadSuccess'));
}
async function copyContent() {
const content = qrcodeContent.value;
if (!content) return;
try {
await navigator.clipboard.writeText(content);
emit('copied');
ElMessage.success($t('form-design.qrcode.copySuccess'));
} catch (error) {
console.error('Copy failed:', error);
ElMessage.error($t('form-design.qrcode.copyError'));
}
}
const placeholderText = computed(
() => props.placeholder || $t('form-design.qrcode.placeholder'),
);
const containerStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
backgroundColor: props.backgroundColor,
}));
watch(
() => [
qrcodeContent.value,
props.size,
props.foregroundColor,
props.backgroundColor,
props.errorCorrectionLevel,
props.margin,
props.logoUrl,
props.logoSize,
],
() => {
nextTick(() => {
generateQRCode();
});
},
{ immediate: true },
);
</script>
<template>
<div class="qrcode-generator">
<div
class="qrcode-generator__container"
:class="{
'qrcode-generator__container--empty': !qrcodeContent,
'qrcode-generator__container--disabled': disabled,
}"
:style="containerStyle"
>
<canvas ref="canvasRef" style="display: none"></canvas>
<img
v-if="qrcodeUrl"
:src="qrcodeUrl"
alt="QRCode"
class="qrcode-generator__image"
/>
<div v-else class="qrcode-generator__placeholder">
<QrCode class="qrcode-generator__placeholder-icon" />
<span class="qrcode-generator__placeholder-text">{{
placeholderText
}}</span>
</div>
<div v-if="isGenerating" class="qrcode-generator__loading">
<div class="qrcode-generator__spinner"></div>
</div>
</div>
<div v-if="showContent && qrcodeContent" class="qrcode-generator__content">
<ElTooltip :content="qrcodeContent" placement="top" :show-after="300">
<span class="qrcode-generator__content-text">{{ qrcodeContent }}</span>
</ElTooltip>
</div>
<div
v-if="(enableDownload || enableCopy) && qrcodeUrl && !disabled"
class="qrcode-generator__actions"
>
<ElButton
v-if="enableDownload"
type="primary"
size="small"
:icon="Download"
@click="downloadQRCode"
>
{{ $t('form-design.qrcode.download') }}
</ElButton>
<ElButton
v-if="enableCopy"
size="small"
:icon="Copy"
@click="copyContent"
>
{{ $t('form-design.qrcode.copy') }}
</ElButton>
</div>
</div>
</template>
<style scoped>
.qrcode-generator {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qrcode-generator__container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
}
.qrcode-generator__container--empty {
border-style: dashed;
}
.qrcode-generator__container--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.qrcode-generator__image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.qrcode-generator__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--el-text-color-placeholder);
}
.qrcode-generator__placeholder-icon {
width: 48px;
height: 48px;
opacity: 0.5;
}
.qrcode-generator__placeholder-text {
font-size: 12px;
text-align: center;
max-width: 80%;
}
.qrcode-generator__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: color-mix(in srgb, var(--el-bg-color) 80%, transparent);
}
.qrcode-generator__spinner {
width: 24px;
height: 24px;
border: 2px solid var(--el-border-color);
border-top-color: var(--el-color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.qrcode-generator__content {
max-width: 100%;
padding: 0 8px;
}
.qrcode-generator__content-text {
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.qrcode-generator__actions {
display: flex;
gap: 8px;
}
</style>
@@ -1,33 +0,0 @@
import type {
CodeDisplayBaseProps,
ErrorCorrectionLevel,
QRCodeType,
VCardInfo,
WiFiInfo,
} from '../shared/types';
export type { QRCodeType, ErrorCorrectionLevel, VCardInfo, WiFiInfo };
export type QRCodeDataSource = 'field' | 'formula' | 'static';
export interface QRCodeGeneratorProps extends CodeDisplayBaseProps {
modelValue?: null | string;
qrcodeType?: QRCodeType;
size?: number;
errorCorrectionLevel?: ErrorCorrectionLevel;
foregroundColor?: string;
backgroundColor?: string;
logoUrl?: string;
logoSize?: number;
margin?: number;
vcardInfo?: VCardInfo;
wifiInfo?: WiFiInfo;
}
export interface QRCodeGeneratorEmits {
(e: 'update:modelValue', value: null | string): void;
(e: 'change', value: null | string): void;
(e: 'generated', content: string): void;
(e: 'downloaded'): void;
(e: 'copied'): void;
}
@@ -1,156 +0,0 @@
<script setup lang="ts">
import type { ScanMode } from '../shared/types';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { QrcodeCapture, QrcodeStream } from 'vue-qrcode-reader';
import { ZqDialog } from '#/components/zq-dialog';
const props = withDefaults(
defineProps<{
modelValue?: boolean;
scanMode?: ScanMode;
continuousScan?: boolean;
}>(),
{
modelValue: false,
scanMode: 'all',
continuousScan: false,
},
);
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'scan', value: string): void;
}>();
const lastScanAt = ref(0);
const errorMessage = ref('');
const scanFormats = computed(() => {
switch (props.scanMode) {
case 'barcode': {
return [
'code_128',
'code_39',
'ean_13',
'ean_8',
'upc_a',
'upc_e',
] as const;
}
case 'qr': {
return ['qr_code'] as const;
}
default: {
return [
'qr_code',
'code_128',
'code_39',
'ean_13',
'ean_8',
'upc_a',
'upc_e',
] as const;
}
}
});
function closeDialog() {
emit('update:modelValue', false);
}
function handleDetect(detectedCodes: Array<{ rawValue: string }>) {
const code = detectedCodes[0]?.rawValue;
if (!code) return;
const now = Date.now();
if (now - lastScanAt.value < 800) return;
lastScanAt.value = now;
emit('scan', code);
if (!props.continuousScan) {
closeDialog();
}
}
function handleError(error: Error) {
errorMessage.value = error.message || $t('form-design.scanInput.scanFailed');
}
function handleCapture(detectedCodes: Array<{ rawValue: string }>) {
if (detectedCodes[0]?.rawValue) {
emit('scan', detectedCodes[0].rawValue);
if (!props.continuousScan) {
closeDialog();
}
}
}
</script>
<template>
<ZqDialog
:model-value="modelValue"
:title="$t('form-design.scanInput.scanDialogTitle')"
width="480px"
:show-footer="false"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="scan-dialog">
<QrcodeStream
v-if="modelValue"
class="scan-dialog__stream"
:formats="[...scanFormats]"
@detect="handleDetect"
@error="handleError"
/>
<div v-if="errorMessage" class="scan-dialog__error">
{{ errorMessage }}
</div>
<div class="scan-dialog__fallback">
<span class="scan-dialog__fallback-label">{{
$t('form-design.scanInput.uploadImage')
}}</span>
<QrcodeCapture @detect="handleCapture" />
</div>
</div>
</ZqDialog>
</template>
<style scoped>
.scan-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.scan-dialog__stream {
width: 100%;
max-height: 320px;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
}
.scan-dialog__error {
font-size: 12px;
color: var(--el-color-danger);
}
.scan-dialog__fallback {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.scan-dialog__fallback-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -1,2 +0,0 @@
export { default as ScanInput } from './scan-input.vue';
export type * from './types';
@@ -1,135 +0,0 @@
<script setup lang="ts">
import type { ScanInputEmits, ScanInputProps } from './types';
import { computed, ref } from 'vue';
import { Scan } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage } from 'element-plus';
import ScanDialog from './ScanDialog.vue';
defineOptions({
name: 'ScanInput',
inheritAttrs: false,
});
const props = withDefaults(defineProps<ScanInputProps>(), {
scanMode: 'all',
autoClose: true,
continuousScan: false,
trimValue: true,
uppercase: false,
lowercase: false,
dedupeInterval: 0,
showManualInput: true,
clearable: true,
});
const emit = defineEmits<ScanInputEmits>();
const scanDialogVisible = ref(false);
const lastScanValue = ref('');
const lastScanAt = ref(0);
const isReadonlyInput = computed(
() => props.readonly || !props.showManualInput,
);
function normalizeValue(value: string): string {
let result = props.trimValue ? value.trim() : value;
if (props.uppercase) result = result.toUpperCase();
if (props.lowercase) result = result.toLowerCase();
return result;
}
function validateValue(value: string): boolean {
if (!props.validatePattern) return true;
try {
return new RegExp(props.validatePattern).test(value);
} catch {
return true;
}
}
function updateValue(value: null | string) {
emit('update:modelValue', value);
emit('change', value);
}
function handleInput(value: string) {
updateValue(value);
}
function openScanDialog() {
if (props.disabled || props.readonly) return;
scanDialogVisible.value = true;
}
function handleScan(rawValue: string) {
const value = normalizeValue(rawValue);
if (!value) return;
const now = Date.now();
if (
props.dedupeInterval > 0 &&
value === lastScanValue.value &&
now - lastScanAt.value < props.dedupeInterval
) {
return;
}
if (!validateValue(value)) {
ElMessage.warning($t('form-design.scanInput.validateFailed'));
return;
}
lastScanValue.value = value;
lastScanAt.value = now;
updateValue(value);
emit('scan', value);
ElMessage.success($t('form-design.scanInput.scanSuccess'));
if (props.autoClose && !props.continuousScan) {
scanDialogVisible.value = false;
}
}
</script>
<template>
<div class="scan-input flex w-full items-center gap-2">
<ElInput
v-if="showManualInput"
:model-value="modelValue ?? ''"
class="flex-1"
:placeholder="placeholder"
:disabled="disabled"
:readonly="isReadonlyInput"
:clearable="clearable"
:maxlength="maxlength"
@update:model-value="handleInput"
/>
<ElButton
:icon="Scan"
:disabled="disabled || readonly"
@click="openScanDialog"
>
{{ $t('form-design.scanInput.scanButton') }}
</ElButton>
<ScanDialog
v-model="scanDialogVisible"
:scan-mode="scanMode"
:continuous-scan="continuousScan"
@scan="handleScan"
/>
</div>
</template>
<style scoped>
.scan-input {
width: 100%;
}
</style>
@@ -1,25 +0,0 @@
import type { ScanMode } from '../shared/types';
export interface ScanInputProps {
modelValue?: null | string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
clearable?: boolean;
maxlength?: number;
scanMode?: ScanMode;
autoClose?: boolean;
continuousScan?: boolean;
trimValue?: boolean;
uppercase?: boolean;
lowercase?: boolean;
validatePattern?: string;
dedupeInterval?: number;
showManualInput?: boolean;
}
export interface ScanInputEmits {
(e: 'update:modelValue', value: null | string): void;
(e: 'change', value: null | string): void;
(e: 'scan', value: string): void;
}
@@ -1,127 +0,0 @@
import type { QRCodeType, VCardInfo, WiFiInfo } from './types';
export function parseFormula(
formula: string,
data: Record<string, any>,
): string {
return formula.replaceAll(/\{\{(\w+)\}\}/g, (_, key) => {
return String(data[key] ?? '');
});
}
function escapeVCardValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,');
}
export function formatVCard(info: VCardInfo): string {
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
const fullName = [info.firstName, info.lastName].filter(Boolean).join(' ');
if (fullName) lines.push(`FN:${escapeVCardValue(fullName)}`);
if (info.firstName || info.lastName) {
lines.push(
`N:${escapeVCardValue(info.lastName || '')};${escapeVCardValue(info.firstName || '')};;;`,
);
}
if (info.organization) {
lines.push(`ORG:${escapeVCardValue(info.organization)}`);
}
if (info.title) lines.push(`TITLE:${escapeVCardValue(info.title)}`);
if (info.phone) lines.push(`TEL:${escapeVCardValue(info.phone)}`);
if (info.email) lines.push(`EMAIL:${escapeVCardValue(info.email)}`);
if (info.address) lines.push(`ADR:;;${escapeVCardValue(info.address)};;;;`);
if (info.website) lines.push(`URL:${escapeVCardValue(info.website)}`);
lines.push('END:VCARD');
return lines.join('\n');
}
export function formatWiFi(info: WiFiInfo): string {
const encryption = info.encryption || 'WPA';
const hidden = info.hidden ? 'true' : 'false';
const password = info.password || '';
return `WIFI:T:${encryption};S:${info.ssid};P:${password};H:${hidden};;`;
}
export function formatCodeContent(
contentType: QRCodeType | 'barcode',
content: string,
structured?: { vcardInfo?: VCardInfo; wifiInfo?: WiFiInfo },
): string {
if (!content && contentType !== 'vcard' && contentType !== 'wifi') {
return '';
}
switch (contentType) {
case 'barcode': {
return content;
}
case 'email': {
return `mailto:${content}`;
}
case 'sms': {
return `sms:${content}`;
}
case 'tel': {
return `tel:${content}`;
}
case 'url': {
if (
content &&
!content.startsWith('http://') &&
!content.startsWith('https://')
) {
return `https://${content}`;
}
return content;
}
case 'vcard': {
return structured?.vcardInfo
? formatVCard(structured.vcardInfo)
: content;
}
case 'wifi': {
return structured?.wifiInfo
? formatWiFi(structured.wifiInfo)
: content;
}
default: {
return content;
}
}
}
export function resolveRawContent(options: {
dataSource?: 'field' | 'formula' | 'static';
modelValue?: null | string;
boundField?: string;
formula?: string;
formData?: Record<string, any>;
}): string {
const {
dataSource = 'static',
modelValue,
boundField,
formula,
formData,
} = options;
switch (dataSource) {
case 'field': {
if (boundField && formData) {
return String(formData[boundField] ?? '');
}
return '';
}
case 'formula': {
if (formula && formData) {
return parseFormula(formula, formData);
}
return '';
}
case 'static': {
return modelValue ?? '';
}
default: {
return '';
}
}
}
@@ -1,66 +0,0 @@
export type CodeContentSource = 'field' | 'formula' | 'static';
export type QRCodeType =
| 'email'
| 'sms'
| 'tel'
| 'text'
| 'url'
| 'vcard'
| 'wifi';
export type BarcodeFormat =
| 'code128'
| 'code39'
| 'ean13'
| 'ean8'
| 'upc';
export type ErrorCorrectionLevel = 'H' | 'L' | 'M' | 'Q';
export type ScanMode = 'all' | 'barcode' | 'qr';
/** vCard 联系人信息 */
export interface VCardInfo {
firstName?: string;
lastName?: string;
organization?: string;
title?: string;
phone?: string;
email?: string;
address?: string;
website?: string;
}
/** WiFi 配置信息 */
export interface WiFiInfo {
ssid: string;
password?: string;
encryption?: 'nopass' | 'WEP' | 'WPA';
hidden?: boolean;
}
export interface CodeDisplayBaseProps {
dataSource?: CodeContentSource;
boundField?: string;
formula?: string;
formData?: Record<string, any>;
showContent?: boolean;
enableDownload?: boolean;
enableCopy?: boolean;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
downloadFilename?: string;
}
export interface UseCodeContentOptions {
dataSource?: CodeContentSource;
modelValue?: null | string;
boundField?: string;
formula?: string;
formData?: Record<string, any>;
contentType?: QRCodeType | 'barcode';
vcardInfo?: VCardInfo;
wifiInfo?: WiFiInfo;
}
@@ -1,28 +0,0 @@
import type { UseCodeContentOptions } from './types';
import { computed, type ComputedRef } from 'vue';
import {
formatCodeContent,
resolveRawContent,
} from './formatCodeContent';
export function useCodeContent(
options: ComputedRef<UseCodeContentOptions> | UseCodeContentOptions,
): ComputedRef<string> {
return computed(() => {
const opts = 'value' in options ? options.value : options;
const raw = resolveRawContent({
dataSource: opts.dataSource,
modelValue: opts.modelValue,
boundField: opts.boundField,
formula: opts.formula,
formData: opts.formData,
});
return formatCodeContent(opts.contentType ?? 'text', raw, {
vcardInfo: opts.vcardInfo,
wifiInfo: opts.wifiInfo,
});
});
}
@@ -1,54 +0,0 @@
import type { BarcodeFormat } from './types';
/** CODE128 支持 ASCII 字符(0x00-0x7F */
const CODE128_PATTERN = /^[\u0000-\u007F]+$/;
/** CODE39 支持大写字母、数字及 - . $ / + % 空格 */
const CODE39_PATTERN = /^[0-9A-Z\-.$/+% ]+$/;
export function validateBarcodeContent(
format: BarcodeFormat,
content: string,
): boolean {
if (!content) return false;
switch (format) {
case 'code128': {
return CODE128_PATTERN.test(content);
}
case 'code39': {
return CODE39_PATTERN.test(content);
}
case 'ean13': {
return /^\d{13}$/.test(content);
}
case 'ean8': {
return /^\d{8}$/.test(content);
}
case 'upc': {
return /^\d{12}$/.test(content);
}
default: {
return content.length > 0;
}
}
}
/** 获取校验失败原因,用于 UI 提示 */
export function getBarcodeValidationErrorKey(
format: BarcodeFormat,
content: string,
): 'invalidCharset' | 'invalidFormat' | null {
if (!content) return null;
if (validateBarcodeContent(format, content)) return null;
switch (format) {
case 'code128':
case 'code39': {
return 'invalidCharset';
}
default: {
return 'invalidFormat';
}
}
}
@@ -1,375 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Check, Clock, RefreshCw, Smartphone } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElIcon, ElMessage, ElResult } from 'element-plus';
import QRCode from 'qrcode';
import { checkSignatureStatus, createSignatureToken } from '#/api/core/file';
import { ZqDialog } from '#/components/zq-dialog';
interface Props {
visible: boolean;
source?: string;
}
const props = withDefaults(defineProps<Props>(), {
source: 'form',
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'complete', fileId: string): void;
}>();
// 状态
const qrcodeUrl = ref('');
const callbackKey = ref('');
const expiredAt = ref('');
const isLoading = ref(false);
const isWaiting = ref(false);
const isCompleted = ref(false);
const completedFileId = ref('');
let pollInterval: null | ReturnType<typeof setInterval> = null;
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
});
// 过期时间显示
const expiredTimeText = computed(() => {
if (!expiredAt.value) return '';
const date = new Date(expiredAt.value);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
});
// 生成签名令牌和二维码
async function generateQRCode() {
try {
isLoading.value = true;
// 调用后端API创建签名令牌
const result = await createSignatureToken(props.source, 30);
callbackKey.value = result.callback_key;
expiredAt.value = result.expired_at;
// 构建手机签名页面URL
const baseUrl = window.location.origin;
const signatureUrl = `${baseUrl}/mobile-signature/${result.token}`;
// 生成二维码
qrcodeUrl.value = await QRCode.toDataURL(signatureUrl, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
// 开始轮询检查签名状态
startPolling();
} catch (error) {
console.error('Generate QRCode failed:', error);
ElMessage.error($t('form-design.signaturePad.qrcodeError'));
} finally {
isLoading.value = false;
}
}
// 开始轮询检查签名状态
function startPolling() {
isWaiting.value = true;
// 调用后端API检查签名状态
pollInterval = setInterval(async () => {
if (!callbackKey.value) return;
try {
const result = await checkSignatureStatus(callbackKey.value);
if (result.status === 'completed' && result.file_id) {
completedFileId.value = result.file_id;
isCompleted.value = true;
isWaiting.value = false;
stopPolling();
} else if (result.status === 'expired') {
isWaiting.value = false;
stopPolling();
ElMessage.warning($t('form-design.signaturePad.qrcodeExpired'));
}
} catch (error) {
console.error('Check signature status failed:', error);
}
}, 2000);
}
// 停止轮询
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
// 确认使用签名
function confirmSignature() {
if (completedFileId.value) {
emit('complete', completedFileId.value);
dialogVisible.value = false;
}
}
// 取消
function handleCancel() {
dialogVisible.value = false;
}
// 重新生成二维码
function regenerateQRCode() {
stopPolling();
isCompleted.value = false;
completedFileId.value = '';
qrcodeUrl.value = '';
callbackKey.value = '';
expiredAt.value = '';
generateQRCode();
}
// 弹窗打开时生成二维码
function handleOpened() {
generateQRCode();
}
// 弹窗关闭时清理
watch(dialogVisible, (visible) => {
if (!visible) {
stopPolling();
qrcodeUrl.value = '';
callbackKey.value = '';
expiredAt.value = '';
isWaiting.value = false;
isCompleted.value = false;
completedFileId.value = '';
}
});
onBeforeUnmount(() => {
stopPolling();
});
</script>
<template>
<ZqDialog
v-model="dialogVisible"
:title="$t('form-design.signaturePad.mobileSign')"
width="420px"
:close-on-click-modal="false"
@opened="handleOpened"
>
<div class="mobile-signature-dialog">
<!-- 签名完成状态 -->
<template v-if="isCompleted">
<ElResult
icon="success"
:title="$t('form-design.signaturePad.signCompleted')"
:sub-title="$t('form-design.signaturePad.signCompletedTip')"
>
<template #icon>
<div class="success-icon">
<Check class="success-icon__check" />
</div>
</template>
</ElResult>
</template>
<!-- 等待签名状态 -->
<template v-else>
<div v-loading="isLoading" class="mobile-signature-dialog__content">
<div class="mobile-signature-dialog__icon">
<Smartphone class="mobile-signature-dialog__smartphone" />
</div>
<div class="mobile-signature-dialog__tip">
{{ $t('form-design.signaturePad.scanQrcodeTip') }}
</div>
<div class="mobile-signature-dialog__qrcode">
<img v-if="qrcodeUrl" :src="qrcodeUrl" alt="QRCode" />
<div v-else class="mobile-signature-dialog__loading">
{{ $t('common.loading') }}
</div>
</div>
<div v-if="isWaiting" class="mobile-signature-dialog__waiting">
<span class="mobile-signature-dialog__dot"></span>
{{ $t('form-design.signaturePad.waitingForSign') }}
</div>
<div v-if="expiredAt" class="mobile-signature-dialog__expired">
<ElIcon><Clock /></ElIcon>
<span>{{
$t('form-design.signaturePad.qrcodeExpiredAt', {
time: expiredTimeText,
})
}}</span>
</div>
</div>
</template>
</div>
<template #footer>
<template v-if="isCompleted">
<ElButton @click="regenerateQRCode">
{{ $t('form-design.signaturePad.resignMobile') }}
</ElButton>
<ElButton type="primary" @click="confirmSignature">
{{ $t('form-design.signaturePad.useThisSignature') }}
</ElButton>
</template>
<template v-else>
<div class="mobile-signature-dialog__footer">
<ElButton
:icon="RefreshCw"
:loading="isLoading"
@click="regenerateQRCode"
>
{{ $t('form-design.signaturePad.refreshQrcode') }}
</ElButton>
<ElButton @click="handleCancel">
{{ $t('common.cancel') }}
</ElButton>
</div>
</template>
</template>
</ZqDialog>
</template>
<style scoped>
.mobile-signature-dialog {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mobile-signature-dialog__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.mobile-signature-dialog__icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--el-color-primary-light-9);
border-radius: 50%;
}
.mobile-signature-dialog__smartphone {
width: 24px;
height: 24px;
color: var(--el-color-primary);
}
.mobile-signature-dialog__tip {
font-size: 14px;
color: var(--el-text-color-secondary);
text-align: center;
}
.mobile-signature-dialog__qrcode {
padding: 16px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.mobile-signature-dialog__qrcode img {
display: block;
width: 200px;
height: 200px;
}
.mobile-signature-dialog__loading {
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
}
.mobile-signature-dialog__waiting {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--el-color-primary);
}
.mobile-signature-dialog__dot {
width: 8px;
height: 8px;
background-color: var(--el-color-primary);
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.success-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--el-color-success-light-9);
border-radius: 50%;
}
.success-icon__check {
width: 32px;
height: 32px;
color: var(--el-color-success);
}
.mobile-signature-dialog__expired {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.mobile-signature-dialog__footer {
display: flex;
justify-content: space-between;
width: 100%;
}
</style>
@@ -1,363 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RotateCcw } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElMessage } from 'element-plus';
import { uploadFile as uploadFileApi } from '#/api/core/file';
import { ZqDialog } from '#/components/zq-dialog';
interface Props {
visible: boolean;
penColor?: string;
penWidth?: number;
backgroundColor?: string;
source?: string;
}
const props = withDefaults(defineProps<Props>(), {
penColor: '#000000',
penWidth: 2,
backgroundColor: '#ffffff',
source: 'form',
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'complete', fileId: string): void;
}>();
// Canvas 相关
const canvasRef = ref<HTMLCanvasElement>();
const containerRef = ref<HTMLDivElement>();
let ctx: CanvasRenderingContext2D | null = null;
let isDrawing = false;
let lastX = 0;
let lastY = 0;
// 状态
const hasSignature = ref(false);
const isUploading = ref(false);
// 计算属性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
});
// 初始化画布
function initCanvas() {
if (!canvasRef.value || !containerRef.value) return;
const canvas = canvasRef.value;
const container = containerRef.value;
// 设置画布尺寸
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置画笔样式
ctx.strokeStyle = props.penColor;
ctx.lineWidth = props.penWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 设置背景
if (props.backgroundColor !== 'transparent') {
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
// 获取鼠标/触摸位置
function getPosition(e: MouseEvent | TouchEvent): { x: number; y: number } {
const canvas = canvasRef.value;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
if ('touches' in e) {
const touch = e.touches[0];
return {
x: touch ? touch.clientX - rect.left : 0,
y: touch ? touch.clientY - rect.top : 0,
};
}
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
// 开始绘制
function startDrawing(e: MouseEvent | TouchEvent) {
if (!ctx) return;
e.preventDefault();
isDrawing = true;
const pos = getPosition(e);
lastX = pos.x;
lastY = pos.y;
}
// 绘制中
function draw(e: MouseEvent | TouchEvent) {
if (!isDrawing || !ctx) return;
e.preventDefault();
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
hasSignature.value = true;
}
// 结束绘制
function stopDrawing() {
isDrawing = false;
}
// 清除签名
function clearSignature() {
const canvas = canvasRef.value;
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 重新填充背景
if (props.backgroundColor !== 'transparent') {
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
hasSignature.value = false;
}
// 确认签名
async function confirmSignature() {
const canvas = canvasRef.value;
if (!canvas || !hasSignature.value) {
ElMessage.warning($t('form-design.signaturePad.pleaseSign'));
return;
}
try {
isUploading.value = true;
// 创建一个新的 canvas,先填充白色背景再绘制签名
const exportCanvas = document.createElement('canvas');
exportCanvas.width = canvas.width;
exportCanvas.height = canvas.height;
const exportCtx = exportCanvas.getContext('2d');
if (!exportCtx) {
throw new Error('Failed to create export canvas');
}
// 填充白色背景
exportCtx.fillStyle = '#ffffff';
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
// 绘制签名
exportCtx.drawImage(canvas, 0, 0);
// 转换为 Blob
const blob = await new Promise<Blob | null>((resolve) => {
exportCanvas.toBlob(resolve, 'image/png');
});
if (!blob) {
throw new Error('Failed to create signature image');
}
// 创建 File 对象
const file = new File([blob], `signature_${Date.now()}.png`, {
type: 'image/png',
});
// 上传文件
const result = await uploadFileApi(file, { source: props.source });
if (result?.id) {
emit('complete', result.id);
dialogVisible.value = false;
}
} catch (error) {
console.error('Upload signature failed:', error);
ElMessage.error($t('form-design.signaturePad.uploadFailed'));
} finally {
isUploading.value = false;
}
}
// 取消
function handleCancel() {
dialogVisible.value = false;
hasSignature.value = false;
}
// 弹窗打开后初始化画布
function handleOpened() {
nextTick(() => {
initCanvas();
});
}
// 监听画笔属性变化
watch(
() => [props.penColor, props.penWidth],
() => {
if (ctx) {
ctx.strokeStyle = props.penColor;
ctx.lineWidth = props.penWidth;
}
},
);
// 窗口大小变化时重新初始化
let resizeObserver: null | ResizeObserver = null;
watch(dialogVisible, (visible) => {
if (visible) {
nextTick(() => {
if (containerRef.value) {
resizeObserver = new ResizeObserver(() => {
initCanvas();
});
resizeObserver.observe(containerRef.value);
}
});
} else {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
hasSignature.value = false;
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
<template>
<ZqDialog
v-model="dialogVisible"
:title="$t('form-design.signaturePad.signNow')"
width="700px"
:close-on-click-modal="false"
@opened="handleOpened"
>
<div class="signature-dialog">
<div class="signature-dialog__tip">
{{ $t('form-design.signaturePad.dialogTip') }}
</div>
<div ref="containerRef" class="signature-dialog__canvas-wrapper">
<canvas
ref="canvasRef"
class="signature-dialog__canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<!-- 占位提示 -->
<div v-if="!hasSignature" class="signature-dialog__placeholder">
{{ $t('form-design.signaturePad.drawHere') }}
</div>
</div>
<!-- 工具栏 -->
<div class="signature-dialog__toolbar">
<ElButton
type="default"
:icon="RotateCcw"
:disabled="!hasSignature"
@click="clearSignature"
>
{{ $t('form-design.signaturePad.redo') }}
</ElButton>
</div>
</div>
<template #footer>
<ElButton @click="handleCancel">
{{ $t('common.cancel') }}
</ElButton>
<ElButton
type="primary"
:loading="isUploading"
:disabled="!hasSignature"
@click="confirmSignature"
>
{{ $t('form-design.signaturePad.confirmSign') }}
</ElButton>
</template>
</ZqDialog>
</template>
<style scoped>
.signature-dialog {
display: flex;
flex-direction: column;
gap: 16px;
}
.signature-dialog__tip {
font-size: 14px;
color: var(--el-text-color-secondary);
}
.signature-dialog__canvas-wrapper {
position: relative;
height: 300px;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
background-color: var(--el-fill-color-lighter);
overflow: hidden;
cursor: crosshair;
touch-action: none;
}
.signature-dialog__canvas {
display: block;
width: 100%;
height: 100%;
}
.signature-dialog__placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
color: var(--el-text-color-placeholder);
pointer-events: none;
user-select: none;
}
.signature-dialog__toolbar {
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,2 +0,0 @@
export { default as SignaturePad } from './signature-pad.vue';
export type { SignaturePadEmits, SignaturePadProps } from './types';
@@ -1,306 +0,0 @@
<script setup lang="ts">
import type { SignaturePadEmits, SignaturePadProps } from './types';
import { computed, ref, watch } from 'vue';
import { Eraser, PenLine, Smartphone } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import { getFileUrl } from '#/composables/useFileUrl';
import MobileSignatureDialog from './MobileSignatureDialog.vue';
import SignatureDialog from './SignatureDialog.vue';
defineOptions({
name: 'SignaturePad',
inheritAttrs: false,
});
const props = withDefaults(defineProps<SignaturePadProps>(), {
penColor: '#000000',
penWidth: 2,
backgroundColor: '#ffffff',
width: '100%',
height: 200,
disabled: false,
readonly: false,
placeholder: '',
source: 'form',
});
const emit = defineEmits<SignaturePadEmits>();
// 状态
const signatureUrl = ref<string>('');
const isHovering = ref(false);
const signatureDialogVisible = ref(false);
const mobileSignatureDialogVisible = ref(false);
// 计算属性
const isDisabled = computed(() => props.disabled || props.readonly);
const hasSignature = computed(() => !!props.modelValue && !!signatureUrl.value);
const containerStyle = computed(() => ({
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: `${props.height}px`,
}));
const placeholderText = computed(
() => props.placeholder || $t('form-design.signaturePad.placeholder'),
);
// 打开立即签名弹窗
function openSignatureDialog() {
if (isDisabled.value) return;
signatureDialogVisible.value = true;
}
// 打开手机签名弹窗
function openMobileSignatureDialog() {
if (isDisabled.value) return;
mobileSignatureDialogVisible.value = true;
}
// 签名完成回调
function handleSignatureComplete(fileId: string) {
emit('update:modelValue', fileId);
emit('change', fileId);
emit('signed');
signatureDialogVisible.value = false;
mobileSignatureDialogVisible.value = false;
}
// 清除签名
function clearSignature() {
if (isDisabled.value) return;
signatureUrl.value = '';
emit('update:modelValue', null);
emit('change', null);
emit('cleared');
}
// 加载已有签名
async function loadSignature(fileId: string) {
if (!fileId) {
signatureUrl.value = '';
return;
}
try {
const url = await getFileUrl(fileId);
signatureUrl.value = url;
} catch (error) {
console.error('Load signature failed:', error);
signatureUrl.value = '';
}
}
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
loadSignature(newValue);
} else {
signatureUrl.value = '';
}
},
{ immediate: true },
);
</script>
<template>
<div class="signature-pad">
<!-- 已有签名时显示图片 -->
<div
v-if="hasSignature"
class="signature-pad__preview"
:style="containerStyle"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<img :src="signatureUrl" alt="signature" class="signature-pad__image" />
<Transition name="fade">
<div v-if="isHovering && !isDisabled" class="signature-pad__overlay">
<div class="signature-pad__actions">
<ElButton
type="primary"
size="small"
plain
:icon="PenLine"
@click="openSignatureDialog"
>
{{ $t('form-design.signaturePad.resignNow') }}
</ElButton>
<ElButton
type="danger"
size="small"
:icon="Eraser"
@click="clearSignature"
>
{{ $t('form-design.signaturePad.clear') }}
</ElButton>
</div>
</div>
</Transition>
</div>
<!-- 未签名时显示触发区域 -->
<div
v-else
class="signature-pad__trigger"
:class="{
'signature-pad__trigger--disabled': isDisabled,
'signature-pad__trigger--hover': isHovering,
}"
:style="containerStyle"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<!-- 默认状态 -->
<div v-if="!isHovering || isDisabled" class="signature-pad__placeholder">
<PenLine class="signature-pad__placeholder-icon" />
<span class="text-sm">{{ placeholderText }}</span>
</div>
<!-- Hover 状态显示操作按钮 -->
<Transition name="fade">
<div v-if="isHovering && !isDisabled" class="signature-pad__actions">
<ElButton
type="primary"
plain
size="small"
:icon="PenLine"
@click="openSignatureDialog"
>
{{ $t('form-design.signaturePad.signNow') }}
</ElButton>
<ElButton
type="default"
size="small"
:icon="Smartphone"
@click="openMobileSignatureDialog"
>
{{ $t('form-design.signaturePad.mobileSign') }}
</ElButton>
</div>
</Transition>
</div>
<!-- 立即签名弹窗 -->
<SignatureDialog
v-model:visible="signatureDialogVisible"
:pen-color="penColor"
:pen-width="penWidth"
:background-color="backgroundColor"
:source="source"
@complete="handleSignatureComplete"
/>
<!-- 手机签名弹窗 -->
<MobileSignatureDialog
v-model:visible="mobileSignatureDialogVisible"
:source="source"
@complete="handleSignatureComplete"
/>
</div>
</template>
<style scoped>
.signature-pad {
width: 100%;
}
.signature-pad__trigger {
position: relative;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
background-color: var(--el-fill-color-lighter);
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
}
.signature-pad__trigger:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
.signature-pad__trigger--disabled {
cursor: not-allowed;
opacity: 0.6;
}
.signature-pad__trigger--disabled:hover {
border-color: var(--el-border-color);
background-color: var(--el-fill-color-lighter);
}
.signature-pad__placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--el-text-color-placeholder);
pointer-events: none;
user-select: none;
}
.signature-pad__placeholder-icon {
width: 30px;
height: 30px;
opacity: 0.5;
}
.signature-pad__actions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 12px;
}
.signature-pad__preview {
position: relative;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background-color: var(--el-fill-color-lighter);
overflow: hidden;
}
.signature-pad__image {
width: 100%;
height: 100%;
object-fit: contain;
}
.signature-pad__overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.4);
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -1,29 +0,0 @@
export interface SignaturePadProps {
/** 签名文件ID */
modelValue?: null | string;
/** 画笔颜色 */
penColor?: string;
/** 画笔粗细 */
penWidth?: number;
/** 背景颜色 */
backgroundColor?: string;
/** 画布宽度 */
width?: number | string;
/** 画布高度 */
height?: number;
/** 是否禁用 */
disabled?: boolean;
/** 只读模式 */
readonly?: boolean;
/** 占位提示 */
placeholder?: string;
/** 文件上传来源标识 */
source?: string;
}
export interface SignaturePadEmits {
(e: 'update:modelValue', value: null | string): void;
(e: 'change', value: null | string): void;
(e: 'signed'): void;
(e: 'cleared'): void;
}