Remove unused code display form widgets
This commit is contained in:
@@ -67,19 +67,14 @@
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "catalog:",
|
||||
"element-plus": "catalog:",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"nanoid": "^5.1.7",
|
||||
"pinia": "catalog:",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "catalog:",
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
"vue-router": "catalog:",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"unplugin-element-plus": "catalog:"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user