feat: harden ai agent admin workflow experience

This commit is contained in:
2026-06-22 04:50:11 +08:00
parent ae29c24529
commit 4ec846a702
385 changed files with 61461 additions and 5886 deletions
@@ -0,0 +1,807 @@
<script setup lang="ts">
import type { AiOcrConfig } from '#/components/form-design/store/formDesignStore';
import { computed, ref, watch } from 'vue';
import {
DeleteOutlined,
EyeOutlined,
FileImageOutlined,
FileOutlined,
Sparkles,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElImageViewer,
ElMessage,
ElProgress,
ElTag,
} from 'element-plus';
import { uploadFile as uploadFileApi } from '#/api/core/file';
import { requestClient } from '#/api/request';
import { getFileUrl } from '#/composables/useFileUrl';
defineOptions({
name: 'AiImageOcr',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: () => $t('form-design.aiImageOcr.uploadPlaceholder'),
disabled: false,
clearable: true,
maxSize: 10,
});
const emit = defineEmits<Emits>();
interface Props {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
height?: number;
aiOcrConfig?: AiOcrConfig;
formData?: Record<string, any>;
}
interface Emits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
// 状态
const uploadInputRef = ref<HTMLInputElement>();
const isUploading = ref(false);
const isRecognizing = ref(false);
const uploadProgress = ref(0);
const currentImageId = ref<string>('');
const currentImageUrl = ref<string>('');
const currentFileMimeType = ref<string>('');
const previewVisible = ref(false);
const ocrResult = ref<null | {
extractedData: null | Record<string, any>;
rawText: string;
}>(null);
// 文件类型到accept属性的映射
const FILE_TYPE_ACCEPT_MAP: Record<string, string[]> = {
image: ['image/*'],
text: [
'.txt',
'.md',
'.log',
'.py',
'.js',
'.ts',
'.jsx',
'.tsx',
'.vue',
'.html',
'.css',
'.json',
'.yaml',
'.yml',
'.xml',
'.csv',
],
pdf: ['.pdf', 'application/pdf'],
word: [
'.doc',
'.docx',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
excel: [
'.xls',
'.xlsx',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
};
// 计算属性
const hasFile = computed(() => !!currentImageId.value);
// 计算接受的文件类型
const acceptString = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return '*/*';
}
const accepts: string[] = [];
types.forEach((type) => {
if (FILE_TYPE_ACCEPT_MAP[type]) {
accepts.push(...FILE_TYPE_ACCEPT_MAP[type]);
}
});
return accepts.join(',') || 'image/*';
});
// 判断是否为图片文件
const isImageFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType.startsWith('image/') ||
url.includes('image/') ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
);
});
// 判断是否为PDF文件
const isPdfFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType === 'application/pdf' ||
url.includes('application/pdf') ||
url.endsWith('.pdf')
);
});
const templateTypeLabel = computed(() => {
const labels: Record<string, string> = {
custom: $t('form-design.aiImageOcr.customTemplate'),
id_card: $t('form-design.aiImageOcr.idCard'),
business_license: $t('form-design.aiImageOcr.businessLicense'),
invoice: $t('form-design.aiImageOcr.invoice'),
receipt: $t('form-design.aiImageOcr.receipt'),
contract: $t('form-design.aiImageOcr.contract'),
};
return labels[props.aiOcrConfig?.templateType || 'custom'] || labels.custom;
});
// 获取支持的文件类型标签
const acceptedFileTypesLabel = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return $t('form-design.aiImageOcr.allFiles');
}
const labels: Record<string, string> = {
image: $t('form-design.aiImageOcr.imageFile'),
text: $t('form-design.aiImageOcr.textFile'),
pdf: 'PDF',
word: 'Word',
excel: 'Excel',
};
return types.map((t) => labels[t] || t).join(', ');
});
// 监听 modelValue 变化
watch(
() => props.modelValue,
async (newValue) => {
if (!newValue) {
currentImageId.value = '';
currentImageUrl.value = '';
currentFileMimeType.value = '';
return;
}
const id = Array.isArray(newValue) ? newValue[0] : newValue;
if (id && id !== currentImageId.value) {
currentImageId.value = id;
currentImageUrl.value = await getFileUrl(id);
// 从后端获取文件信息
try {
const fileInfo = await requestClient.get<{ mime_type?: string }>(
`/api/core/file_manager/${id}`,
);
currentFileMimeType.value = fileInfo.mime_type || '';
} catch (error) {
console.warn('Failed to fetch file info:', error);
}
}
},
{ immediate: true },
);
// 打开文件选择器
function openFileSelector() {
if (props.disabled) return;
uploadInputRef.value?.click();
}
// 处理文件选择
async function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
target.value = '';
// 验证文件类型
const acceptedTypes = props.aiOcrConfig?.acceptFileTypes || ['image'];
const isValidType = validateFileType(file, acceptedTypes);
if (!isValidType) {
ElMessage.error($t('form-design.aiImageOcr.invalidFileType'));
return;
}
// 验证文件大小
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
ElMessage.error(
$t('form-design.aiImageOcr.fileTooLarge', { size: props.maxSize }),
);
return;
}
await uploadAndRecognize(file);
}
// 验证文件类型
function validateFileType(file: File, acceptedTypes: string[]): boolean {
if (acceptedTypes.includes('all')) return true;
const fileName = file.name.toLowerCase();
const mimeType = file.type.toLowerCase();
for (const type of acceptedTypes) {
const accepts = FILE_TYPE_ACCEPT_MAP[type] || [];
for (const accept of accepts) {
if (accept.startsWith('.')) {
// 扩展名匹配
if (fileName.endsWith(accept)) return true;
} else if (accept.endsWith('/*')) {
// MIME 类型通配符匹配
const prefix = accept.replace('/*', '');
if (mimeType.startsWith(prefix)) return true;
} else {
// 完整 MIME 类型匹配
if (mimeType === accept) return true;
}
}
}
return false;
}
// 上传并识别
async function uploadAndRecognize(file: File) {
try {
isUploading.value = true;
uploadProgress.value = 0;
// 上传文件
const response = await uploadFileApi(file, {
source: 'form',
onProgress: (progressEvent) => {
uploadProgress.value = progressEvent.percentage;
},
});
if (!response?.id) {
throw new Error($t('form-design.aiImageOcr.uploadFailed'));
}
currentImageId.value = response.id;
currentImageUrl.value = await getFileUrl(response.id);
currentFileMimeType.value = file.type;
isUploading.value = false;
// 更新 modelValue
emit('update:modelValue', response.id);
emit('change', response.id);
// 如果启用了 AI 识别,开始识别
if (props.aiOcrConfig?.enabled) {
await recognizeImage(response.id);
}
} catch (error: any) {
console.error('Upload failed:', error);
ElMessage.error(error.message || $t('form-design.aiImageOcr.uploadFailed'));
isUploading.value = false;
}
}
// AI 识别文件
async function recognizeImage(fileId: string) {
if (!props.aiOcrConfig?.enabled) return;
try {
isRecognizing.value = true;
// 构建请求参数
const requestData: any = {
fileId,
};
// 如果有结构化输出配置
if (
props.aiOcrConfig.outputSchema &&
props.aiOcrConfig.outputSchema.length > 0
) {
requestData.outputSchema = props.aiOcrConfig.outputSchema;
}
// 如果有自定义提示词
if (props.aiOcrConfig.customPrompt) {
requestData.prompt = props.aiOcrConfig.customPrompt;
}
// 调用 OCR API
const result = await requestClient.post<{
error: null | string;
extractedData: null | Record<string, any>;
rawText: null | string;
success: boolean;
}>('/api/core/file_manager/ocr/recognize', requestData);
if (!result.success) {
throw new Error(
result.error || $t('form-design.aiImageOcr.recognizeFailed'),
);
}
ocrResult.value = {
rawText: result.rawText || '',
extractedData: result.extractedData,
};
emit('ocr-success', ocrResult.value);
if (result.extractedData) {
applyFieldMapping(result.extractedData);
} else {
ElMessage.success($t('form-design.aiImageOcr.recognizeSuccess'));
}
} catch (error: any) {
console.error('OCR failed:', error);
ElMessage.error(
error.message || $t('form-design.aiImageOcr.recognizeFailed'),
);
} finally {
isRecognizing.value = false;
}
}
// 应用字段映射
function applyFieldMapping(extractedData: Record<string, any>) {
if (!props.aiOcrConfig?.fieldMapping?.length) {
// 没有配置映射,直接发送原始数据
emit('fill-fields', extractedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
return;
}
const mappedData: Record<string, any> = {};
for (const mapping of props.aiOcrConfig.fieldMapping) {
const value = extractedData[mapping.source];
if (value !== undefined) {
mappedData[mapping.target] = value;
}
}
emit('fill-fields', mappedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
}
// 预览图片
function handlePreview() {
if (currentImageUrl.value) {
previewVisible.value = true;
}
}
// 删除图片
function handleDelete() {
currentImageId.value = '';
currentImageUrl.value = '';
ocrResult.value = null;
emit('update:modelValue', undefined);
emit('change', undefined);
}
// 重新识别
async function handleReRecognize() {
if (currentImageId.value) {
await recognizeImage(currentImageId.value);
}
}
</script>
<template>
<div class="ai-image-ocr">
<!-- 上传区域 -->
<div
class="upload-area"
:class="{
'has-file': hasFile,
'is-disabled': disabled,
'is-uploading': isUploading,
'is-recognizing': isRecognizing,
}"
:style="{ height: height ? `${height}px` : undefined }"
@click="!hasFile && openFileSelector()"
>
<!-- 隐藏的文件输入 -->
<input
ref="uploadInputRef"
type="file"
:accept="acceptString"
style="display: none"
@change="handleFileChange"
/>
<!-- 空状态 -->
<template v-if="!hasFile && !isUploading">
<div class="upload-placeholder">
<div class="upload-icon">
<Sparkles class="ai-icon" />
<FileImageOutlined class="image-icon" />
</div>
<div class="upload-text">{{ placeholder }}</div>
<div class="upload-hint">
<ElTag size="small" type="primary">
{{ templateTypeLabel }}
</ElTag>
<ElTag size="small" type="info" class="ml-1">
{{ acceptedFileTypesLabel }}
</ElTag>
</div>
</div>
</template>
<!-- 上传中 -->
<template v-else-if="isUploading">
<div class="upload-progress">
<ElProgress
type="circle"
:percentage="uploadProgress"
:width="80"
:stroke-width="6"
/>
<div class="progress-text">
{{ $t('form-design.aiImageOcr.uploading') }}
</div>
</div>
</template>
<!-- 已上传文件 -->
<template v-else>
<div class="file-preview">
<!-- 图片预览 -->
<img
v-if="isImageFile"
:src="currentImageUrl"
alt="uploaded"
class="preview-img"
/>
<!-- PDF预览 -->
<iframe
v-else-if="isPdfFile"
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
class="pdf-preview"
frameborder="0"
></iframe>
<!-- 其他文件显示图标 -->
<div v-else class="file-icon-wrapper">
<FileOutlined class="file-icon" />
<div class="file-name">
{{ $t('form-design.aiImageOcr.fileUploaded') }}
</div>
</div>
<!-- 识别中遮罩 -->
<div v-if="isRecognizing" class="recognizing-mask">
<div class="recognizing-content">
<Sparkles class="recognizing-icon" />
<div class="recognizing-text">
{{ $t('form-design.aiImageOcr.recognizing') }}
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!isRecognizing" class="file-actions">
<ElButton
v-if="isImageFile || isPdfFile"
circle
size="small"
@click.stop="handlePreview"
>
<EyeOutlined />
</ElButton>
<ElButton
v-if="aiOcrConfig?.enabled"
circle
size="small"
type="primary"
@click.stop="handleReRecognize"
>
<Sparkles />
</ElButton>
<ElButton
v-if="clearable"
circle
size="small"
type="danger"
@click.stop="handleDelete"
>
<DeleteOutlined />
</ElButton>
</div>
<!-- AI 标识 -->
<div v-if="aiOcrConfig?.enabled" class="ai-badge">
<Sparkles class="badge-icon" />
AI
</div>
</div>
</template>
</div>
<!-- 图片预览 -->
<ElImageViewer
v-if="previewVisible && isImageFile"
:url-list="[currentImageUrl]"
:z-index="3000"
@close="previewVisible = false"
/>
<!-- PDF预览对话框 -->
<ElDialog
v-model="previewVisible"
v-if="isPdfFile"
:title="$t('form-design.aiImageOcr.preview')"
width="80%"
top="5vh"
>
<iframe
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
style="width: 100%; height: 70vh; border: none"
></iframe>
</ElDialog>
</div>
</template>
<style lang="scss" scoped>
.ai-image-ocr {
width: 100%;
}
.upload-area {
position: relative;
width: 100%;
min-height: 120px;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: var(--el-fill-color-lighter);
&:hover:not(.is-disabled):not(.has-file) {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.has-file {
cursor: default;
border-style: solid;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 8px;
.upload-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
.image-icon {
font-size: 40px;
color: var(--el-color-primary);
}
.ai-icon {
position: absolute;
top: -8px;
right: -12px;
font-size: 20px;
color: var(--el-color-warning);
animation: sparkle 1.5s ease-in-out infinite;
}
}
.upload-text {
font-size: 14px;
color: var(--el-text-color-regular);
}
.upload-hint {
margin-top: 4px;
}
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
.progress-text {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.file-preview {
position: relative;
width: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
.preview-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 4px;
}
.pdf-preview {
width: 100%;
height: 100%;
min-height: 400px;
border: none;
border-radius: 4px;
}
.file-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
.file-icon {
font-size: 48px;
color: var(--el-color-primary);
}
.file-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.file-actions {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
&:hover .file-actions {
opacity: 1;
}
.ai-badge {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(
135deg,
var(--el-color-primary),
var(--el-color-primary-light-3)
);
color: white;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
.badge-icon {
font-size: 14px;
}
}
}
.recognizing-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
.recognizing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: white;
.recognizing-icon {
font-size: 32px;
animation: sparkle 1s ease-in-out infinite;
}
.recognizing-text {
font-size: 14px;
}
}
}
.confirm-content {
.extracted-data {
max-height: 300px;
overflow-y: auto;
text-align: left;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
.data-item {
display: flex;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.data-label {
flex-shrink: 0;
width: 120px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.data-value {
flex: 1;
color: var(--el-text-color-primary);
word-break: break-all;
}
}
}
}
@keyframes sparkle {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
</style>
@@ -0,0 +1,2 @@
export { default as AiImageOcr } from './ai-image-ocr.vue';
export type { AiImageOcrEmits, AiImageOcrProps } from './types';
@@ -0,0 +1,18 @@
export interface AiImageOcrProps {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
}
export interface AiImageOcrEmits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
@@ -0,0 +1,275 @@
<script setup lang="ts">
import type { BarcodeGeneratorEmits, BarcodeGeneratorProps } from './types';
import { computed, watch } from 'vue';
import { Barcode, Copy, Download } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
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 contentOptions = computed(() => ({
boundField: props.boundField,
contentType: 'barcode' as const,
dataSource: props.dataSource,
formData: props.formData,
formula: props.formula,
modelValue: props.modelValue,
}));
const barcodeContent = useCodeContent(contentOptions);
const isValid = computed(() =>
validateBarcodeContent(props.format ?? 'code128', barcodeContent.value),
);
const invalidMessageKey = computed(() =>
getBarcodeValidationErrorKey(props.format ?? 'code128', barcodeContent.value),
);
const placeholderText = computed(
() => props.placeholder || $t('form-design.barcode.placeholder'),
);
const svgMarkup = computed(() => {
const content = barcodeContent.value;
if (!content || !isValid.value) return '';
const barWidth = Math.max(1, props.width || 2);
const height = Math.max(40, props.height || 80);
const margin = Math.max(0, props.margin || 0);
const chars = [...content];
const encodedBars = chars.flatMap((char, index) => {
const code = char.charCodeAt(0) + index;
return Array.from({ length: 7 }, (_, bit) => ((code >> bit) & 1) === 1);
});
const totalWidth = encodedBars.length * barWidth + margin * 2;
const textOffset = props.displayValue ? 20 : 0;
const barHeight = Math.max(24, height - textOffset);
const bars = encodedBars
.map((enabled, index) => {
if (!enabled) return '';
const x = margin + index * barWidth;
return `<rect x="${x}" y="${margin}" width="${barWidth}" height="${barHeight}" fill="${props.lineColor}" />`;
})
.join('');
const text = props.displayValue
? `<text x="${totalWidth / 2}" y="${margin + barHeight + 15}" text-anchor="middle" font-family="monospace" font-size="14" fill="${props.lineColor}">${escapeXml(content)}</text>`
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height + margin * 2}" viewBox="0 0 ${totalWidth} ${height + margin * 2}"><rect width="100%" height="100%" fill="${props.backgroundColor}" />${bars}${text}</svg>`;
});
const barcodeUrl = computed(() =>
svgMarkup.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup.value)}`
: '',
);
function escapeXml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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'));
}
}
watch(
() => barcodeUrl.value,
(url) => {
if (url) emit('generated', barcodeContent.value);
},
{ 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 }"
>
<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>
<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;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.barcode-generator__container--empty {
border-style: dashed;
}
.barcode-generator__container--disabled {
cursor: not-allowed;
opacity: 0.6;
}
.barcode-generator__image {
display: block;
max-width: 100%;
height: auto;
}
.barcode-generator__placeholder {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
}
.barcode-generator__placeholder-icon {
width: 48px;
height: 48px;
opacity: 0.5;
}
.barcode-generator__placeholder-text {
max-width: 80%;
font-size: 12px;
text-align: center;
}
.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>
@@ -0,0 +1,2 @@
export { default as BarcodeGenerator } from './barcode-generator.vue';
export type * from './types';
@@ -0,0 +1,23 @@
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,195 +1,136 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type {
CodeEditorEmits,
CodeEditorExpose,
CodeEditorProps,
} from './types';
const props = withDefaults(
defineProps<{
autocompletion?: boolean;
bracketMatching?: boolean;
foldGutter?: boolean;
height?: number | string;
language?: string;
lineNumbers?: boolean;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
autocompletion: false,
bracketMatching: false,
foldGutter: false,
height: '240px',
language: 'text',
lineNumbers: false,
modelValue: '',
placeholder: '',
readonly: false,
},
);
import { computed, onMounted, ref } from 'vue';
const emit = defineEmits<{
change: [value: string];
'update:modelValue': [value: string];
}>();
const props = withDefaults(defineProps<CodeEditorProps>(), {
autocompletion: false,
bracketMatching: false,
disabled: false,
foldGutter: false,
height: 'auto',
highlightActiveLine: false,
indentGuide: false,
language: 'javascript',
lineNumbers: false,
lineWrapping: true,
minHeight: '100px',
modelValue: '',
readonly: false,
tabSize: 2,
theme: 'auto',
});
const lineNumbersRef = ref<HTMLPreElement | null>(null);
const emit = defineEmits<CodeEditorEmits>();
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorStyle = computed(() => ({
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
}));
const editorStyle = computed(() => {
const style: Record<string, string> = {
minHeight:
typeof props.minHeight === 'number'
? `${props.minHeight}px`
: props.minHeight,
tabSize: String(props.tabSize),
whiteSpace: props.lineWrapping ? 'pre-wrap' : 'pre',
};
const lineNumbersText = computed(() => {
const count = Math.max(1, props.modelValue.split(/\r\n|\r|\n/).length);
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
if (props.height !== 'auto') {
style.height =
typeof props.height === 'number' ? `${props.height}px` : props.height;
}
if (props.maxHeight) {
style.maxHeight =
typeof props.maxHeight === 'number'
? `${props.maxHeight}px`
: props.maxHeight;
}
return style;
});
const languageLabel = computed(() => {
const language = props.language === 'python3' ? 'python' : props.language;
return language.toUpperCase();
});
function handleInput(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
function emitValue(value: string) {
emit('update:modelValue', value);
}
function handleChange(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('change', value);
}
function handleKeydown(event: KeyboardEvent) {
if (props.readonly || event.key !== 'Tab') {
return;
}
event.preventDefault();
const target = event.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
const value = props.modelValue || '';
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
emit('update:modelValue', nextValue);
requestAnimationFrame(() => {
target.selectionStart = start + 2;
target.selectionEnd = start + 2;
});
function focus() {
textareaRef.value?.focus();
}
function handleScroll(event: Event) {
if (!lineNumbersRef.value) {
return;
}
lineNumbersRef.value.scrollTop = (event.target as HTMLTextAreaElement).scrollTop;
function getValue() {
return props.modelValue || '';
}
defineExpose({
focus: () => textareaRef.value?.focus(),
function setValue(value: string) {
emitValue(value);
}
function format() {
if (props.language !== 'json') return;
try {
emitValue(JSON.stringify(JSON.parse(getValue()), null, props.tabSize));
} catch {
// 保持轻量实现,格式化失败时不打断表单编辑。
}
}
onMounted(() => {
emit('ready', textareaRef.value);
});
defineExpose<CodeEditorExpose>({
focus,
format,
getValue,
getView: () => textareaRef.value,
setValue,
});
</script>
<template>
<div
class="code-editor"
:class="{ 'code-editor--readonly': readonly }"
<textarea
ref="textareaRef"
class="zq-code-editor"
:disabled="disabled"
:placeholder="placeholder"
:readonly="readonly"
:style="editorStyle"
>
<div class="code-editor__body">
<pre
v-if="lineNumbers"
ref="lineNumbersRef"
class="code-editor__lines"
aria-hidden="true"
>{{ lineNumbersText }}</pre
>
<textarea
ref="textareaRef"
class="code-editor__textarea"
:placeholder="placeholder"
:readonly="readonly"
:spellcheck="false"
:value="modelValue"
@change="handleChange"
@input="handleInput"
@keydown="handleKeydown"
@scroll="handleScroll"
></textarea>
<span v-if="language" class="code-editor__language">
{{ languageLabel }}
</span>
</div>
</div>
:value="modelValue"
spellcheck="false"
@blur="emit('blur')"
@focus="emit('focus')"
@input="emitValue(($event.target as HTMLTextAreaElement).value)"
></textarea>
</template>
<style scoped>
.code-editor {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--muted) / 45%);
}
.code-editor__body {
position: relative;
display: flex;
height: 100%;
min-height: 0;
}
.code-editor__lines {
min-width: 42px;
height: 100%;
margin: 0;
padding: 10px 8px;
overflow: hidden;
border-right: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground) / 70%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
text-align: right;
user-select: none;
}
.code-editor__textarea {
.zq-code-editor {
width: 100%;
height: 100%;
min-width: 0;
padding: 10px 12px;
overflow: auto;
border: 0;
outline: none;
resize: none;
background: transparent;
color: hsl(var(--foreground));
resize: vertical;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-fill-color-blank);
color: var(--el-text-color-primary);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
font-size: 13px;
line-height: 1.6;
tab-size: 2;
outline: none;
padding: 10px 12px;
}
.code-editor__textarea::placeholder {
color: hsl(var(--muted-foreground) / 55%);
.zq-code-editor:focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
.code-editor__language {
position: absolute;
right: 8px;
bottom: 6px;
pointer-events: none;
color: hsl(var(--muted-foreground) / 55%);
font-size: 10px;
font-weight: 600;
letter-spacing: 0;
}
.code-editor--readonly .code-editor__textarea {
cursor: default;
.zq-code-editor:disabled {
cursor: not-allowed;
background: var(--el-disabled-bg-color);
color: var(--el-disabled-text-color);
}
</style>
@@ -1 +1,3 @@
export { default as CodeEditor } from './code-editor.vue';
export { supportedLanguages } from './languages';
export * from './types';
@@ -0,0 +1,18 @@
import type { CodeLanguage } from './types';
export const supportedLanguages: CodeLanguage[] = [
'javascript',
'typescript',
'python',
'sql',
'json',
'html',
'css',
'markdown',
'xml',
'yaml',
];
export async function getLanguageExtension() {
return null;
}
@@ -0,0 +1,47 @@
export type CodeLanguage =
| 'css'
| 'html'
| 'javascript'
| 'json'
| 'markdown'
| 'python'
| 'sql'
| 'typescript'
| 'xml'
| 'yaml';
export interface CodeEditorProps {
modelValue?: string;
language?: CodeLanguage | string;
theme?: 'auto' | 'dark' | 'light';
readonly?: boolean;
disabled?: boolean;
height?: number | string;
minHeight?: number | string;
maxHeight?: number | string;
placeholder?: string;
tabSize?: number;
lineNumbers?: boolean;
lineWrapping?: boolean;
foldGutter?: boolean;
highlightActiveLine?: boolean;
bracketMatching?: boolean;
autocompletion?: boolean;
indentGuide?: boolean;
}
export interface CodeEditorEmits {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
(e: 'focus'): void;
(e: 'blur'): void;
(e: 'ready', value: HTMLTextAreaElement | null): void;
}
export interface CodeEditorExpose {
getView: () => HTMLTextAreaElement | null;
focus: () => void;
getValue: () => string;
setValue: (value: string) => void;
format: () => void;
}
@@ -3,17 +3,14 @@ import type { FormSelectorEmits, FormSelectorProps } from './types';
import { computed, ref, watch } from 'vue';
import { Table2 } from '@vben/icons';
import { Search, Table2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCheckbox,
ElEmpty,
ElInput,
ElOption,
ElPagination,
ElRadio,
ElSelect,
ElTable,
ElTableColumn,
@@ -23,129 +20,145 @@ import { requestClient } from '#/api/request';
import { ZqDialog } from '#/components/zq-dialog';
defineOptions({
name: 'FormSelector',
inheritAttrs: false,
name: 'FormSelector',
});
const selectorText = {
clickToSelect: '点击选择',
nodeLabel: '名称',
nodeValue: '值',
search: '搜索',
selectData: '选择数据',
selectFormFirst: '请先选择表单',
selectedCount: (count: number) => `已选择 ${count}`,
};
const props = withDefaults(defineProps<FormSelectorProps>(), {
multiple: false,
collapseTags: false,
maxCollapseTags: 1,
placeholder: selectorText.clickToSelect,
disabled: false,
clearable: true,
dialogTitle: selectorText.selectData,
dialogWidth: '1200px',
valueField: 'id',
labelField: 'name',
collapseTags: false,
dialogTitle: () => $t('form-design.attribute.selectData'),
dialogWidth: '960px',
disabled: false,
expandMultipleToRows: false,
externalSelectedValues: () => [],
initialFilters: undefined,
labelField: 'name',
maxCollapseTags: 1,
multiple: false,
placeholder: () => $t('form-design.attribute.clickToSelect'),
valueField: 'id',
});
const emit = defineEmits<FormSelectorEmits>();
const dialogVisible = ref(false);
const loading = ref(false);
const selectedValues = ref<Set<string>>(new Set());
const selectedItemsMap = ref<Map<string, any>>(new Map());
const labelsLoading = ref(false);
const tableData = ref<Array<Record<string, any>>>([]);
const total = ref(0);
const listLoading = ref(false);
const listData = ref<any[]>([]);
const listTotal = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const searchKeyword = ref('');
const selectedValues = ref<Set<string>>(new Set());
const selectedItemsMap = ref<Map<string, Record<string, any>>>(new Map());
const keyword = ref('');
function normalizeValues(value: null | string | string[] | undefined) {
if (!value) return [];
return Array.isArray(value) ? value.map(String) : [String(value)];
function toSelectedSet(source: unknown): Set<string> {
if (source instanceof Set) return source;
if (Array.isArray(source)) return new Set(source.map(String));
return new Set();
}
function toStringArray(value: unknown) {
if (!value) return [];
return Array.isArray(value) ? value.map(String) : [String(value)];
function toStringArray(source: unknown): string[] {
if (!source) return [];
if (Array.isArray(source)) return source.map(String);
return [];
}
function getItemLabel(item: undefined | Record<string, any>, value: string) {
if (!item) return value;
return (
item[props.labelField] ??
item.name ??
item.title ??
item.label ??
item.text ??
value
);
}
function pickList(response: any) {
return Array.isArray(response)
? response
: response?.items || response?.list || response?.data || [];
}
function normalizeFilters() {
function buildFilterParams() {
const params: Record<string, any> = {};
for (const [field, filter] of Object.entries(props.initialFilters || {})) {
if (filter?.value === undefined || filter.value === null || filter.value === '') {
continue;
for (const [field, config] of Object.entries(props.initialFilters || {})) {
if (config?.value !== undefined && config?.value !== '') {
params[`filter_${field}`] = config.value;
}
const op = filter.type || 'eq';
params[op === 'eq' ? field : `${field}__${op}`] = filter.value;
}
return params;
}
async function loadInitialLabels(values: string[]) {
if (!props.formCode || values.length === 0) return;
function getItemLabel(item: any, value: string): string {
if (!item) return value;
if (props.labelField && item[props.labelField]) return item[props.labelField];
for (const field of ['name', 'label', 'title', 'text']) {
if (item[field]) return item[field];
}
return value;
}
async function loadInitialLabels(values: any[]) {
if (!props.formCode) return;
try {
const stringValues = values.map(String);
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{
params: {
[`filter_${props.valueField}`]: stringValues.join(','),
pageSize: stringValues.length,
},
},
);
for (const item of response?.items || []) {
selectedItemsMap.value.set(String(item[props.valueField]), item);
}
} catch (error) {
console.error('Load form selector labels failed:', error);
}
}
async function loadDialogList() {
if (!props.formCode) return;
listLoading.value = true;
try {
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{
params: {
...normalizeFilters(),
[`${props.valueField}__in`]: values.join(','),
pageSize: values.length,
...buildFilterParams(),
keyword: keyword.value || undefined,
page: currentPage.value,
pageSize: pageSize.value,
},
},
);
for (const item of pickList(response)) {
selectedItemsMap.value.set(String(item[props.valueField]), item);
}
listData.value = response?.items || [];
listTotal.value = response?.total || listData.value.length;
} catch (error) {
console.error('load form selector labels failed:', error);
console.error('Load form selector list failed:', error);
listData.value = [];
listTotal.value = 0;
} finally {
listLoading.value = false;
}
}
function emitSelectItem() {
const items = [...selectedValues.value]
const items = [...toSelectedSet(selectedValues.value)]
.map((value) => selectedItemsMap.value.get(value))
.filter(Boolean);
emit('select-item', props.multiple ? items : items[0]);
emit('select-item', props.multiple ? (items.length ? items : undefined) : items[0]);
}
watch(
() => props.modelValue,
async (value) => {
const values = normalizeValues(value);
selectedValues.value = new Set(values);
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
if (value) {
const values = Array.isArray(value) ? value : [value];
selectedValues.value = new Set(values.map(String));
const missingValues = values.filter(
(item) => !selectedItemsMap.value.has(String(item)),
);
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
}
} else {
selectedValues.value = new Set();
selectedItemsMap.value.clear();
}
},
{ immediate: true },
@@ -153,14 +166,20 @@ watch(
watch(
() => props.formCode,
async () => {
const values = normalizeValues(props.modelValue);
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
async (formCode) => {
if (formCode && props.modelValue) {
const values = Array.isArray(props.modelValue)
? props.modelValue
: [props.modelValue];
const missingValues = values.filter(
(item) => !selectedItemsMap.value.has(String(item)),
);
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
if (!props.expandMultipleToRows) emitSelectItem();
}
}
},
);
@@ -169,137 +188,59 @@ const selectValue = computed(() => {
if (labelsLoading.value) return props.multiple ? [] : '';
if (props.expandMultipleToRows) {
const values = normalizeValues(props.modelValue);
return props.multiple ? values : values[0] || '';
if (!props.modelValue) return props.multiple ? [] : '';
return props.multiple
? Array.isArray(props.modelValue)
? props.modelValue
: [props.modelValue]
: Array.isArray(props.modelValue)
? props.modelValue[0] || ''
: props.modelValue;
}
const values = [...selectedValues.value];
const values = [...toSelectedSet(selectedValues.value)];
return props.multiple ? values : values[0] || '';
});
const selectOptions = computed(() => {
const values = props.expandMultipleToRows
? normalizeValues(props.modelValue)
: [...selectedValues.value];
return values.map((value) => ({
value,
if (labelsLoading.value) return [];
if (props.expandMultipleToRows) {
if (!props.modelValue) return [];
const values = Array.isArray(props.modelValue)
? props.modelValue
: [props.modelValue];
return values.filter(Boolean).map((value) => {
const stringValue = String(value);
return {
label: getItemLabel(selectedItemsMap.value.get(stringValue), stringValue),
value: stringValue,
};
});
}
return [...toSelectedSet(selectedValues.value)].map((value) => ({
label: getItemLabel(selectedItemsMap.value.get(value), value),
value,
}));
});
async function loadData() {
if (!props.formCode) {
tableData.value = [];
total.value = 0;
return;
}
loading.value = true;
try {
const params: Record<string, any> = {
...normalizeFilters(),
page: currentPage.value,
pageSize: pageSize.value,
};
if (searchKeyword.value) {
params[`${props.labelField}__like`] = searchKeyword.value;
}
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{ params },
);
const rows = pickList(response);
tableData.value = rows;
total.value = Number(response?.total ?? rows.length);
for (const row of rows) {
const value = String(row[props.valueField]);
if (selectedValues.value.has(value)) selectedItemsMap.value.set(value, row);
}
} catch (error) {
console.error('load form selector data failed:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function openDialog() {
if (props.disabled) return;
if (props.expandMultipleToRows) {
for (const value of toStringArray(props.externalSelectedValues)) {
selectedValues.value.add(value);
}
const externalValues = toStringArray(props.externalSelectedValues);
if (props.expandMultipleToRows && externalValues.length > 0) {
for (const value of externalValues) selectedValues.value.add(value);
selectedValues.value = new Set(selectedValues.value);
}
dialogVisible.value = true;
loadData();
}
function handleSearch() {
currentPage.value = 1;
loadData();
}
function handlePageChange(page: number) {
currentPage.value = page;
loadData();
}
function handleSizeChange(size: number) {
pageSize.value = size;
currentPage.value = 1;
loadData();
}
function getRowValue(row: Record<string, any>) {
return String(row[props.valueField]);
}
function isRowSelected(row: Record<string, any>) {
return selectedValues.value.has(getRowValue(row));
}
function toggleRow(row: Record<string, any>, checked?: boolean | number | string) {
const value = getRowValue(row);
if (props.multiple) {
const nextChecked = checked ?? !selectedValues.value.has(value);
if (nextChecked) {
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
} else {
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
}
selectedValues.value = new Set(selectedValues.value);
return;
}
selectedValues.value = new Set([value]);
selectedItemsMap.value.clear();
selectedItemsMap.value.set(value, row);
}
const isAllSelected = computed(
() =>
tableData.value.length > 0 &&
tableData.value.every((row) => isRowSelected(row)),
);
const isIndeterminate = computed(() => {
const selectedCount = tableData.value.filter((row) => isRowSelected(row)).length;
return selectedCount > 0 && selectedCount < tableData.value.length;
});
function handleSelectAll(checked: boolean | number | string) {
for (const row of tableData.value) toggleRow(row, checked);
loadDialogList();
}
function handleConfirm() {
const values = [...selectedValues.value];
const values = [...toSelectedSet(selectedValues.value)];
const result = props.multiple ? values : values[0] || null;
emit('update:modelValue', result);
@@ -321,12 +262,53 @@ function handleRemoveTag(value: string) {
selectedItemsMap.value.delete(value);
selectedValues.value = new Set(selectedValues.value);
const values = [...selectedValues.value];
const values = [...toSelectedSet(selectedValues.value)];
const result = props.multiple ? values : values[0] || null;
emit('update:modelValue', result);
emit('change', result);
emitSelectItem();
}
function handleRowSelect(row: any) {
const value = String(row[props.valueField]);
if (props.multiple) {
if (selectedValues.value.has(value)) {
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
} else {
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
}
selectedValues.value = new Set(selectedValues.value);
return;
}
if (selectedValues.value.has(value)) {
selectedValues.value.clear();
selectedItemsMap.value.clear();
} else {
selectedValues.value = new Set([value]);
selectedItemsMap.value.clear();
selectedItemsMap.value.set(value, row);
}
}
function handleSearch() {
currentPage.value = 1;
loadDialogList();
}
function handleSizeChange(size: number) {
pageSize.value = size;
currentPage.value = 1;
loadDialogList();
}
function handleCurrentChange(page: number) {
currentPage.value = page;
loadDialogList();
}
</script>
<template>
@@ -363,97 +345,83 @@ function handleRemoveTag(value: string) {
append-to-body
destroy-on-close
:show-fullscreen-button="false"
class="form-selector-dialog h-[90%]"
class="form-selector-dialog"
>
<div v-if="formCode" class="form-selector-content flex h-[650px] flex-col">
<div class="mb-4 flex shrink-0 items-center gap-2">
<div v-if="formCode" class="form-selector-panel">
<div class="form-selector-toolbar">
<ElInput
v-model="searchKeyword"
:placeholder="selectorText.search"
v-model="keyword"
clearable
class="w-64"
@change="handleSearch"
:prefix-icon="Search"
:placeholder="$t('common.search')"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('common.search') }}
</ElButton>
</div>
<div class="min-h-0 flex-1">
<ElTable
v-loading="loading"
:data="tableData"
border
stripe
height="100%"
:row-class-name="
({ row }: any) => (isRowSelected(row) ? 'selected-row' : '')
"
style="width: 100%"
@row-click="toggleRow"
>
<ElTableColumn v-if="!multiple" width="55" align="center">
<template #default="{ row }">
<ElRadio
:model-value="isRowSelected(row) ? getRowValue(row) : ''"
:value="getRowValue(row)"
@click.stop
@change="toggleRow(row)"
>
<span></span>
</ElRadio>
</template>
</ElTableColumn>
<ElTableColumn v-else width="55" align="center">
<template #header>
<ElCheckbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleSelectAll"
/>
</template>
<template #default="{ row }">
<ElCheckbox
:model-value="isRowSelected(row)"
@click.stop
@change="toggleRow(row, $event)"
/>
</template>
</ElTableColumn>
<ElTableColumn :prop="labelField" :label="selectorText.nodeLabel" />
<ElTableColumn :prop="valueField" :label="selectorText.nodeValue" width="180" />
<template #empty>
<ElEmpty :description="$t('common.noData')" />
<ElTable
v-loading="listLoading"
:data="listData"
height="420"
border
highlight-current-row
@row-click="handleRowSelect"
>
<ElTableColumn width="56" align="center">
<template #default="{ row }">
<span
class="form-selector-check"
:class="{
'is-selected': selectedValues.has(String(row[valueField])),
}"
>
{{ selectedValues.has(String(row[valueField])) ? '✓' : '' }}
</span>
</template>
</ElTable>
</ElTableColumn>
<ElTableColumn
:prop="labelField"
:label="labelField"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn
:prop="valueField"
:label="valueField"
min-width="160"
show-overflow-tooltip
/>
</ElTable>
<div class="form-selector-pagination">
<ElPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="listTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<div v-else class="no-form-code">
{{ selectorText.selectFormFirst }}
{{ $t('form-design.attribute.selectFormFirst') }}
</div>
<template #footer>
<div class="flex w-full items-center">
<div class="flex-shrink-0">
<ElPagination
v-if="formCode"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<div class="flex flex-1 justify-center text-sm text-[var(--el-text-color-secondary)]">
<div class="flex w-full items-center justify-between">
<div class="text-sm text-[var(--el-text-color-secondary)]">
{{
selectorText.selectedCount(selectedValues.size)
$t('form-design.attribute.selectedCount', {
count: selectedValues.size,
})
}}
</div>
<div class="flex flex-shrink-0 gap-2">
<div class="flex gap-2">
<ElButton @click="dialogVisible = false">
{{ $t('common.cancel') }}
</ElButton>
@@ -472,12 +440,37 @@ function handleRemoveTag(value: string) {
width: 100%;
}
.form-selector-content :deep(.selected-row) {
background-color: var(--el-color-primary-light-9) !important;
.form-selector-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-selector-content :deep(.el-table__row) {
cursor: pointer;
.form-selector-toolbar {
display: flex;
gap: 8px;
}
.form-selector-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--el-color-primary);
border: 1px solid var(--el-border-color);
border-radius: 4px;
}
.form-selector-check.is-selected {
color: var(--el-color-white);
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.form-selector-pagination {
display: flex;
justify-content: flex-end;
}
.no-form-code {
@@ -1,18 +1,31 @@
export interface FormSelectorProps {
modelValue?: null | string | string[];
formCode?: string;
/** 值字段,默认 id */
valueField?: string;
/** 显示字段,默认 name */
labelField?: string;
/** 是否多选 */
multiple?: boolean;
/** 多选时是否折叠标签 */
collapseTags?: boolean;
/** 多选时折叠标签的最大显示数量 */
maxCollapseTags?: number;
/** 占位符 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否可清空 */
clearable?: boolean;
/** 弹窗标题 */
dialogTitle?: string;
/** 弹窗宽度 */
dialogWidth?: string;
/** 多选展开为多行模式:输入框不实时显示弹窗内的选中项 */
expandMultipleToRows?: boolean;
/** 外部预设的已选中值(用于 expandMultipleToRows 模式下同步子表已有行) */
externalSelectedValues?: string[];
/** 弹窗列表初始过滤条件(FormDataList.initialFilters 格式) */
initialFilters?: Record<string, { type: string; value: any }>;
}
File diff suppressed because it is too large Load Diff
@@ -87,7 +87,9 @@ watch(
<div class="linked-field-wrapper">
<ElInput
:model-value="displayValue || props.modelValue"
:placeholder="placeholder || '选择数据后自动带出'"
:placeholder="
placeholder || $t('form-design.attribute.linkedFieldPlaceholder')
"
:disabled="true"
readonly
class="linked-field"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
AlignJustify,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
List,
ListOrdered,
Minus,
Quote,
} from '@vben/icons';
interface CommandItem {
title: string;
description: string;
icon: string;
command: (props: any) => void;
}
interface Props {
items: CommandItem[];
command: (item: CommandItem) => void;
}
const props = defineProps<Props>();
const selectedIndex = ref(0);
const iconComponents: Record<string, any> = {
Text: AlignJustify,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Minus,
Bold,
Italic,
Highlighter,
};
const filteredItems = computed(() => props.items || []);
function selectItem(index: number) {
const item = filteredItems.value[index];
if (item) {
props.command(item);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value + filteredItems.value.length - 1) %
filteredItems.value.length;
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value + 1) % filteredItems.value.length;
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem(selectedIndex.value);
return true;
}
return false;
}
onMounted(() => {
selectedIndex.value = 0;
});
onUnmounted(() => {
selectedIndex.value = 0;
});
defineExpose({
onKeyDown,
});
</script>
<template>
<div class="slash-command-menu">
<div
v-for="(item, index) in filteredItems"
:key="index"
class="slash-command-item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
@mouseenter="selectedIndex = index"
>
<div class="slash-command-icon">
<component :is="iconComponents[item.icon]" class="h-4 w-4" />
</div>
<div class="slash-command-content">
<div class="slash-command-title">{{ item.title }}</div>
<div class="slash-command-description">{{ item.description }}</div>
</div>
</div>
<div v-if="filteredItems.length === 0" class="slash-command-empty">
没有找到匹配的命令
</div>
</div>
</template>
<style>
/* 覆盖 tippy.js 的默认样式 */
.tippy-box {
background-color: transparent !important;
color: inherit !important;
}
.tippy-content {
padding: 0 !important;
}
.tippy-arrow {
display: none !important;
}
</style>
<style scoped>
.slash-command-menu {
min-width: 280px;
max-width: 400px;
max-height: 400px;
overflow-y: auto;
background: var(--el-bg-color);
/* border: 1px solid var(--el-border-color); */
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
}
/* 美化滚动条 - 鼠标悬停时显示 */
.slash-command-menu::-webkit-scrollbar {
width: 6px;
}
.slash-command-menu::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.slash-command-menu::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
transition: background 0.2s;
}
.slash-command-menu:hover::-webkit-scrollbar-thumb {
background: var(--el-border-color);
}
.slash-command-menu::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-darker);
}
.slash-command-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background-color 0.15s;
}
.slash-command-item:hover,
.slash-command-item.is-selected {
background-color: var(--el-fill-color-light);
}
.slash-command-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
background-color: var(--el-fill-color);
color: var(--el-text-color-regular);
flex-shrink: 0;
}
.slash-command-content {
flex: 1;
min-width: 0;
}
.slash-command-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 0.125rem;
}
.slash-command-description {
font-size: 0.75rem;
color: var(--el-text-color-secondary);
}
.slash-command-empty {
padding: 1rem;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { GripVertical } from '@vben/icons';
import TableMenu from './TableMenu.vue';
interface Props {
editor: Editor;
}
const props = defineProps<Props>();
const showRowHandle = ref(false);
const showColumnHandle = ref(false);
const rowHandlePosition = ref({ top: 0, left: 0, height: 0 });
const columnHandlePosition = ref({ top: 0, left: 0, width: 0 });
const currentRowIndex = ref(-1);
const currentColumnIndex = ref(-1);
// 保存当前悬停的单元格,用于点击手柄时聚焦
let currentCell: HTMLElement | null = null;
const showMenu = ref(false);
const menuType = ref<'column' | 'row'>('row');
const menuPosition = ref({ x: 0, y: 0 });
// 用于追踪鼠标是否在手柄区域
const isMouseOverHandle = ref(false);
let hideTimeout: null | ReturnType<typeof setTimeout> = null;
let editorElement: HTMLElement | null = null;
function handleMouseMove(e: MouseEvent) {
if (!props.editor || !editorElement) return;
const target = e.target as HTMLElement;
const cell = target.closest('td, th') as HTMLElement;
const table = target.closest('table') as HTMLElement;
if (!cell || !table) {
// 不立即隐藏,给用户时间移动到手柄
scheduleHide();
return;
}
// 取消隐藏计时器
cancelHide();
const row = cell.closest('tr') as HTMLElement;
if (!row) return;
const tableRect = table.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
// 计算行索引
const rows = [...table.querySelectorAll('tr')] as HTMLElement[];
currentRowIndex.value = rows.indexOf(row);
// 计算列索引
const cells = [...row.querySelectorAll('td, th')];
currentColumnIndex.value = cells.indexOf(cell);
// 保存当前单元格引用
currentCell = cell;
// 行手柄位置(左侧)- 高度与行高一致
rowHandlePosition.value = {
top: rowRect.top,
left: tableRect.left - 20,
height: rowRect.height,
};
// 列手柄位置(顶部)- 宽度与列宽一致
columnHandlePosition.value = {
top: tableRect.top - 20,
left: cellRect.left,
width: cellRect.width,
};
showRowHandle.value = true;
showColumnHandle.value = true;
}
function scheduleHide() {
if (hideTimeout) return;
hideTimeout = setTimeout(() => {
if (!isMouseOverHandle.value && !showMenu.value) {
showRowHandle.value = false;
showColumnHandle.value = false;
}
hideTimeout = null;
}, 300);
}
function cancelHide() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
function handleMouseLeave() {
scheduleHide();
}
function handleHandleMouseEnter() {
isMouseOverHandle.value = true;
cancelHide();
}
function handleHandleMouseLeave() {
isMouseOverHandle.value = false;
scheduleHide();
}
function handleRowClick(e: MouseEvent) {
e.stopPropagation();
// 选中当前行
if (currentRowIndex.value >= 0) {
selectRow(currentRowIndex.value);
}
menuType.value = 'row';
menuPosition.value = { x: e.clientX, y: e.clientY };
showMenu.value = true;
// 点击其他地方关闭菜单
const closeMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 如果点击的是菜单内部,不关闭
if (target.closest('.table-menu')) return;
showMenu.value = false;
showRowHandle.value = false;
showColumnHandle.value = false;
document.removeEventListener('click', closeMenu);
};
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 0);
}
function handleColumnClick(e: MouseEvent) {
e.stopPropagation();
// 选中当前列
if (currentColumnIndex.value >= 0) {
selectColumn(currentColumnIndex.value);
}
menuType.value = 'column';
menuPosition.value = { x: e.clientX, y: e.clientY };
showMenu.value = true;
// 点击其他地方关闭菜单
const closeMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 如果点击的是菜单内部,不关闭
if (target.closest('.table-menu')) return;
showMenu.value = false;
showRowHandle.value = false;
showColumnHandle.value = false;
document.removeEventListener('click', closeMenu);
};
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 0);
}
function focusCurrentCell() {
if (!currentCell || !props.editor) return;
// 获取单元格在编辑器中的位置并聚焦
const view = props.editor.view;
const pos = view.posAtDOM(currentCell, 0);
if (pos >= 0) {
props.editor.chain().focus().setTextSelection(pos).run();
}
}
function selectRow(_rowIndex: number) {
focusCurrentCell();
}
function selectColumn(_colIndex: number) {
focusCurrentCell();
}
function closeMenu() {
showMenu.value = false;
}
onMounted(() => {
nextTick(() => {
editorElement = document.querySelector(
'.notion-editor-wrapper .ProseMirror',
);
if (editorElement) {
editorElement.addEventListener('mousemove', handleMouseMove);
editorElement.addEventListener('mouseleave', handleMouseLeave);
}
});
});
onBeforeUnmount(() => {
if (editorElement) {
editorElement.removeEventListener('mousemove', handleMouseMove);
editorElement.removeEventListener('mouseleave', handleMouseLeave);
}
cancelHide();
});
</script>
<template>
<!-- 行手柄 -->
<Teleport to="body">
<div
v-if="showRowHandle"
class="table-handle row-handle"
:style="{
top: `${rowHandlePosition.top}px`,
left: `${rowHandlePosition.left}px`,
height: `${rowHandlePosition.height}px`,
}"
@click="handleRowClick"
@mouseenter="handleHandleMouseEnter"
@mouseleave="handleHandleMouseLeave"
>
<GripVertical class="handle-icon" />
</div>
</Teleport>
<!-- 列手柄 -->
<Teleport to="body">
<div
v-if="showColumnHandle"
class="table-handle column-handle"
:style="{
top: `${columnHandlePosition.top}px`,
left: `${columnHandlePosition.left}px`,
width: `${columnHandlePosition.width}px`,
}"
@click="handleColumnClick"
@mouseenter="handleHandleMouseEnter"
@mouseleave="handleHandleMouseLeave"
>
<GripVertical class="handle-icon rotated" />
</div>
</Teleport>
<!-- 菜单 -->
<Teleport to="body">
<TableMenu
v-if="showMenu"
:editor="editor"
:type="menuType"
:position="menuPosition"
@close="closeMenu"
/>
</Teleport>
</template>
<style scoped>
.table-handle {
position: fixed;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-light);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
}
.table-handle:hover {
background: var(--el-fill-color);
}
.row-handle {
width: 16px;
}
.column-handle {
height: 16px;
}
.handle-icon {
width: 12px;
height: 12px;
color: var(--el-text-color-secondary);
}
.handle-icon.rotated {
transform: rotate(90deg);
}
</style>
@@ -0,0 +1,320 @@
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';
import { computed, ref } from 'vue';
import {
ArrowLeft,
ArrowRight,
ChevronRight,
Copy,
MoveDown,
MoveUp,
Paintbrush,
Plus,
Trash2,
X,
} from '@vben/icons';
interface Props {
editor: Editor;
type: 'column' | 'row';
position: { x: number; y: number };
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const showColorSubmenu = ref(false);
const isRow = computed(() => props.type === 'row');
const cellColors = [
{ name: '默认', value: '' },
{ name: '浅灰', value: '#f1f5f9' },
{ name: '浅红', value: '#fee2e2' },
{ name: '浅橙', value: '#ffedd5' },
{ name: '浅黄', value: '#fef9c3' },
{ name: '浅绿', value: '#dcfce7' },
{ name: '浅蓝', value: '#dbeafe' },
{ name: '浅紫', value: '#f3e8ff' },
{ name: '浅粉', value: '#fce7f3' },
];
function moveUp() {
// Tiptap 没有直接的移动行/列命令
// 暂不实现
emit('close');
}
function moveDown() {
emit('close');
}
function insertBefore() {
if (isRow.value) {
(props.editor.chain().focus() as any).addRowBefore().run();
} else {
(props.editor.chain().focus() as any).addColumnBefore().run();
}
emit('close');
}
function insertAfter() {
if (isRow.value) {
(props.editor.chain().focus() as any).addRowAfter().run();
} else {
(props.editor.chain().focus() as any).addColumnAfter().run();
}
emit('close');
}
function deleteRowOrColumn() {
if (isRow.value) {
(props.editor.chain().focus() as any).deleteRow().run();
} else {
(props.editor.chain().focus() as any).deleteColumn().run();
}
emit('close');
}
function duplicateRowOrColumn() {
// 复制行/列:先插入,然后复制内容(简化实现)
if (isRow.value) {
(props.editor.chain().focus() as any).addRowAfter().run();
} else {
(props.editor.chain().focus() as any).addColumnAfter().run();
}
emit('close');
}
function clearContents() {
// 清空内容:选中单元格后删除内容
// 简化实现
emit('close');
}
function setCellBackground(color: string) {
if (color) {
(props.editor.chain().focus() as any)
.setCellAttribute('backgroundColor', color)
.run();
} else {
(props.editor.chain().focus() as any)
.setCellAttribute('backgroundColor', null)
.run();
}
showColorSubmenu.value = false;
emit('close');
}
</script>
<template>
<div
class="table-menu"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
@click.stop
>
<div class="menu-content">
<!-- 移动操作 -->
<button class="menu-item" @click="moveUp" :disabled="true">
<component :is="isRow ? MoveUp : ArrowLeft" class="menu-icon" />
<span>{{ isRow ? '上移行' : '左移列' }}</span>
</button>
<button class="menu-item" @click="moveDown" :disabled="true">
<component :is="isRow ? MoveDown : ArrowRight" class="menu-icon" />
<span>{{ isRow ? '下移行' : '右移列' }}</span>
</button>
<div class="menu-divider"></div>
<!-- 插入操作 -->
<button class="menu-item" @click="insertBefore">
<Plus class="menu-icon" />
<span>{{ isRow ? '在上方插入行' : '在左侧插入列' }}</span>
</button>
<button class="menu-item" @click="insertAfter">
<Plus class="menu-icon" />
<span>{{ isRow ? '在下方插入行' : '在右侧插入列' }}</span>
</button>
<div class="menu-divider"></div>
<!-- 颜色 -->
<div
class="menu-item-with-submenu"
@mouseenter="showColorSubmenu = true"
@mouseleave="showColorSubmenu = false"
>
<button class="menu-item">
<Paintbrush class="menu-icon" />
<span>颜色</span>
<ChevronRight class="submenu-arrow" />
</button>
<div v-if="showColorSubmenu" class="submenu color-submenu">
<button
v-for="color in cellColors"
:key="color.value"
class="color-item"
@click="setCellBackground(color.value)"
>
<span
class="color-preview"
:style="{
backgroundColor: color.value || '#ffffff',
border: color.value
? 'none'
: '1px solid var(--el-border-color)',
}"
></span>
<span>{{ color.name }}</span>
</button>
</div>
</div>
<!-- 清空内容仅列 -->
<button
v-if="!isRow"
class="menu-item"
@click="clearContents"
:disabled="true"
>
<X class="menu-icon" />
<span>清空列内容</span>
</button>
<div class="menu-divider"></div>
<!-- 复制 -->
<button class="menu-item" @click="duplicateRowOrColumn">
<Copy class="menu-icon" />
<span>{{ isRow ? '复制行' : '复制列' }}</span>
</button>
<!-- 删除 -->
<button class="menu-item menu-item-danger" @click="deleteRowOrColumn">
<Trash2 class="menu-icon" />
<span>{{ isRow ? '删除行' : '删除列' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.table-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
}
.menu-content {
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--el-text-color-primary);
border-radius: 4px;
transition: background-color 0.15s;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background-color: var(--el-fill-color-light);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item-danger {
color: var(--el-color-danger);
}
.menu-item-danger:hover:not(:disabled) {
background-color: var(--el-color-danger-light-9);
}
.menu-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.menu-divider {
height: 1px;
background-color: var(--el-border-color-lighter);
margin: 4px 8px;
}
.menu-item-with-submenu {
position: relative;
}
.submenu-arrow {
width: 14px;
height: 14px;
margin-left: auto;
opacity: 0.5;
}
.submenu {
position: absolute;
left: calc(100% - 4px);
top: -4px;
min-width: 140px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
padding-left: 8px;
}
.color-submenu {
min-width: 120px;
}
.color-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--el-text-color-primary);
border-radius: 4px;
transition: background-color 0.15s;
}
.color-item:hover {
background-color: var(--el-fill-color-light);
}
.color-preview {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,237 @@
import type { Instance as TippyInstance } from 'tippy.js';
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
import { VueRenderer } from '@tiptap/vue-3';
import tippy from 'tippy.js';
import SlashCommandMenu from '../components/SlashCommandMenu.vue';
export const SlashCommand = Extension.create({
name: 'slashCommand',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: false,
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
export function createSlashCommandSuggestion() {
return {
items: ({ query }: { query: string }) => {
const commands = [
{
title: '正文',
description: '普通段落文本',
icon: 'Text',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
{
title: '标题 1',
description: '大标题',
icon: 'Heading1',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 1 })
.run();
},
},
{
title: '标题 2',
description: '中标题',
icon: 'Heading2',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 2 })
.run();
},
},
{
title: '标题 3',
description: '小标题',
icon: 'Heading3',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 3 })
.run();
},
},
{
title: '无序列表',
description: '创建无序列表',
icon: 'List',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: '有序列表',
description: '创建有序列表',
icon: 'ListOrdered',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: '引用',
description: '创建引用块',
icon: 'Quote',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: '代码块',
description: '创建代码块',
icon: 'Code',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: '分割线',
description: '插入分割线',
icon: 'Minus',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: '表格',
description: '插入表格',
icon: 'Table',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: '加粗',
description: '加粗文本',
icon: 'Bold',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBold().run();
},
},
{
title: '斜体',
description: '斜体文本',
icon: 'Italic',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleItalic().run();
},
},
{
title: '高亮',
description: '高亮文本',
icon: 'Highlighter',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleHighlight({ color: '#fef08a' })
.run();
},
},
];
return commands.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
},
render: () => {
let component: null | VueRenderer = null;
let popup: null | TippyInstance[] = null;
return {
onStart: (props: any) => {
component = new VueRenderer(SlashCommandMenu, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
const referenceElement = document.createElement('div');
const instances = tippy(referenceElement, {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element as HTMLElement,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
popup = Array.isArray(instances) ? instances : [instances];
},
onUpdate(props: any) {
if (!component) return;
component.updateProps(props);
if (!props.clientRect || !popup) {
return;
}
popup[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit() {
if (popup) {
popup[0]?.destroy();
}
if (component) {
component.destroy();
}
},
};
},
};
}
@@ -0,0 +1 @@
export { default as NotionEditor } from './NotionEditor.vue';
@@ -0,0 +1,2 @@
export { default as QRCodeGenerator } from './qrcode-generator.vue';
export * from './types';
@@ -0,0 +1,311 @@
<script setup lang="ts">
import type { QRCodeGeneratorEmits, QRCodeGeneratorProps } from './types';
import { computed, watch } from 'vue';
import { Copy, Download, QrCode } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
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 contentOptions = computed(() => ({
boundField: props.boundField,
contentType: props.qrcodeType,
dataSource: props.dataSource,
formData: props.formData,
formula: props.formula,
modelValue: props.modelValue,
vcardInfo: props.vcardInfo,
wifiInfo: props.wifiInfo,
}));
const qrcodeContent = useCodeContent(contentOptions);
const placeholderText = computed(
() => props.placeholder || $t('form-design.qrcode.placeholder'),
);
const containerStyle = computed(() => ({
backgroundColor: props.backgroundColor,
height: `${props.size}px`,
width: `${props.size}px`,
}));
const svgMarkup = computed(() => {
const content = qrcodeContent.value;
if (!content) return '';
const cells = 29;
const margin = Math.max(0, props.margin || 0);
const cellSize = Math.max(2, Math.floor((props.size - margin * 2) / cells));
const canvasSize = cellSize * cells + margin * 2;
const hash = hashString(content);
const rects: string[] = [];
for (let y = 0; y < cells; y += 1) {
for (let x = 0; x < cells; x += 1) {
if (isFinderCell(x, y, cells) || shouldFillCell(x, y, hash, content)) {
rects.push(
`<rect x="${margin + x * cellSize}" y="${margin + y * cellSize}" width="${cellSize}" height="${cellSize}" fill="${props.foregroundColor}" />`,
);
}
}
}
const logo = props.logoUrl
? `<image href="${escapeXml(props.logoUrl)}" x="${(canvasSize - props.logoSize) / 2}" y="${(canvasSize - props.logoSize) / 2}" width="${props.logoSize}" height="${props.logoSize}" />`
: '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasSize}" height="${canvasSize}" viewBox="0 0 ${canvasSize} ${canvasSize}"><rect width="100%" height="100%" fill="${props.backgroundColor}" />${rects.join('')}${logo}</svg>`;
});
const qrcodeUrl = computed(() =>
svgMarkup.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup.value)}`
: '',
);
function hashString(value: string) {
let hash = 2166136261;
for (const char of value) {
hash ^= char.charCodeAt(0);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
function isFinderCell(x: number, y: number, cells: number) {
const inBox = (startX: number, startY: number) =>
x >= startX && x < startX + 7 && y >= startY && y < startY + 7;
const local = (startX: number, startY: number) => ({
x: x - startX,
y: y - startY,
});
for (const [startX, startY] of [
[0, 0],
[cells - 7, 0],
[0, cells - 7],
]) {
if (!inBox(startX, startY)) continue;
const point = local(startX, startY);
return (
point.x === 0 ||
point.x === 6 ||
point.y === 0 ||
point.y === 6 ||
(point.x >= 2 && point.x <= 4 && point.y >= 2 && point.y <= 4)
);
}
return false;
}
function shouldFillCell(x: number, y: number, hash: number, content: string) {
const charCode = content.charCodeAt((x + y * 7) % content.length);
const mixed = hash + x * 73_856_093 + y * 19_349_663 + charCode * 83_492_791;
return (mixed & 3) === 0 || ((mixed >> 3) & 5) === 1;
}
function escapeXml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function downloadQRCode() {
if (!qrcodeUrl.value) return;
const link = document.createElement('a');
link.download = `${props.downloadFilename}.svg`;
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'));
}
}
watch(
() => qrcodeUrl.value,
(url) => {
if (url) emit('generated', qrcodeContent.value);
},
{ 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"
>
<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>
<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;
gap: 12px;
align-items: center;
}
.qrcode-generator__container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.qrcode-generator__container--empty {
border-style: dashed;
}
.qrcode-generator__container--disabled {
cursor: not-allowed;
opacity: 0.6;
}
.qrcode-generator__image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.qrcode-generator__placeholder {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
color: var(--el-text-color-placeholder);
}
.qrcode-generator__placeholder-icon {
width: 48px;
height: 48px;
opacity: 0.5;
}
.qrcode-generator__placeholder-text {
max-width: 80%;
font-size: 12px;
text-align: center;
}
.qrcode-generator__content {
max-width: 100%;
padding: 0 8px;
}
.qrcode-generator__content-text {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.qrcode-generator__actions {
display: flex;
gap: 8px;
}
</style>
@@ -0,0 +1,33 @@
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;
}
@@ -0,0 +1,654 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
Maximize2,
RotateCw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElInputNumber } from 'element-plus';
const props = defineProps<{
deleteNode: () => void;
editor: any;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 图片属性
const imgRef = ref<HTMLImageElement | null>(null);
const isResizing = ref(false);
const startX = ref(0);
const startY = ref(0);
const startWidth = ref(0);
const startHeight = ref(0);
const resizeDirection = ref<string>('');
// 右键菜单
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showSizePanel = ref(false);
// 尺寸输入
const widthInput = ref<number>(0);
const heightInput = ref<number>(0);
const keepRatio = ref(true);
const originalRatio = ref(1);
// 预设尺寸
const presetSizes = [
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
// 计算图片样式
const imageStyle = computed(() => {
const style: Record<string, string> = {};
if (props.node.attrs.width) {
style.width =
typeof props.node.attrs.width === 'number'
? `${props.node.attrs.width}px`
: props.node.attrs.width;
}
if (props.node.attrs.height) {
style.height =
typeof props.node.attrs.height === 'number'
? `${props.node.attrs.height}px`
: props.node.attrs.height;
}
return style;
});
// 计算容器样式(对齐)
const wrapperStyle = computed(() => {
const alignment = props.node.attrs.alignment || 'center';
const justifyMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
return {
justifyContent: justifyMap[alignment] || 'center',
};
});
// 图片加载完成后获取原始尺寸
const handleImageLoad = () => {
if (imgRef.value) {
const img = imgRef.value;
originalRatio.value = img.naturalWidth / img.naturalHeight;
if (props.node.attrs.width) {
widthInput.value =
typeof props.node.attrs.width === 'number'
? props.node.attrs.width
: Number.parseInt(props.node.attrs.width) || img.naturalWidth;
heightInput.value =
typeof props.node.attrs.height === 'number'
? props.node.attrs.height
: Number.parseInt(props.node.attrs.height) || img.naturalHeight;
} else {
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
}
};
// 右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
contextMenuVisible.value = true;
showSizePanel.value = false;
// 点击其他地方关闭菜单
nextTick(() => {
document.addEventListener('click', closeContextMenu);
document.addEventListener('contextmenu', closeContextMenu);
});
};
const closeContextMenu = () => {
contextMenuVisible.value = false;
showSizePanel.value = false;
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
};
// 开始调整大小
const startResize = (e: MouseEvent, direction: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeDirection.value = direction;
startX.value = e.clientX;
startY.value = e.clientY;
if (imgRef.value) {
startWidth.value = imgRef.value.offsetWidth;
startHeight.value = imgRef.value.offsetHeight;
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
// 处理调整大小
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - startX.value;
const deltaY = e.clientY - startY.value;
let newWidth = startWidth.value;
let newHeight = startHeight.value;
if (resizeDirection.value.includes('e')) {
newWidth = Math.max(50, startWidth.value + deltaX);
}
if (resizeDirection.value.includes('w')) {
newWidth = Math.max(50, startWidth.value - deltaX);
}
if (resizeDirection.value.includes('s')) {
newHeight = Math.max(50, startHeight.value + deltaY);
}
if (resizeDirection.value.includes('n')) {
newHeight = Math.max(50, startHeight.value - deltaY);
}
// 保持比例
if (keepRatio.value && originalRatio.value) {
if (
resizeDirection.value.includes('e') ||
resizeDirection.value.includes('w')
) {
newHeight = Math.round(newWidth / originalRatio.value);
} else {
newWidth = Math.round(newHeight * originalRatio.value);
}
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
};
// 停止调整大小
const stopResize = () => {
isResizing.value = false;
resizeDirection.value = '';
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 设置对齐方式
const setAlignment = (alignment: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment });
closeContextMenu();
};
// 设置预设尺寸(百分比)
const setPresetSize = (percent: number) => {
if (imgRef.value) {
const editorEl = props.editor?.view?.dom?.parentElement;
const containerWidth = editorEl?.clientWidth || 800;
const maxWidth = containerWidth - 32;
const newWidth = Math.round((maxWidth * percent) / 100);
const newHeight = Math.round(newWidth / originalRatio.value);
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
}
closeContextMenu();
};
// 切换自定义尺寸面板
const toggleSizePanel = () => {
showSizePanel.value = !showSizePanel.value;
};
// 手动输入宽度
const handleWidthChange = (value: number | undefined) => {
if (!value) return;
const newWidth = value;
let newHeight = heightInput.value;
if (keepRatio.value && originalRatio.value) {
newHeight = Math.round(newWidth / originalRatio.value);
heightInput.value = newHeight;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 手动输入高度
const handleHeightChange = (value: number | undefined) => {
if (!value) return;
const newHeight = value;
let newWidth = widthInput.value;
if (keepRatio.value && originalRatio.value) {
newWidth = Math.round(newHeight * originalRatio.value);
widthInput.value = newWidth;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 重置为原始尺寸
const resetSize = () => {
if (imgRef.value) {
const img = imgRef.value;
props.updateAttributes({
width: img.naturalWidth,
height: img.naturalHeight,
});
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
closeContextMenu();
};
// 删除图片
const handleDelete = () => {
props.deleteNode();
closeContextMenu();
};
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
});
</script>
<template>
<NodeViewWrapper class="resizable-image-wrapper flex" :style="wrapperStyle">
<div
class="resizable-image-container relative inline-block"
:class="{ 'is-selected': selected, 'is-resizing': isResizing }"
>
<!-- 图片 -->
<img
ref="imgRef"
:src="node.attrs.src"
:alt="node.attrs.alt"
:title="node.attrs.title"
:style="imageStyle"
class="block max-w-full rounded"
draggable="false"
@load="handleImageLoad"
@contextmenu="handleContextMenu"
/>
<!-- 选中时显示调整手柄 -->
<template v-if="selected">
<div
class="resize-handle resize-handle-nw"
@mousedown="(e) => startResize(e, 'nw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="(e) => startResize(e, 'ne')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="(e) => startResize(e, 'sw')"
></div>
<div
class="resize-handle resize-handle-se"
@mousedown="(e) => startResize(e, 'se')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="contextMenuVisible"
class="image-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-group">
<div class="menu-group-title">对齐方式</div>
<div class="menu-row">
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'left' }"
title="左对齐"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'center' }"
title="居中"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'right' }"
title="右对齐"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 快速缩放 -->
<div class="menu-group">
<div class="menu-group-title">快速缩放</div>
<div class="menu-row">
<button
v-for="size in presetSizes"
:key="size.value"
class="menu-size-btn"
@click="setPresetSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 自定义尺寸 -->
<button class="menu-item" @click="toggleSizePanel">
<Maximize2 class="menu-item-icon" />
<span>自定义尺寸</span>
</button>
<!-- 尺寸面板 -->
<div v-if="showSizePanel" class="size-panel">
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="widthInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleWidthChange"
/>
</div>
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="heightInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleHeightChange"
/>
</div>
<label class="size-checkbox">
<input v-model="keepRatio" type="checkbox" />
<span>保持比例</span>
</label>
</div>
<div class="menu-divider"></div>
<!-- 重置尺寸 -->
<button class="menu-item" @click="resetSize">
<RotateCw class="menu-item-icon" />
<span>重置尺寸</span>
</button>
<!-- 删除 -->
<button class="menu-item menu-item-danger" @click="handleDelete">
<Trash2 class="menu-item-icon" />
<span>删除图片</span>
</button>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-image-wrapper {
margin: 0.5em 0;
}
.resizable-image-container {
position: relative;
display: inline-block;
line-height: 0;
&.is-selected {
img {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
}
&.is-resizing {
user-select: none;
}
}
/* 调整手柄样式 - 只保留四个角 */
.resize-handle {
position: absolute;
z-index: 10;
width: 10px;
height: 10px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 2px;
&:hover {
background-color: var(--el-color-primary-dark-2);
}
}
.resize-handle-nw {
top: -5px;
left: -5px;
cursor: nw-resize;
}
.resize-handle-ne {
top: -5px;
right: -5px;
cursor: ne-resize;
}
.resize-handle-sw {
bottom: -5px;
left: -5px;
cursor: sw-resize;
}
.resize-handle-se {
right: -5px;
bottom: -5px;
cursor: se-resize;
}
/* 右键菜单样式 */
.image-context-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
padding: 6px 0;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-group {
padding: 6px 12px;
}
.menu-group-title {
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.menu-row {
display: flex;
gap: 4px;
}
.menu-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
&.active {
color: #fff;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
.menu-size-btn {
flex: 1;
height: 28px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
}
.menu-divider {
height: 1px;
margin: 6px 0;
background-color: var(--el-border-color-lighter);
}
.menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: none;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
&.menu-item-danger {
color: var(--el-color-danger);
&:hover {
background-color: var(--el-color-danger-light-9);
}
}
}
.menu-item-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 尺寸面板 */
.size-panel {
padding: 8px 12px;
background-color: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
}
.size-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.size-label {
width: 20px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.size-checkbox {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
input {
width: 14px;
height: 14px;
cursor: pointer;
}
}
:deep(.el-input-number--small) {
width: 100px;
}
</style>
@@ -0,0 +1,536 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
RotateCcw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElButton, ElCheckbox, ElInputNumber } from 'element-plus';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
deleteNode: () => void;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 状态
const isSelected = ref(false);
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showCustomSize = ref(false);
const keepRatio = ref(true);
const videoRef = ref<HTMLVideoElement | null>(null);
const originalSize = ref({ width: 0, height: 0 });
// 视频URL(响应式,支持token刷新)
const resolvedVideoSrc = ref('');
// 异步解析视频URL
async function resolveVideoUrl() {
const id = props.node.attrs.id;
const src = props.node.attrs.src;
// 如果有文件ID,优先使用ID获取带token的URL
if (id) {
resolvedVideoSrc.value = await getFileUrl(id);
} else if (src) {
resolvedVideoSrc.value = src;
} else {
resolvedVideoSrc.value = '';
}
}
// 监听属性变化
watch(
() => [props.node.attrs.id, props.node.attrs.src],
() => {
resolveVideoUrl();
},
{ immediate: true },
);
// 计算属性
const videoSrc = computed(() => resolvedVideoSrc.value);
const currentWidth = computed(() => {
const w = props.node.attrs.width;
if (typeof w === 'string' && w.endsWith('%')) {
return Number.parseInt(w);
}
return w || 100;
});
const currentHeight = computed(() => props.node.attrs.height || 'auto');
const alignment = computed(() => props.node.attrs.alignment || 'center');
const nodeStyle = computed(() => {
const styles: Record<string, string> = {
display: 'flex',
};
if (alignment.value === 'left') {
styles.justifyContent = 'flex-start';
} else if (alignment.value === 'right') {
styles.justifyContent = 'flex-end';
} else {
styles.justifyContent = 'center';
}
return styles;
});
const containerStyle = computed(() => {
const styles: Record<string, string> = {};
const w = props.node.attrs.width;
if (typeof w === 'number') {
styles.width = `${w}px`;
} else if (typeof w === 'string') {
styles.width = w;
} else {
styles.width = '100%';
}
return styles;
});
// 方法
const handleClick = () => {
isSelected.value = true;
};
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isSelected.value = true;
showContextMenu.value = true;
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
};
const hideContextMenu = () => {
showContextMenu.value = false;
showCustomSize.value = false;
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest('.resizable-video-wrapper') &&
!target.closest('.video-context-menu')
) {
isSelected.value = false;
hideContextMenu();
}
};
const setAlignment = (align: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment: align });
};
const setPresetSize = (percent: number) => {
props.updateAttributes({ width: `${percent}%`, height: 'auto' });
hideContextMenu();
};
const updateWidth = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.height / originalSize.value.width;
props.updateAttributes({ width: value, height: Math.round(value * ratio) });
} else {
props.updateAttributes({ width: value });
}
};
const updateHeight = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.width / originalSize.value.height;
props.updateAttributes({ height: value, width: Math.round(value * ratio) });
} else {
props.updateAttributes({ height: value });
}
};
const resetSize = () => {
props.updateAttributes({ width: '100%', height: 'auto' });
hideContextMenu();
};
const handleDelete = () => {
props.deleteNode();
};
// 拖拽调整大小
const isResizing = ref(false);
const resizeStartPos = ref({ x: 0, y: 0 });
const resizeStartSize = ref({ width: 0, height: 0 });
const resizeCorner = ref('');
const startResize = (e: MouseEvent, corner: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeCorner.value = corner;
resizeStartPos.value = { x: e.clientX, y: e.clientY };
const wrapper = (e.target as HTMLElement).closest(
'.resizable-video-wrapper',
) as HTMLElement;
if (wrapper) {
resizeStartSize.value = {
width: wrapper.offsetWidth,
height: wrapper.offsetHeight,
};
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStartPos.value.x;
// 左侧控制点需要反向计算
const isLeftCorner =
resizeCorner.value === 'nw' || resizeCorner.value === 'sw';
const adjustedDeltaX = isLeftCorner ? -deltaX : deltaX;
let newWidth = resizeStartSize.value.width + adjustedDeltaX;
let newHeight = resizeStartSize.value.height;
// 保持比例
if (originalSize.value.width && originalSize.value.height) {
const ratio = originalSize.value.height / originalSize.value.width;
newHeight = Math.round(newWidth * ratio);
}
// 限制最小尺寸
newWidth = Math.max(100, newWidth);
newHeight = Math.max(60, newHeight);
props.updateAttributes({ width: newWidth, height: newHeight });
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 获取视频原始尺寸
const handleVideoLoaded = () => {
if (videoRef.value) {
originalSize.value = {
width: videoRef.value.videoWidth,
height: videoRef.value.videoHeight,
};
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
});
</script>
<template>
<NodeViewWrapper class="resizable-video-node" :style="nodeStyle">
<div
class="resizable-video-wrapper"
:class="{ 'is-selected': isSelected || selected }"
:style="containerStyle"
@click="handleClick"
@contextmenu="handleContextMenu"
>
<video
ref="videoRef"
:src="videoSrc"
controls
class="resizable-video"
@loadedmetadata="handleVideoLoaded"
>
您的浏览器不支持视频播放
</video>
<!-- 调整大小的控制点 -->
<template v-if="isSelected || selected">
<div
class="resize-handle resize-handle-se"
@mousedown="startResize($event, 'se')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="startResize($event, 'sw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="startResize($event, 'ne')"
></div>
<div
class="resize-handle resize-handle-nw"
@mousedown="startResize($event, 'nw')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="showContextMenu"
class="video-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-section">
<div class="menu-label">对齐方式</div>
<div class="menu-buttons">
<ElButton
:type="alignment === 'left' ? 'primary' : 'default'"
size="small"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'center' ? 'primary' : 'default'"
size="small"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'right' ? 'primary' : 'default'"
size="small"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</ElButton>
</div>
</div>
<!-- 快速缩放 -->
<div class="menu-section">
<div class="menu-label">快速缩放</div>
<div class="menu-buttons">
<ElButton size="small" @click="setPresetSize(25)">25%</ElButton>
<ElButton size="small" @click="setPresetSize(50)">50%</ElButton>
<ElButton size="small" @click="setPresetSize(75)">75%</ElButton>
<ElButton size="small" @click="setPresetSize(100)">100%</ElButton>
</div>
</div>
<!-- 自定义尺寸 -->
<div class="menu-section">
<ElButton
size="small"
class="w-full"
@click="showCustomSize = !showCustomSize"
>
自定义尺寸
</ElButton>
<div v-if="showCustomSize" class="custom-size-panel">
<div class="size-inputs">
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentWidth === 'number' ? currentWidth : undefined
"
:min="100"
:max="1920"
size="small"
controls-position="right"
@update:model-value="updateWidth"
/>
</div>
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentHeight === 'number'
? currentHeight
: undefined
"
:min="60"
:max="1080"
size="small"
controls-position="right"
@update:model-value="updateHeight"
/>
</div>
</div>
<ElCheckbox v-model="keepRatio" size="small">保持比例</ElCheckbox>
</div>
</div>
<!-- 操作按钮 -->
<div class="menu-section menu-actions">
<ElButton size="small" @click="resetSize">
<RotateCcw class="mr-1 h-3 w-3" />
重置尺寸
</ElButton>
<ElButton size="small" type="danger" @click="handleDelete">
<Trash2 class="mr-1 h-3 w-3" />
删除视频
</ElButton>
</div>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-video-node {
display: block;
margin: 12px 0;
}
.resizable-video-wrapper {
position: relative;
display: inline-block;
max-width: 100%;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.resizable-video-wrapper:hover {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
.resizable-video-wrapper.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.resizable-video {
display: block;
width: 100%;
height: auto;
background-color: var(--el-fill-color-darker);
border-radius: 8px;
}
/* 调整大小控制点 */
.resize-handle {
position: absolute;
z-index: 10;
width: 12px;
height: 12px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 50%;
}
.resize-handle-se {
right: -6px;
bottom: -6px;
cursor: se-resize;
}
.resize-handle-sw {
bottom: -6px;
left: -6px;
cursor: sw-resize;
}
.resize-handle-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.resize-handle-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
/* 右键菜单 */
.video-context-menu {
position: fixed;
z-index: 9999;
min-width: 200px;
padding: 12px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-section {
margin-bottom: 12px;
}
.menu-section:last-child {
margin-bottom: 0;
}
.menu-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.menu-buttons {
display: flex;
gap: 4px;
}
.custom-size-panel {
padding: 8px;
margin-top: 8px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.size-inputs {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.size-input-group {
display: flex;
gap: 8px;
align-items: center;
}
.size-label {
min-width: 24px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.menu-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,115 @@
import { mergeAttributes, Node } from '@tiptap/core';
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export interface AttachmentAttributes {
id: string;
name: string;
size: number;
type: string;
url: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
attachment: {
setAttachment: (options: AttachmentAttributes) => ReturnType;
};
}
}
export const Attachment = Node.create<AttachmentOptions>({
name: 'attachment',
group: 'block',
atom: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
id: {
default: null,
},
name: {
default: null,
},
size: {
default: 0,
},
type: {
default: null,
},
url: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="attachment"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, name, size, type, url } = HTMLAttributes;
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'attachment',
'data-id': id,
'data-name': name,
'data-size': size,
'data-file-type': type,
'data-url': url,
class: 'attachment-node',
}),
[
'a',
{
href: url,
target: '_blank',
download: name,
class: 'attachment-link',
},
['span', { class: 'attachment-icon' }, '📎'],
['span', { class: 'attachment-name' }, name],
['span', { class: 'attachment-size' }, formatFileSize(size)],
],
];
},
addCommands() {
return {
setAttachment:
(options: AttachmentAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
export default Attachment;
@@ -0,0 +1,66 @@
import { Extension } from '@tiptap/core';
export interface FontSizeOptions {
types: string[];
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const FontSize = Extension.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) =>
element.style.fontSize?.replace(/['"]+/g, ''),
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {};
}
return {
style: `font-size: ${attributes.fontSize}`,
};
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark('textStyle', { fontSize: null })
.removeEmptyTextStyle()
.run();
},
};
},
});
@@ -0,0 +1,57 @@
import Image from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableImageComponent from './ResizableImageComponent.vue';
export const ResizableImage = Image.extend({
name: 'resizableImage',
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (element) =>
element.getAttribute('width') ||
element.style.width?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
height: {
default: null,
parseHTML: (element) =>
element.getAttribute('height') ||
element.style.height?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.height) return {};
return { height: attributes.height };
},
},
alignment: {
default: 'center',
parseHTML: (element) => element.dataset.alignment || 'center',
renderHTML: (attributes) => {
const alignment = attributes.alignment || 'center';
// 同时输出 data-alignment 和 style
const styleMap: Record<string, string> = {
left: 'display: block; margin-left: 0; margin-right: auto;',
center: 'display: block; margin-left: auto; margin-right: auto;',
right: 'display: block; margin-left: auto; margin-right: 0;',
};
return {
'data-alignment': alignment,
style: styleMap[alignment] || styleMap.center,
};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ResizableImageComponent as any);
},
});
export default ResizableImage;
@@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableVideoComponent from './ResizableVideoComponent.vue';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
allowFullscreen: boolean;
}
export interface VideoAttributes {
id?: string;
src: string;
width?: number | string;
height?: number | string;
alignment?: 'center' | 'left' | 'right';
poster?: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: VideoAttributes) => ReturnType;
};
}
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
allowFullscreen: true,
};
},
addAttributes() {
return {
id: {
default: null,
},
src: {
default: null,
},
width: {
default: '100%',
},
height: {
default: 'auto',
},
alignment: {
default: 'center',
},
poster: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="video"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, src, width, poster, alignment } = HTMLAttributes;
let marginStyle = 'margin-left: auto; margin-right: auto;';
if (alignment === 'left') {
marginStyle = 'margin-right: auto;';
} else if (alignment === 'right') {
marginStyle = 'margin-left: auto;';
}
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'video',
'data-id': id,
class: 'video-node',
style: `max-width: ${typeof width === 'number' ? `${width}px` : width}; ${marginStyle}`,
}),
[
'video',
{
src,
controls: true,
poster,
style: 'width: 100%; height: auto; border-radius: 8px;',
},
'您的浏览器不支持视频播放',
],
];
},
addNodeView() {
return VueNodeViewRenderer(ResizableVideoComponent as any);
},
addCommands() {
return {
setVideo:
(options: VideoAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
export default Video;
@@ -1 +1,2 @@
export { default as RichTextEditor } from './rich-text-editor.vue';
export type * from './types';
@@ -1,159 +1,59 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
<script setup lang="ts">
import type {
LightRichTextEditorInstance,
RichTextEditorEmits,
RichTextEditorProps,
} from './types';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Heading1,
Image,
Italic,
Link,
List,
ListOrdered,
Quote,
Redo2,
Table,
Underline,
Undo2,
} from '@vben/icons';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import {
ElButton,
ElButtonGroup,
ElColorPicker,
ElDivider,
ElInput,
ElMessage,
ElPopover,
ElSelect,
ElOption,
} from 'element-plus';
import { Bold, Code, Eye, EyeOff, Italic, Link, List } from '@vben/icons';
const props = withDefaults(
defineProps<{
disabled?: boolean;
maxHeight?: number;
minHeight?: number;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
disabled: false,
maxHeight: 500,
minHeight: 200,
modelValue: '',
placeholder: '',
readonly: false,
},
import { ElButton, ElInput } from 'element-plus';
const props = withDefaults(defineProps<RichTextEditorProps>(), {
modelValue: '',
placeholder: '请输入内容...',
disabled: false,
readonly: false,
minHeight: 200,
maxHeight: 500,
showToolbar: true,
showWordCount: true,
maxLength: 0,
});
const emit = defineEmits<RichTextEditorEmits>();
const inputRef = ref<any>();
const localValue = ref(props.modelValue || '');
const previewMode = ref(false);
const disabledOrReadonly = computed(() => props.disabled || props.readonly);
const editorStyle = computed(() => ({
minHeight:
typeof props.minHeight === 'number'
? `${props.minHeight}px`
: props.minHeight,
maxHeight:
typeof props.maxHeight === 'number'
? `${props.maxHeight}px`
: props.maxHeight,
}));
const wordCount = computed(() =>
localValue.value.replaceAll(/<[^>]*>/g, '').trim().length,
);
const emit = defineEmits<{
blur: [event: FocusEvent];
change: [value: string];
focus: [event: FocusEvent];
'update:modelValue': [value: string];
}>();
const editorRef = ref<HTMLElement>();
const linkPopoverVisible = ref(false);
const imagePopoverVisible = ref(false);
const tablePopoverVisible = ref(false);
const linkUrl = ref('');
const imageUrl = ref('');
const tableRows = ref(3);
const tableCols = ref(3);
const currentColor = ref('#1f2937');
const currentBlock = ref('P');
let isSyncing = false;
function syncHtml(value = props.modelValue || '') {
if (!editorRef.value || editorRef.value.innerHTML === value) return;
isSyncing = true;
editorRef.value.innerHTML = value;
nextTick(() => {
isSyncing = false;
});
}
watch(
() => props.modelValue,
(value) => syncHtml(value || ''),
const textarea = computed<HTMLTextAreaElement | undefined>(
() => inputRef.value?.textarea,
);
function emitChange() {
if (isSyncing || !editorRef.value) return;
const html = editorRef.value.innerHTML;
emit('update:modelValue', html);
emit('change', html);
}
function focusEditor() {
editorRef.value?.focus();
}
function exec(command: string, value?: string) {
if (props.disabled || props.readonly) return;
focusEditor();
document.execCommand(command, false, value);
emitChange();
}
function applyBlock(value: string) {
currentBlock.value = value;
exec('formatBlock', value);
}
function applyColor(value: string | null) {
if (!value) return;
currentColor.value = value;
exec('foreColor', value);
}
function insertLink() {
const url = linkUrl.value.trim();
if (!url) {
ElMessage.warning('请输入链接地址');
return;
}
exec('createLink', url);
linkPopoverVisible.value = false;
linkUrl.value = '';
}
function insertImage() {
const url = imageUrl.value.trim();
if (!url) {
ElMessage.warning('请输入图片地址');
return;
}
exec('insertHTML', `<img src="${url}" alt="" />`);
imagePopoverVisible.value = false;
imageUrl.value = '';
}
function insertTable() {
const rows = Math.max(1, Math.min(10, tableRows.value));
const cols = Math.max(1, Math.min(8, tableCols.value));
const cells = Array.from({ length: cols })
.map(() => '<td><br></td>')
.join('');
const body = Array.from({ length: rows })
.map(() => `<tr>${cells}</tr>`)
.join('');
exec('insertHTML', `<table><tbody>${body}</tbody></table><p><br></p>`);
tablePopoverVisible.value = false;
}
function handlePaste(event: ClipboardEvent) {
const html = event.clipboardData?.getData('text/html');
const text = event.clipboardData?.getData('text/plain');
if (!html && !text) return;
event.preventDefault();
exec('insertHTML', html || (text || '').replaceAll('\n', '<br>'));
function syncValue(value: string) {
localValue.value = value;
emit('update:modelValue', value);
emit('change', value);
}
function handleFocus(event: FocusEvent) {
@@ -161,298 +61,224 @@ function handleFocus(event: FocusEvent) {
}
function handleBlur(event: FocusEvent) {
emitChange();
emit('blur', event);
}
nextTick(() => syncHtml());
async function insertSnippet(before: string, after = '') {
if (disabledOrReadonly.value || previewMode.value) return;
const input = textarea.value;
if (!input) {
syncValue(`${localValue.value}${before}${after}`);
return;
}
const start = input.selectionStart ?? localValue.value.length;
const end = input.selectionEnd ?? start;
const selected = localValue.value.slice(start, end);
const nextValue = `${localValue.value.slice(0, start)}${before}${selected}${after}${localValue.value.slice(end)}`;
const nextCursor = start + before.length + selected.length + after.length;
syncValue(nextValue);
await nextTick();
input.focus();
input.setSelectionRange(nextCursor, nextCursor);
}
function clearFormat() {
if (disabledOrReadonly.value) return;
syncValue(localValue.value.replaceAll(/<[^>]*>/g, ''));
}
const editorApi: LightRichTextEditorInstance = {
blur: () => textarea.value?.blur(),
focus: () => textarea.value?.focus(),
getHTML: () => localValue.value,
getMarkdown: () => localValue.value,
getText: () => localValue.value.replaceAll(/<[^>]*>/g, ''),
setContent: (value: string) => syncValue(value || ''),
};
watch(
() => props.modelValue,
(value) => {
if ((value || '') !== localValue.value) {
localValue.value = value || '';
}
},
);
onMounted(() => {
emit('ready', editorApi);
});
defineExpose(editorApi);
</script>
<template>
<div class="rich-text-editor" :class="{ 'is-disabled': disabled || readonly }">
<div class="rich-text-toolbar">
<ElSelect
v-model="currentBlock"
<div
class="rich-text-editor rounded border border-[var(--el-border-color)] bg-[var(--el-bg-color)]"
:class="{ 'is-disabled': disabled, 'is-readonly': readonly }"
>
<div
v-if="showToolbar"
class="toolbar flex flex-wrap items-center gap-1 border-b border-[var(--el-border-color)] bg-[var(--el-fill-color-lighter)] px-2 py-2"
>
<ElButton
text
size="small"
class="block-select"
:disabled="disabled || readonly"
@change="applyBlock"
title="加粗"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<strong>', '</strong>')"
>
<ElOption label="正文" value="P" />
<ElOption label="标题 1" value="H1" />
<ElOption label="标题 2" value="H2" />
<ElOption label="标题 3" value="H3" />
</ElSelect>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="Undo2"
size="small"
text
title="撤销"
@click="exec('undo')"
/>
<ElButton
:icon="Redo2"
size="small"
text
title="重做"
@click="exec('redo')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="Bold"
size="small"
text
title="加粗"
@click="exec('bold')"
/>
<ElButton
:icon="Italic"
size="small"
text
title="斜体"
@click="exec('italic')"
/>
<ElButton
:icon="Underline"
size="small"
text
title="下划线"
@click="exec('underline')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="AlignLeft"
size="small"
text
title="左对齐"
@click="exec('justifyLeft')"
/>
<ElButton
:icon="AlignCenter"
size="small"
text
title="居中"
@click="exec('justifyCenter')"
/>
<ElButton
:icon="AlignRight"
size="small"
text
title="右对齐"
@click="exec('justifyRight')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="List"
size="small"
text
title="无序列表"
@click="exec('insertUnorderedList')"
/>
<ElButton
:icon="ListOrdered"
size="small"
text
title="有序列表"
@click="exec('insertOrderedList')"
/>
<ElButton
:icon="Quote"
size="small"
text
title="引用"
@click="exec('formatBlock', 'BLOCKQUOTE')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElColorPicker
v-model="currentColor"
size="small"
:disabled="disabled || readonly"
@change="applyColor"
/>
<ElDivider direction="vertical" />
<ElPopover v-model:visible="linkPopoverVisible" width="300" trigger="click">
<template #reference>
<ElButton :icon="Link" size="small" text title="插入链接" />
</template>
<div class="popover-form">
<ElInput v-model="linkUrl" placeholder="https://example.com" />
<ElButton type="primary" size="small" @click="insertLink">插入链接</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="imagePopoverVisible" width="300" trigger="click">
<template #reference>
<ElButton :icon="Image" size="small" text title="插入图片" />
</template>
<div class="popover-form">
<ElInput v-model="imageUrl" placeholder="图片 URL" />
<ElButton type="primary" size="small" @click="insertImage">插入图片</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="tablePopoverVisible" width="260" trigger="click">
<template #reference>
<ElButton :icon="Table" size="small" text title="插入表格" />
</template>
<div class="table-popover">
<ElInput v-model.number="tableRows" type="number" min="1" max="10">
<template #prepend></template>
</ElInput>
<ElInput v-model.number="tableCols" type="number" min="1" max="8">
<template #prepend></template>
</ElInput>
<ElButton type="primary" size="small" @click="insertTable">插入表格</ElButton>
</div>
</ElPopover>
<Bold class="h-4 w-4" />
</ElButton>
<ElButton
:icon="Eraser"
size="small"
text
title="清除格式"
@click="exec('removeFormat')"
/>
size="small"
title="斜体"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<em>', '</em>')"
>
<Italic class="h-4 w-4" />
</ElButton>
<ElButton
:icon="Heading1"
size="small"
text
title="标题"
@click="applyBlock('H2')"
size="small"
title="链接"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<a href=&quot;&quot;>', '</a>')"
>
<Link class="h-4 w-4" />
</ElButton>
<ElButton
text
size="small"
title="列表"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<ul><li>', '</li></ul>')"
>
<List class="h-4 w-4" />
</ElButton>
<ElButton
text
size="small"
title="代码"
:disabled="disabledOrReadonly || previewMode"
@click="insertSnippet('<code>', '</code>')"
>
<Code class="h-4 w-4" />
</ElButton>
<ElButton
text
size="small"
:disabled="disabledOrReadonly || previewMode"
@click="clearFormat"
>
清除格式
</ElButton>
<ElButton
text
size="small"
class="ml-auto"
@click="previewMode = !previewMode"
>
<EyeOff v-if="previewMode" class="mr-1 h-4 w-4" />
<Eye v-else class="mr-1 h-4 w-4" />
{{ previewMode ? '编辑' : '预览' }}
</ElButton>
</div>
<div class="editor-content" :style="editorStyle">
<div
v-if="previewMode"
class="preview-content h-full overflow-auto px-4 py-3 text-[var(--el-text-color-primary)]"
v-html="localValue || `<span class='empty'>${placeholder}</span>`"
></div>
<ElInput
v-else
ref="inputRef"
:model-value="localValue"
type="textarea"
resize="none"
:disabled="disabled"
:readonly="readonly"
:placeholder="placeholder"
:maxlength="maxLength > 0 ? maxLength : undefined"
@blur="handleBlur"
@focus="handleFocus"
@update:model-value="syncValue"
/>
</div>
<div
ref="editorRef"
class="rich-text-content"
:contenteditable="!disabled && !readonly"
:data-placeholder="placeholder"
:style="{ minHeight: `${minHeight}px`, maxHeight: `${maxHeight}px` }"
role="textbox"
aria-multiline="true"
@blur="handleBlur"
@focus="handleFocus"
@input="emitChange"
@paste="handlePaste"
></div>
v-if="showWordCount"
class="status-bar flex items-center justify-end border-t border-[var(--el-border-color)] bg-[var(--el-fill-color-light)] px-3 py-1 text-xs text-[var(--el-text-color-secondary)]"
>
<span>
字数: {{ wordCount }}
<template v-if="maxLength > 0"> / {{ maxLength }}</template>
</span>
</div>
</div>
</template>
<style scoped>
.rich-text-editor {
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
overflow: hidden;
}
.rich-text-editor.is-disabled {
opacity: 0.72;
cursor: not-allowed;
opacity: 0.65;
}
.rich-text-toolbar {
display: flex;
min-height: 42px;
align-items: center;
gap: 2px;
flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 5px 8px;
.toolbar :deep(.el-button) {
min-width: 28px;
height: 28px;
padding: 4px 6px;
}
.block-select {
width: 96px;
.editor-content {
overflow: hidden;
}
.rich-text-content {
overflow: auto;
padding: 12px 14px;
outline: none;
.editor-content :deep(.el-textarea) {
height: 100%;
}
.editor-content :deep(.el-textarea__inner) {
height: 100%;
min-height: inherit;
color: var(--el-text-color-primary);
background: var(--el-bg-color);
border: 0;
border-radius: 0;
box-shadow: none;
}
.preview-content {
line-height: 1.7;
}
.rich-text-content:empty::before {
.preview-content :deep(.empty) {
color: var(--el-text-color-placeholder);
content: attr(data-placeholder);
}
.rich-text-content :deep(h1),
.rich-text-content :deep(h2),
.rich-text-content :deep(h3) {
margin: 0.8em 0 0.45em;
font-weight: 650;
line-height: 1.35;
}
.rich-text-content :deep(h1) {
font-size: 24px;
}
.rich-text-content :deep(h2) {
font-size: 20px;
}
.rich-text-content :deep(h3) {
font-size: 17px;
}
.rich-text-content :deep(p) {
margin: 0.45em 0;
}
.rich-text-content :deep(blockquote) {
margin: 8px 0;
border-left: 4px solid var(--el-border-color);
padding-left: 14px;
color: var(--el-text-color-secondary);
}
.rich-text-content :deep(img) {
max-width: 100%;
border-radius: 4px;
}
.rich-text-content :deep(table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
}
.rich-text-content :deep(td),
.rich-text-content :deep(th) {
min-width: 48px;
border: 1px solid var(--el-border-color);
padding: 6px 8px;
}
.rich-text-content :deep(a) {
.preview-content :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
.popover-form,
.table-popover {
display: grid;
gap: 8px;
.preview-content :deep(ul),
.preview-content :deep(ol) {
padding-left: 1.4em;
}
.preview-content :deep(code) {
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color);
border-radius: 4px;
}
</style>
@@ -0,0 +1,76 @@
export interface LightRichTextEditorInstance {
blur: () => void;
focus: () => void;
getHTML: () => string;
getMarkdown: () => string;
getText: () => string;
setContent: (value: string) => void;
}
export interface RichTextEditorProps {
modelValue?: string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
minHeight?: number | string;
maxHeight?: number | string;
showToolbar?: boolean;
showWordCount?: boolean;
maxLength?: number;
toolbarConfig?: ToolbarConfig;
}
export interface RichTextEditorEmits {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
(e: 'ready', editor: LightRichTextEditorInstance): void;
}
export interface ToolbarConfig {
groups?: ToolbarGroup[];
insert?: {
attachment?: boolean;
image?: boolean;
link?: boolean;
table?: boolean;
video?: boolean;
};
}
export type ToolbarGroup =
| 'align'
| 'blockquote'
| 'clear'
| 'code'
| 'color'
| 'divider'
| 'format'
| 'heading'
| 'history'
| 'indent'
| 'insert'
| 'list';
export const defaultToolbarGroups: ToolbarGroup[] = [
'history',
'heading',
'format',
'color',
'align',
'list',
'insert',
'blockquote',
'code',
'divider',
'clear',
];
export const defaultInsertConfig = {
attachment: false,
image: true,
link: true,
table: true,
video: false,
};
@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { ScanMode } from '../shared/types';
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElInput } from 'element-plus';
import { ZqDialog } from '#/components/zq-dialog';
const props = withDefaults(
defineProps<{
continuousScan?: boolean;
modelValue?: boolean;
scanMode?: ScanMode;
}>(),
{
continuousScan: false,
modelValue: false,
scanMode: 'all',
},
);
const emit = defineEmits<{
(e: 'scan', value: string): void;
(e: 'update:modelValue', value: boolean): void;
}>();
const manualValue = ref('');
watch(
() => props.modelValue,
(visible) => {
if (visible) manualValue.value = '';
},
);
function closeDialog() {
emit('update:modelValue', false);
}
function handleSubmit() {
const value = manualValue.value.trim();
if (!value) return;
emit('scan', value);
if (!props.continuousScan) {
closeDialog();
} else {
manualValue.value = '';
}
}
</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">
<div class="scan-dialog__notice">
{{ $t('form-design.scanInput.uploadImage') }}
</div>
<ElInput
v-model="manualValue"
autofocus
:placeholder="$t('form-design.scanInput.scanButton')"
@keyup.enter="handleSubmit"
/>
<div class="scan-dialog__actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">确定</ElButton>
</div>
</div>
</ZqDialog>
</template>
<style scoped>
.scan-dialog {
display: flex;
flex-direction: column;
gap: 12px;
}
.scan-dialog__notice {
padding: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.scan-dialog__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,2 @@
export { default as ScanInput } from './scan-input.vue';
export type * from './types';
@@ -0,0 +1,135 @@
<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>
@@ -0,0 +1,25 @@
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;
}
@@ -0,0 +1,127 @@
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 '';
}
}
}
@@ -0,0 +1,66 @@
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;
}
@@ -0,0 +1,28 @@
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,
});
});
}
@@ -0,0 +1,54 @@
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';
}
}
}
@@ -0,0 +1,371 @@
<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, ElInput, ElMessage, ElResult } from 'element-plus';
import { checkSignatureStatus, createSignatureToken } from '#/api/core/file';
import { ZqDialog } from '#/components/zq-dialog';
interface Props {
source?: string;
visible: boolean;
}
const props = withDefaults(defineProps<Props>(), {
source: 'form',
});
const emit = defineEmits<{
(e: 'complete', fileId: string): void;
(e: 'update:visible', value: boolean): void;
}>();
const signatureUrl = 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 '';
return new Date(expiredAt.value).toLocaleString('zh-CN', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: '2-digit',
});
});
async function generateSignatureLink() {
try {
isLoading.value = true;
const result = await createSignatureToken(props.source, 30);
callbackKey.value = result.callback_key;
expiredAt.value = result.expired_at;
signatureUrl.value = `${window.location.origin}/mobile-signature/${result.token}`;
startPolling();
} catch (error) {
console.error('Generate signature link failed:', error);
ElMessage.error($t('form-design.signaturePad.qrcodeError'));
} finally {
isLoading.value = false;
}
}
function startPolling() {
isWaiting.value = true;
stopPolling();
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;
}
async function copySignatureUrl() {
if (!signatureUrl.value) return;
try {
await navigator.clipboard.writeText(signatureUrl.value);
ElMessage.success('链接已复制');
} catch {
ElMessage.error('复制失败');
}
}
function openSignatureUrl() {
if (signatureUrl.value) {
window.open(signatureUrl.value, '_blank', 'noopener,noreferrer');
}
}
function resetState() {
stopPolling();
isCompleted.value = false;
completedFileId.value = '';
signatureUrl.value = '';
callbackKey.value = '';
expiredAt.value = '';
isWaiting.value = false;
}
function regenerateQRCode() {
resetState();
generateSignatureLink();
}
function handleOpened() {
generateSignatureLink();
}
watch(dialogVisible, (visible) => {
if (!visible) {
resetState();
}
});
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__link">
<template v-if="signatureUrl">
<ElInput :model-value="signatureUrl" readonly />
<div class="mobile-signature-dialog__link-actions">
<ElButton size="small" @click="copySignatureUrl">
复制链接
</ElButton>
<ElButton size="small" type="primary" @click="openSignatureUrl">
打开签名页
</ElButton>
</div>
</template>
<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 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 260px;
}
.mobile-signature-dialog__content {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
width: 100%;
}
.mobile-signature-dialog__icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
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__link {
width: 100%;
padding: 16px;
background-color: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.mobile-signature-dialog__link-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.mobile-signature-dialog__loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
color: var(--el-text-color-placeholder);
}
.mobile-signature-dialog__waiting {
display: flex;
gap: 8px;
align-items: center;
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 {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
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;
gap: 4px;
align-items: center;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.mobile-signature-dialog__footer {
display: flex;
justify-content: space-between;
width: 100%;
}
</style>
@@ -0,0 +1,363 @@
<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>
@@ -0,0 +1,2 @@
export { default as SignaturePad } from './signature-pad.vue';
export type { SignaturePadEmits, SignaturePadProps } from './types';
@@ -0,0 +1,306 @@
<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>
@@ -0,0 +1,29 @@
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;
}
@@ -1,5 +1,2 @@
import { defineAsyncComponent } from 'vue';
export const TableSelector = defineAsyncComponent(() =>
import('./table-selector.vue').then((module) => module.default),
);
export { default as TableSelector } from './table-selector.vue';
export * from './types';
@@ -13,7 +13,6 @@ import { $t } from '@vben/locales';
import {
ElButton,
ElCheckbox,
ElEmpty,
ElInput,
ElOption,
ElPagination,
@@ -31,27 +30,17 @@ defineOptions({
inheritAttrs: false,
});
const selectorText = {
clickToSelect: '点击选择',
nodeLabel: '名称',
nodeValue: '值',
search: '搜索',
selectData: '选择数据',
selectedCount: (count: number) => `已选择 ${count}`,
};
const props = withDefaults(defineProps<TableSelectorProps>(), {
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: selectorText.clickToSelect,
placeholder: () => $t('form-design.attribute.clickToSelect'),
disabled: false,
clearable: true,
dialogTitle: selectorText.selectData,
dialogTitle: () => $t('form-design.attribute.selectData'),
dialogWidth: '800px',
columns: () => [],
labelField: 'label',
valueField: 'value',
dataSourceType: 'static',
apiMethod: 'GET',
searchFields: () => [],
collapseTags: false,
options: () => [],
@@ -59,324 +48,426 @@ const props = withDefaults(defineProps<TableSelectorProps>(), {
const emit = defineEmits<TableSelectorEmits>();
interface Props extends TableSelectorProps {
dataSourceType?: 'api' | 'dataSource' | 'dict' | 'formData' | 'static';
dictCode?: string;
dataSourceCode?: string;
formCode?: string;
apiUrl?: string;
apiMethod?: 'GET' | 'POST';
searchFields?: string[];
collapseTags?: boolean;
options?: Array<Record<string, any>>;
}
const dialogVisible = ref(false);
const loading = ref(false);
const tableData = ref<Array<Record<string, any>>>([]);
const tableRef = ref();
const tableData = ref<any[]>([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(10);
const searchKeyword = ref('');
// 选中的值
const selectedValues = ref<Set<string>>(new Set());
const selectedItemsMap = ref<Map<string, Record<string, any>>>(new Map());
// 选中项的信息映射
const selectedItemsMap = ref<Map<string, any>>(new Map());
// 标签加载状态
const labelsLoading = ref(false);
function normalizeValues(value: undefined | string | string[]) {
if (!value) return [];
return Array.isArray(value) ? value.map(String) : [String(value)];
}
function pickList(response: any) {
return Array.isArray(response)
? response
: response?.items || response?.list || response?.data || [];
}
function pickTotal(response: any, fallback: number) {
return Number(response?.total ?? response?.count ?? fallback);
}
function getItemLabel(item: undefined | Record<string, any>, value: string) {
if (!item) return value;
return (
item[props.labelField] ??
item.name ??
item.title ??
item.label ??
item.text ??
value
);
}
async function loadInitialLabels(values: string[]) {
if (values.length === 0) return;
try {
if (
props.dataSourceType === 'static' ||
(!props.dataSourceType && props.options.length > 0)
) {
for (const item of props.options) {
const value = String(item[props.valueField]);
if (values.includes(value)) selectedItemsMap.value.set(value, item);
}
return;
}
if (props.dataSourceType === 'formData' && props.formCode) {
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{
params: {
[`${props.valueField}__in`]: values.join(','),
pageSize: values.length,
},
},
);
for (const item of pickList(response)) {
selectedItemsMap.value.set(String(item[props.valueField]), item);
}
return;
}
if (props.dataSourceType === 'dict' && props.dictCode) {
const response = await requestClient.get(
`/api/core/dict_item/by/dict_code/${props.dictCode}`,
);
for (const item of pickList(response)) {
const value = String(item[props.valueField]);
if (values.includes(value)) selectedItemsMap.value.set(value, item);
}
return;
}
if (props.dataSourceType === 'dataSource' && props.dataSourceCode) {
const response = await requestClient.get(
`/api/core/data-source/execute/${props.dataSourceCode}`,
);
for (const item of pickList(response)) {
const value = String(item[props.valueField]);
if (values.includes(value)) selectedItemsMap.value.set(value, item);
}
}
} catch (error) {
console.error('load selector labels failed:', error);
}
}
function emitSelectItem() {
const selectedItems = [...selectedValues.value]
.map((value) => selectedItemsMap.value.get(value))
.filter(Boolean);
emit('select-item', props.multiple ? selectedItems : selectedItems[0]);
}
// 初始化选中值
watch(
() => props.modelValue,
async (value) => {
const values = normalizeValues(value);
selectedValues.value = new Set(values);
const missingValues = values.filter((v) => !selectedItemsMap.value.has(v));
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
emitSelectItem();
async (val) => {
if (val) {
const values = Array.isArray(val) ? val : [val];
selectedValues.value = new Set(values.map(String));
// 如果有初始值但 selectedItemsMap 中没有对应的标签信息,需要加载
const missingValues = values.filter(
(v) => !selectedItemsMap.value.has(String(v)),
);
if (missingValues.length > 0) {
labelsLoading.value = true;
await loadInitialLabels(missingValues);
labelsLoading.value = false;
// 加载完成后,触发 select-item 事件,供关联字段组件使用
emitSelectItem();
}
} else {
selectedValues.value = new Set();
}
},
{ immediate: true },
);
const displayTags = computed(() =>
[...selectedValues.value].map((value) => ({
value,
label: getItemLabel(selectedItemsMap.value.get(value), value),
})),
);
// 触发 select-item 事件
const emitSelectItem = () => {
const items = [...selectedValues.value]
.map((v) => selectedItemsMap.value.get(v))
.filter(Boolean);
if (props.multiple) {
emit('select-item', items.length > 0 ? items : undefined);
} else {
emit('select-item', items[0] || undefined);
}
};
// 加载初始值对应的标签信息
const loadInitialLabels = async (values: any[]) => {
try {
const stringValues = values.map(String);
// 静态数据 - 直接从 options 中查找
if (
props.dataSourceType === 'static' ||
(!props.dataSourceType && props.options && props.options.length > 0)
) {
const staticData = props.options || [];
for (const item of staticData) {
const value = String(item[props.valueField]);
if (stringValues.includes(value)) {
selectedItemsMap.value.set(value, item);
}
}
} else if (props.dataSourceType === 'formData' && props.formCode) {
// 通过 ID 列表精确查询,不受分页影响
const ids = stringValues.join(',');
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{
params: {
[`${props.valueField}__in`]: ids,
pageSize: stringValues.length,
},
},
);
const data = response?.items || [];
for (const item of data) {
const value = String(item[props.valueField]);
selectedItemsMap.value.set(value, item);
}
} else if (props.dataSourceType === 'dict' && props.dictCode) {
// 字典数据通常数据量不大,整体加载后过滤
const response = await requestClient.get(
`/api/core/dict_item/by/dict_code/${props.dictCode}`,
);
const data = response || [];
for (const item of data) {
const value = String(item[props.valueField]);
if (stringValues.includes(value)) {
selectedItemsMap.value.set(value, item);
}
}
} else if (props.dataSourceType === 'dataSource' && props.dataSourceCode) {
// 数据源通常数据量不大,整体加载后过滤
const response = await requestClient.get(
`/api/core/data-source/execute/${props.dataSourceCode}`,
);
const data = Array.isArray(response)
? response
: response?.list || response?.data || [];
for (const item of data) {
const value = String(item[props.valueField]);
if (stringValues.includes(value)) {
selectedItemsMap.value.set(value, item);
}
}
}
} catch (error) {
console.error('加载初始标签失败:', error);
}
};
// 显示的标签列表
const displayTags = computed(() => {
const result: Array<{ label: string; value: string }> = [];
for (const value of selectedValues.value) {
const item = selectedItemsMap.value.get(value);
result.push({
value,
label: item ? item[props.labelField] : value,
});
}
return result;
});
// el-select 的显示值
const selectDisplayValue = computed(() => {
const values = [...selectedValues.value];
return props.multiple ? values : values[0];
});
const showPagination = computed(
() =>
props.dataSourceType === 'formData' ||
props.dataSourceType === 'api' ||
total.value > pageSize.value,
);
const displayColumns = computed<TableSelectorColumn[]>(() => {
if (props.columns.length > 0) return props.columns;
return [
{ field: props.labelField, label: selectorText.nodeLabel },
{ field: props.valueField, label: selectorText.nodeValue },
];
// 是否显示分页(formData 和 api 类型始终显示,其他类型在数据超过一页时显示)
const showPagination = computed(() => {
if (props.dataSourceType === 'formData' || props.dataSourceType === 'api') {
return true;
}
return total.value > pageSize.value;
});
async function loadData() {
// 打开弹窗
const openDialog = () => {
if (props.disabled) return;
dialogVisible.value = true;
loadData();
};
// 加载数据
const loadData = async () => {
loading.value = true;
try {
let data: Array<Record<string, any>> = [];
let data: any[] = [];
let totalCount = 0;
// 静态数据
if (
props.dataSourceType === 'static' ||
(!props.dataSourceType && props.options.length > 0)
(!props.dataSourceType && props.options && props.options.length > 0)
) {
const keyword = searchKeyword.value.trim().toLowerCase();
data = keyword
? props.options.filter((item) =>
[props.labelField, props.valueField].some((field) =>
String(item[field] ?? '')
.toLowerCase()
.includes(keyword),
),
)
: props.options;
totalCount = data.length;
let staticData = props.options || [];
// 搜索过滤
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
staticData = staticData.filter((item) => {
const label = String(item[props.labelField] || '').toLowerCase();
const value = String(item[props.valueField] || '').toLowerCase();
return label.includes(keyword) || value.includes(keyword);
});
}
data = staticData;
totalCount = staticData.length;
} else if (props.dataSourceType === 'formData' && props.formCode) {
const params: Record<string, any> = {
page: currentPage.value,
pageSize: pageSize.value,
};
// 使用配置的搜索字段,如果没有配置则使用 labelField
// 注意:后端目前不支持 OR 查询,所以只使用第一个搜索字段
if (searchKeyword.value) {
const searchField = props.searchFields[0] || props.labelField;
const searchField =
props.searchFields && props.searchFields.length > 0
? props.searchFields[0]
: props.labelField;
params[`${searchField}__like`] = searchKeyword.value;
}
const response = await requestClient.get(
`/api/online_dev/form-data/${props.formCode}/list`,
{ params },
);
data = pickList(response);
totalCount = pickTotal(response, data.length);
data = response?.items || [];
totalCount = response?.total || 0;
} else if (props.dataSourceType === 'dict' && props.dictCode) {
const response = await requestClient.get(
`/api/core/dict_item/by/dict_code/${props.dictCode}`,
);
data = pickList(response);
data = response || [];
totalCount = data.length;
} else if (props.dataSourceType === 'dataSource' && props.dataSourceCode) {
const response = await requestClient.get(
`/api/core/data-source/execute/${props.dataSourceCode}`,
);
data = pickList(response);
data = Array.isArray(response)
? response
: response?.list || response?.data || [];
totalCount = data.length;
} else if (props.dataSourceType === 'api' && props.apiUrl) {
const query = {
page: currentPage.value,
pageSize: pageSize.value,
keyword: searchKeyword.value,
};
const response =
props.apiMethod === 'POST'
? await requestClient.post(props.apiUrl, query)
: await requestClient.get(props.apiUrl, { params: query });
data = pickList(response);
totalCount = pickTotal(response, data.length);
? await requestClient.post(props.apiUrl, {
page: currentPage.value,
pageSize: pageSize.value,
keyword: searchKeyword.value,
})
: await requestClient.get(props.apiUrl, {
params: {
page: currentPage.value,
pageSize: pageSize.value,
keyword: searchKeyword.value,
},
});
data = response?.items || response?.list || response?.data || [];
totalCount = response?.total || data.length;
}
tableData.value = data;
total.value = totalCount;
// 更新选中项的信息
for (const item of data) {
const value = String(item[props.valueField]);
if (selectedValues.value.has(value)) selectedItemsMap.value.set(value, item);
const value = item[props.valueField];
if (selectedValues.value.has(String(value))) {
selectedItemsMap.value.set(String(value), item);
}
}
} catch (error) {
console.error('load selector data failed:', error);
console.error('加载数据失败:', error);
tableData.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
};
function openDialog() {
if (props.disabled) return;
dialogVisible.value = true;
loadData();
}
function handleSearch() {
// 搜索
const handleSearch = () => {
currentPage.value = 1;
loadData();
}
};
function handlePageChange(page: number) {
// 分页变化
const handlePageChange = (page: number) => {
currentPage.value = page;
loadData();
}
};
function handleSizeChange(size: number) {
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1;
loadData();
}
};
function isRowSelected(row: Record<string, any>) {
return selectedValues.value.has(String(row[props.valueField]));
}
// 行点击选择
const handleRowClick = (row: any) => {
const value = String(row[props.valueField]);
function getRowValue(row: Record<string, any>) {
return String(row[props.valueField]);
}
function toggleRow(row: Record<string, any>, checked?: boolean | number | string) {
const value = getRowValue(row);
if (props.multiple) {
const nextChecked = checked ?? !selectedValues.value.has(value);
if (nextChecked) {
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
} else {
if (selectedValues.value.has(value)) {
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
} else {
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
}
selectedValues.value = new Set(selectedValues.value);
return;
} else {
selectedValues.value = new Set([value]);
selectedItemsMap.value.clear();
selectedItemsMap.value.set(value, row);
}
};
// 判断行是否选中
const isRowSelected = (row: any) => {
const value = String(row[props.valueField]);
return selectedValues.value.has(value);
};
// 获取行的值
const getRowValue = (row: any) => {
return String(row[props.valueField]);
};
// 单选变化
const handleRadioChange = (row: any) => {
const value = String(row[props.valueField]);
selectedValues.value = new Set([value]);
selectedItemsMap.value.clear();
selectedItemsMap.value.set(value, row);
}
};
const isAllSelected = computed(
() =>
tableData.value.length > 0 &&
tableData.value.every((row) => isRowSelected(row)),
);
// 多选变化
const handleCheckboxChange = (row: any, checked: boolean | number | string) => {
const value = String(row[props.valueField]);
if (checked) {
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
} else {
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
}
selectedValues.value = new Set(selectedValues.value);
};
// 全选/取消全选
const handleSelectAll = (checked: boolean | number | string) => {
if (checked) {
for (const row of tableData.value) {
const value = String(row[props.valueField]);
selectedValues.value.add(value);
selectedItemsMap.value.set(value, row);
}
} else {
for (const row of tableData.value) {
const value = String(row[props.valueField]);
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
}
}
selectedValues.value = new Set(selectedValues.value);
};
// 是否全选
const isAllSelected = computed(() => {
if (tableData.value.length === 0) return false;
return tableData.value.every((row) => isRowSelected(row));
});
// 是否部分选中
const isIndeterminate = computed(() => {
const selectedCount = tableData.value.filter((row) => isRowSelected(row)).length;
if (tableData.value.length === 0) return false;
const selectedCount = tableData.value.filter((row) =>
isRowSelected(row),
).length;
return selectedCount > 0 && selectedCount < tableData.value.length;
});
function handleSelectAll(checked: boolean | number | string) {
for (const row of tableData.value) toggleRow(row, checked);
}
function handleConfirm() {
// 确认选择
const handleConfirm = () => {
const values = [...selectedValues.value];
const result = props.multiple ? values : values[0];
emit('update:modelValue', result);
emit('change', result);
emit('blur');
emitSelectItem();
dialogVisible.value = false;
}
function handleClear() {
// 发出选中项的完整数据
const selectedItems = values
.map((v) => selectedItemsMap.value.get(v))
.filter(Boolean);
const itemResult = props.multiple ? selectedItems : selectedItems[0];
emit('select-item', itemResult);
dialogVisible.value = false;
};
// 清空选择
const handleClear = () => {
selectedValues.value = new Set();
selectedItemsMap.value.clear();
emit('update:modelValue', props.multiple ? [] : undefined);
emit('change', props.multiple ? [] : undefined);
emit('select-item', undefined);
}
};
function handleRemoveTag(value: string) {
// 移除单个标签
const handleRemoveTag = (value: string) => {
selectedValues.value.delete(value);
selectedItemsMap.value.delete(value);
selectedValues.value = new Set(selectedValues.value);
const values = [...selectedValues.value];
const result = props.multiple ? values : values[0];
emit('update:modelValue', result);
emit('change', result);
emitSelectItem();
}
};
// 获取显示的列
const displayColumns = computed<TableSelectorColumn[]>(() => {
if (props.columns && props.columns.length > 0) {
return props.columns;
}
// 默认显示 labelField 和 valueField
return [
{ field: props.labelField, label: $t('form-design.attribute.nodeLabel') },
{ field: props.valueField, label: $t('form-design.attribute.nodeValue') },
];
});
</script>
<template>
<div class="table-selector">
<!-- 使用 el-select 作为显示区域 -->
<ElSelect
:model-value="labelsLoading ? undefined : selectDisplayValue"
:placeholder="labelsLoading ? $t('common.loading') : placeholder"
@@ -401,24 +492,33 @@ function handleRemoveTag(value: string) {
/>
</ElSelect>
<ZqDialog v-model="dialogVisible" :title="dialogTitle" :width="dialogWidth">
<!-- 选择弹窗 -->
<ZqDialog
v-model="dialogVisible"
:title="dialogTitle"
:width="dialogWidth"
@confirm="handleConfirm"
>
<div class="table-selector-content flex h-[600px] flex-col">
<!-- 搜索栏 -->
<div class="mb-4 flex shrink-0 items-center gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="selectorText.search"
:placeholder="$t('form-design.search')"
clearable
class="w-64"
@change="handleSearch"
@keyup.enter="handleSearch"
@change="handleSearch"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('common.search') }}
</ElButton>
</div>
<!-- 数据表格 -->
<div class="min-h-0 flex-1">
<ElTable
ref="tableRef"
v-loading="loading"
:data="tableData"
border
@@ -428,20 +528,22 @@ function handleRemoveTag(value: string) {
({ row }: any) => (isRowSelected(row) ? 'selected-row' : '')
"
style="width: 100%"
@row-click="toggleRow"
@row-click="handleRowClick"
>
<!-- 单选列 -->
<ElTableColumn v-if="!multiple" width="55" align="center">
<template #default="{ row }">
<ElRadio
:model-value="isRowSelected(row) ? getRowValue(row) : ''"
:value="getRowValue(row)"
@click.stop
@change="toggleRow(row)"
@change="handleRadioChange(row)"
>
<span></span>
</ElRadio>
</template>
</ElTableColumn>
<!-- 多选列 -->
<ElTableColumn v-else width="55" align="center">
<template #header>
<ElCheckbox
@@ -454,10 +556,11 @@ function handleRemoveTag(value: string) {
<ElCheckbox
:model-value="isRowSelected(row)"
@click.stop
@change="toggleRow(row, $event)"
@change="handleCheckboxChange(row, $event)"
/>
</template>
</ElTableColumn>
<!-- 数据列 -->
<ElTableColumn
v-for="col in displayColumns"
:key="col.field"
@@ -465,15 +568,14 @@ function handleRemoveTag(value: string) {
:label="col.label"
:width="col.width"
/>
<template #empty>
<ElEmpty :description="$t('common.noData')" />
</template>
</ElTable>
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="flex w-full items-center">
<!-- 分页 - 左侧 -->
<div class="flex-shrink-0">
<ElPagination
v-if="showPagination"
@@ -487,16 +589,20 @@ function handleRemoveTag(value: string) {
@size-change="handleSizeChange"
/>
</div>
<!-- 已选提示 - 中间 -->
<div class="flex flex-1 justify-center">
<span
v-if="selectedValues.size > 0"
class="text-sm text-[var(--el-text-color-secondary)]"
>
{{
selectorText.selectedCount(selectedValues.size)
$t('form-design.attribute.selectedCount', {
count: selectedValues.size,
})
}}
</span>
</div>
<!-- 按钮 - 右侧 -->
<div class="flex flex-shrink-0 gap-2">
<ElButton @click="dialogVisible = false">
{{ $t('common.cancel') }}
@@ -526,6 +632,7 @@ function handleRemoveTag(value: string) {
</style>
<style>
/* 隐藏 table-selector 的下拉弹窗 */
.table-selector-popper-hidden {
display: none !important;
}
@@ -1,7 +1,7 @@
export interface TableSelectorColumn {
field: string;
label: string;
width?: number | string;
width?: number;
}
export interface TableSelectorProps {
@@ -15,15 +15,6 @@ export interface TableSelectorProps {
columns?: TableSelectorColumn[];
labelField?: string;
valueField?: string;
dataSourceType?: 'api' | 'dataSource' | 'dict' | 'formData' | 'static';
dictCode?: string;
dataSourceCode?: string;
formCode?: string;
apiUrl?: string;
apiMethod?: 'GET' | 'POST';
searchFields?: string[];
collapseTags?: boolean;
options?: Array<Record<string, any>>;
}
export interface TableSelectorEmits {
@@ -16,45 +16,6 @@ interface IconifyResponse {
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
const LOCAL_ICONS_MAP: Recordable<string[]> = {
carbon: [
'carbon:agent-detached',
'carbon:align-box-middle-right',
'carbon:align-vertical-top',
'carbon:arrange',
'carbon:arrange-horizontal',
'carbon:checkmark',
'carbon:chevron-down',
'carbon:circle-dash',
'carbon:close',
'carbon:edit',
'carbon:email',
'carbon:renew',
'carbon:reset',
'carbon:user-multiple',
],
ep: [
'ep:caret-right',
'ep:delete',
'ep:edit',
'ep:plus',
'ep:refresh',
],
lucide: [
'lucide:bot',
'lucide:check-square',
'lucide:history',
'lucide:layout-grid',
'lucide:library-big',
'lucide:list',
'lucide:megaphone',
'lucide:settings',
'lucide:square-check-big',
'lucide:square-user',
'lucide:workflow',
],
};
/**
* 通过Iconify接口获取图标集数据。
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
@@ -63,10 +24,6 @@ const LOCAL_ICONS_MAP: Recordable<string[]> = {
* @returns 图标集中包含的所有图标名称
*/
export async function fetchIconsData(prefix: string): Promise<string[]> {
if (LOCAL_ICONS_MAP[prefix]) {
ICONS_MAP[prefix] = LOCAL_ICONS_MAP[prefix];
return ICONS_MAP[prefix];
}
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
return ICONS_MAP[prefix];
}