feat: harden ai agent admin workflow experience
This commit is contained in:
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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
+224
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+654
@@ -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>
|
||||
+536
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+57
@@ -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="">', '</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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user