Build lightweight AI agent admin
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,315 @@
|
||||
<script setup lang="ts">
|
||||
import type { BarcodeGeneratorEmits, BarcodeGeneratorProps } from './types';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Barcode, Copy, Download } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
|
||||
import { useCodeContent } from '../shared/useCodeContent';
|
||||
import {
|
||||
getBarcodeValidationErrorKey,
|
||||
validateBarcodeContent,
|
||||
} from '../shared/validateBarcode';
|
||||
|
||||
defineOptions({
|
||||
name: 'BarcodeGenerator',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<BarcodeGeneratorProps>(), {
|
||||
dataSource: 'static',
|
||||
format: 'code128',
|
||||
height: 80,
|
||||
width: 2,
|
||||
margin: 10,
|
||||
displayValue: true,
|
||||
lineColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
showContent: false,
|
||||
enableDownload: false,
|
||||
enableCopy: false,
|
||||
downloadFilename: 'barcode',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
placeholder: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<BarcodeGeneratorEmits>();
|
||||
|
||||
const barcodeUrl = ref<string>('');
|
||||
const isGenerating = ref(false);
|
||||
const svgRef = ref<SVGElement>();
|
||||
|
||||
const contentOptions = computed(() => ({
|
||||
dataSource: props.dataSource,
|
||||
modelValue: props.modelValue,
|
||||
boundField: props.boundField,
|
||||
formula: props.formula,
|
||||
formData: props.formData,
|
||||
contentType: 'barcode' as const,
|
||||
}));
|
||||
|
||||
const barcodeContent = useCodeContent(contentOptions);
|
||||
|
||||
const isValid = computed(() =>
|
||||
validateBarcodeContent(props.format ?? 'code128', barcodeContent.value),
|
||||
);
|
||||
|
||||
const invalidMessageKey = computed(() =>
|
||||
getBarcodeValidationErrorKey(props.format ?? 'code128', barcodeContent.value),
|
||||
);
|
||||
|
||||
function generateBarcode() {
|
||||
const content = barcodeContent.value;
|
||||
if (!content || !isValid.value) {
|
||||
barcodeUrl.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating.value = true;
|
||||
|
||||
try {
|
||||
const svg = svgRef.value;
|
||||
if (!svg) return;
|
||||
|
||||
JsBarcode(svg, content, {
|
||||
format: props.format,
|
||||
height: props.height,
|
||||
width: props.width,
|
||||
margin: props.margin,
|
||||
displayValue: props.displayValue,
|
||||
lineColor: props.lineColor,
|
||||
background: props.backgroundColor,
|
||||
font: 'monospace',
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgStr = serializer.serializeToString(svg);
|
||||
const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
||||
barcodeUrl.value = URL.createObjectURL(blob);
|
||||
emit('generated', content);
|
||||
} catch (error) {
|
||||
console.warn('Generate barcode failed:', error);
|
||||
barcodeUrl.value = '';
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBarcode() {
|
||||
if (!barcodeUrl.value) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${props.downloadFilename}.svg`;
|
||||
link.href = barcodeUrl.value;
|
||||
link.click();
|
||||
|
||||
emit('downloaded');
|
||||
ElMessage.success($t('form-design.barcode.downloadSuccess'));
|
||||
}
|
||||
|
||||
async function copyContent() {
|
||||
const content = barcodeContent.value;
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
emit('copied');
|
||||
ElMessage.success($t('form-design.barcode.copySuccess'));
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
ElMessage.error($t('form-design.barcode.copyError'));
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderText = computed(
|
||||
() => props.placeholder || $t('form-design.barcode.placeholder'),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
barcodeContent.value,
|
||||
props.format,
|
||||
props.height,
|
||||
props.width,
|
||||
props.margin,
|
||||
props.displayValue,
|
||||
props.lineColor,
|
||||
props.backgroundColor,
|
||||
],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
generateBarcode();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="barcode-generator">
|
||||
<div
|
||||
class="barcode-generator__container"
|
||||
:class="{
|
||||
'barcode-generator__container--empty': !barcodeContent || !isValid,
|
||||
'barcode-generator__container--disabled': disabled,
|
||||
}"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<svg ref="svgRef" style="display: none"></svg>
|
||||
|
||||
<img
|
||||
v-if="barcodeUrl && isValid"
|
||||
:src="barcodeUrl"
|
||||
alt="Barcode"
|
||||
class="barcode-generator__image"
|
||||
/>
|
||||
|
||||
<div v-else class="barcode-generator__placeholder">
|
||||
<Barcode class="barcode-generator__placeholder-icon" />
|
||||
<span class="barcode-generator__placeholder-text">{{
|
||||
invalidMessageKey
|
||||
? $t(`form-design.barcode.${invalidMessageKey}`)
|
||||
: placeholderText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isGenerating" class="barcode-generator__loading">
|
||||
<div class="barcode-generator__spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showContent && barcodeContent" class="barcode-generator__content">
|
||||
<ElTooltip :content="barcodeContent" placement="top" :show-after="300">
|
||||
<span class="barcode-generator__content-text">{{ barcodeContent }}</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(enableDownload || enableCopy) && barcodeUrl && !disabled && isValid"
|
||||
class="barcode-generator__actions"
|
||||
>
|
||||
<ElButton
|
||||
v-if="enableDownload"
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Download"
|
||||
@click="downloadBarcode"
|
||||
>
|
||||
{{ $t('form-design.barcode.download') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="enableCopy"
|
||||
size="small"
|
||||
:icon="Copy"
|
||||
@click="copyContent"
|
||||
>
|
||||
{{ $t('form-design.barcode.copy') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.barcode-generator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.barcode-generator__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barcode-generator__container--empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.barcode-generator__container--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.barcode-generator__image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.barcode-generator__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.barcode-generator__placeholder-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.barcode-generator__placeholder-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.barcode-generator__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: color-mix(in srgb, var(--el-bg-color) 80%, transparent);
|
||||
}
|
||||
|
||||
.barcode-generator__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--el-border-color);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.barcode-generator__content {
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.barcode-generator__content-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.barcode-generator__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
import type {
|
||||
CodeEditorEmits,
|
||||
CodeEditorExpose,
|
||||
CodeEditorProps,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} from '@codemirror/commands';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
crosshairCursor,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
placeholder as placeholderExt,
|
||||
rectangularSelection,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { getLanguageExtension } from './languages';
|
||||
|
||||
const props = withDefaults(defineProps<CodeEditorProps>(), {
|
||||
modelValue: '',
|
||||
language: 'javascript',
|
||||
theme: 'auto',
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
height: 'auto',
|
||||
minHeight: '100px',
|
||||
tabSize: 2,
|
||||
lineNumbers: true,
|
||||
lineWrapping: false,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
bracketMatching: true,
|
||||
autocompletion: true,
|
||||
indentGuide: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<CodeEditorEmits>();
|
||||
|
||||
const editorRef = ref<HTMLDivElement>();
|
||||
const view = shallowRef<EditorView | null>(null);
|
||||
|
||||
// 主题跟随系统
|
||||
const { isDark } = usePreferences();
|
||||
|
||||
// Compartments 用于动态更新配置
|
||||
const languageCompartment = new Compartment();
|
||||
const themeCompartment = new Compartment();
|
||||
const readonlyCompartment = new Compartment();
|
||||
const lineNumbersCompartment = new Compartment();
|
||||
const lineWrappingCompartment = new Compartment();
|
||||
const foldGutterCompartment = new Compartment();
|
||||
const highlightActiveLineCompartment = new Compartment();
|
||||
const bracketMatchingCompartment = new Compartment();
|
||||
const autocompletionCompartment = new Compartment();
|
||||
const placeholderCompartment = new Compartment();
|
||||
|
||||
// 计算当前主题
|
||||
const currentTheme = computed(() => {
|
||||
if (props.theme === 'auto') {
|
||||
return isDark.value ? 'dark' : 'light';
|
||||
}
|
||||
return props.theme;
|
||||
});
|
||||
|
||||
// 计算样式
|
||||
const editorStyle = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
if (props.height !== 'auto') {
|
||||
style.height =
|
||||
typeof props.height === 'number' ? `${props.height}px` : props.height;
|
||||
}
|
||||
|
||||
if (props.minHeight) {
|
||||
style.minHeight =
|
||||
typeof props.minHeight === 'number'
|
||||
? `${props.minHeight}px`
|
||||
: props.minHeight;
|
||||
}
|
||||
|
||||
if (props.maxHeight) {
|
||||
style.maxHeight =
|
||||
typeof props.maxHeight === 'number'
|
||||
? `${props.maxHeight}px`
|
||||
: props.maxHeight;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
// 获取主题扩展
|
||||
function getThemeExtension(): Extension {
|
||||
return currentTheme.value === 'dark' ? oneDark : [];
|
||||
}
|
||||
|
||||
// 创建基础扩展
|
||||
function createBaseExtensions(): Extension[] {
|
||||
return [
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
crosshairCursor(),
|
||||
rectangularSelection(),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
||||
EditorState.tabSize.of(props.tabSize),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const value = update.state.doc.toString();
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
if (update.focusChanged) {
|
||||
if (update.view.hasFocus) {
|
||||
emit('focus');
|
||||
} else {
|
||||
emit('blur');
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// 初始化编辑器
|
||||
async function initEditor() {
|
||||
if (!editorRef.value) return;
|
||||
|
||||
const languageExt = await getLanguageExtension(props.language);
|
||||
|
||||
const extensions: Extension[] = [
|
||||
...createBaseExtensions(),
|
||||
languageCompartment.of(languageExt || []),
|
||||
themeCompartment.of(getThemeExtension()),
|
||||
readonlyCompartment.of(
|
||||
EditorState.readOnly.of(props.readonly || props.disabled),
|
||||
),
|
||||
lineNumbersCompartment.of(
|
||||
props.lineNumbers ? [lineNumbers(), highlightActiveLineGutter()] : [],
|
||||
),
|
||||
lineWrappingCompartment.of(
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
),
|
||||
foldGutterCompartment.of(props.foldGutter ? foldGutter() : []),
|
||||
highlightActiveLineCompartment.of(
|
||||
props.highlightActiveLine ? highlightActiveLine() : [],
|
||||
),
|
||||
bracketMatchingCompartment.of(
|
||||
props.bracketMatching ? bracketMatching() : [],
|
||||
),
|
||||
autocompletionCompartment.of(props.autocompletion ? autocompletion() : []),
|
||||
placeholderCompartment.of(
|
||||
props.placeholder ? placeholderExt(props.placeholder) : [],
|
||||
),
|
||||
];
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.modelValue,
|
||||
extensions,
|
||||
});
|
||||
|
||||
view.value = new EditorView({
|
||||
state,
|
||||
parent: editorRef.value,
|
||||
});
|
||||
|
||||
emit('ready', view.value);
|
||||
}
|
||||
|
||||
// 更新编辑器内容
|
||||
function updateContent(value: string) {
|
||||
if (!view.value) return;
|
||||
const currentValue = view.value.state.doc.toString();
|
||||
if (currentValue !== value) {
|
||||
view.value.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
updateContent(value);
|
||||
},
|
||||
);
|
||||
|
||||
// 监听语言变化
|
||||
watch(
|
||||
() => props.language,
|
||||
async (language) => {
|
||||
if (!view.value) return;
|
||||
const languageExt = await getLanguageExtension(language);
|
||||
view.value.dispatch({
|
||||
effects: languageCompartment.reconfigure(languageExt || []),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 监听主题变化
|
||||
watch(currentTheme, () => {
|
||||
if (!view.value) return;
|
||||
view.value.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension()),
|
||||
});
|
||||
});
|
||||
|
||||
// 监听只读状态变化
|
||||
watch(
|
||||
() => [props.readonly, props.disabled],
|
||||
([readonly, disabled]) => {
|
||||
if (!view.value) return;
|
||||
view.value.dispatch({
|
||||
effects: readonlyCompartment.reconfigure(
|
||||
EditorState.readOnly.of(Boolean(readonly || disabled)),
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 监听行号显示变化
|
||||
watch(
|
||||
() => props.lineNumbers,
|
||||
(show) => {
|
||||
if (!view.value) return;
|
||||
view.value.dispatch({
|
||||
effects: lineNumbersCompartment.reconfigure(
|
||||
show ? [lineNumbers(), highlightActiveLineGutter()] : [],
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 监听自动换行变化
|
||||
watch(
|
||||
() => props.lineWrapping,
|
||||
(wrap) => {
|
||||
if (!view.value) return;
|
||||
view.value.dispatch({
|
||||
effects: lineWrappingCompartment.reconfigure(
|
||||
wrap ? EditorView.lineWrapping : [],
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 监听占位符变化
|
||||
watch(
|
||||
() => props.placeholder,
|
||||
(text) => {
|
||||
if (!view.value) return;
|
||||
view.value.dispatch({
|
||||
effects: placeholderCompartment.reconfigure(
|
||||
text ? placeholderExt(text) : [],
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 暴露方法
|
||||
const expose: CodeEditorExpose = {
|
||||
getView: () => view.value,
|
||||
focus: () => view.value?.focus(),
|
||||
getValue: () => view.value?.state.doc.toString() || '',
|
||||
setValue: (value: string) => updateContent(value),
|
||||
format: () => {
|
||||
if (!view.value || props.language !== 'json') return;
|
||||
try {
|
||||
const content = view.value.state.doc.toString();
|
||||
const formatted = JSON.stringify(
|
||||
JSON.parse(content),
|
||||
null,
|
||||
props.tabSize,
|
||||
);
|
||||
updateContent(formatted);
|
||||
} catch {
|
||||
// JSON 解析失败,忽略
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
defineExpose(expose);
|
||||
|
||||
onMounted(() => {
|
||||
initEditor();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view.value?.destroy();
|
||||
view.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="code-editor"
|
||||
:class="{
|
||||
'code-editor--disabled': disabled,
|
||||
'code-editor--readonly': readonly,
|
||||
'code-editor--dark': currentTheme === 'dark',
|
||||
}"
|
||||
:style="editorStyle"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-editor {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-editor) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-content) {
|
||||
width: 99%;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-scroller) {
|
||||
font-family: 'Fira Code', Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.code-editor:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.code-editor--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.code-editor--disabled :deep(.cm-editor) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-gutters) {
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.code-editor--dark :deep(.cm-gutters) {
|
||||
background-color: var(--el-fill-color-darker);
|
||||
border-right-color: var(--el-border-color-darker);
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-activeLineGutter) {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-placeholder) {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as CodeEditor } from './code-editor.vue';
|
||||
export { supportedLanguages } from './languages';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
import type { CodeLanguage } from './types';
|
||||
|
||||
// 语言扩展懒加载映射
|
||||
const languageLoaders: Record<CodeLanguage, () => Promise<Extension>> = {
|
||||
javascript: async () => {
|
||||
const { javascript } = await import('@codemirror/lang-javascript');
|
||||
return javascript();
|
||||
},
|
||||
typescript: async () => {
|
||||
const { javascript } = await import('@codemirror/lang-javascript');
|
||||
return javascript({ typescript: true });
|
||||
},
|
||||
python: async () => {
|
||||
const { python } = await import('@codemirror/lang-python');
|
||||
return python();
|
||||
},
|
||||
sql: async () => {
|
||||
const { sql } = await import('@codemirror/lang-sql');
|
||||
return sql();
|
||||
},
|
||||
json: async () => {
|
||||
const { json } = await import('@codemirror/lang-json');
|
||||
return json();
|
||||
},
|
||||
html: async () => {
|
||||
const { html } = await import('@codemirror/lang-html');
|
||||
return html();
|
||||
},
|
||||
css: async () => {
|
||||
const { css } = await import('@codemirror/lang-css');
|
||||
return css();
|
||||
},
|
||||
markdown: async () => {
|
||||
const { markdown } = await import('@codemirror/lang-markdown');
|
||||
return markdown();
|
||||
},
|
||||
xml: async () => {
|
||||
const { xml } = await import('@codemirror/lang-xml');
|
||||
return xml();
|
||||
},
|
||||
yaml: async () => {
|
||||
const { yaml } = await import('@codemirror/lang-yaml');
|
||||
return yaml();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取语言扩展
|
||||
* @param language 语言类型
|
||||
* @returns 语言扩展
|
||||
*/
|
||||
export async function getLanguageExtension(
|
||||
language: CodeLanguage | string,
|
||||
): Promise<Extension | null> {
|
||||
const loader = languageLoaders[language as CodeLanguage];
|
||||
if (loader) {
|
||||
return await loader();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的语言列表
|
||||
*/
|
||||
export const supportedLanguages: CodeLanguage[] = [
|
||||
'javascript',
|
||||
'typescript',
|
||||
'python',
|
||||
'sql',
|
||||
'json',
|
||||
'html',
|
||||
'css',
|
||||
'markdown',
|
||||
'xml',
|
||||
'yaml',
|
||||
];
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
||||
export type CodeLanguage =
|
||||
| 'css'
|
||||
| 'html'
|
||||
| 'javascript'
|
||||
| 'json'
|
||||
| 'markdown'
|
||||
| 'python'
|
||||
| 'sql'
|
||||
| 'typescript'
|
||||
| 'xml'
|
||||
| 'yaml';
|
||||
|
||||
export interface CodeEditorProps {
|
||||
/** 代码内容 */
|
||||
modelValue?: string;
|
||||
/** 语言类型 */
|
||||
language?: CodeLanguage | string;
|
||||
/** 主题: light/dark/auto(跟随系统) */
|
||||
theme?: 'auto' | 'dark' | 'light';
|
||||
/** 是否只读 */
|
||||
readonly?: boolean;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 高度 */
|
||||
height?: number | string;
|
||||
/** 最小高度 */
|
||||
minHeight?: number | string;
|
||||
/** 最大高度 */
|
||||
maxHeight?: number | string;
|
||||
/** 占位符 */
|
||||
placeholder?: string;
|
||||
/** Tab 大小 */
|
||||
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', view: EditorView): void;
|
||||
}
|
||||
|
||||
export interface CodeEditorExpose {
|
||||
/** 获取 EditorView 实例 */
|
||||
getView: () => EditorView | null;
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => void;
|
||||
/** 获取代码内容 */
|
||||
getValue: () => string;
|
||||
/** 设置代码内容 */
|
||||
setValue: (value: string) => void;
|
||||
/** 格式化代码(仅支持 JSON) */
|
||||
format: () => void;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CodeGeneratorEmits, CodeGeneratorProps } from './types';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
defineOptions({
|
||||
name: 'CodeGenerator',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<CodeGeneratorProps>(), {
|
||||
prefix: '',
|
||||
separator: '',
|
||||
generateMode: 'date_seq',
|
||||
dateFormat: 'YYYYMMDD',
|
||||
seqLength: 4,
|
||||
seqResetRule: 'daily',
|
||||
randomLength: 6,
|
||||
customTemplate: '',
|
||||
businessType: 'default',
|
||||
disabled: true,
|
||||
readonly: true,
|
||||
placeholder: '自动生成编码',
|
||||
generateOnMount: true,
|
||||
isEdit: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<CodeGeneratorEmits>();
|
||||
|
||||
const codeValue = ref('');
|
||||
const loading = ref(false);
|
||||
// 标记是否已经生成过编码,防止重复生成
|
||||
const hasGenerated = ref(false);
|
||||
|
||||
const generateCode = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await requestClient.post<{ code: string }>(
|
||||
'/api/core/code-generator/generate',
|
||||
{
|
||||
prefix: props.prefix,
|
||||
separator: props.separator,
|
||||
generate_mode: props.generateMode,
|
||||
date_format: props.dateFormat,
|
||||
seq_length: props.seqLength,
|
||||
seq_reset_rule: props.seqResetRule,
|
||||
random_length: props.randomLength,
|
||||
custom_template: props.customTemplate,
|
||||
business_type: props.businessType,
|
||||
},
|
||||
);
|
||||
|
||||
if (response && response.code) {
|
||||
codeValue.value = response.code;
|
||||
hasGenerated.value = true;
|
||||
emit('update:modelValue', response.code);
|
||||
emit('change', response.code);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成编码失败:', error);
|
||||
ElMessage.error('生成编码失败,请重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal !== undefined && newVal !== codeValue.value) {
|
||||
codeValue.value = newVal;
|
||||
// 如果接收到非空的外部值,标记为已生成,阻止后续自动生成
|
||||
if (newVal) {
|
||||
hasGenerated.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// 如果初始就有值,确保显示
|
||||
if (props.modelValue) {
|
||||
codeValue.value = props.modelValue;
|
||||
hasGenerated.value = true;
|
||||
}
|
||||
|
||||
// 只有在非编辑模式下,且需要自动生成,且当前没有值时才生成
|
||||
if (
|
||||
!props.isEdit &&
|
||||
props.generateOnMount &&
|
||||
!props.modelValue &&
|
||||
!hasGenerated.value
|
||||
) {
|
||||
generateCode();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
generateCode,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-generator-wrapper flex items-center gap-2">
|
||||
<ElInput
|
||||
v-model="codeValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ElButton
|
||||
v-if="!readonly"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
@click="generateCode"
|
||||
>
|
||||
生成
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-generator-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as CodeGenerator } from './code-generator.vue';
|
||||
export type {
|
||||
CodeGeneratorEmits,
|
||||
CodeGeneratorProps,
|
||||
GenerateMode,
|
||||
SeqResetRule,
|
||||
} from './types';
|
||||
@@ -0,0 +1,33 @@
|
||||
export type GenerateMode =
|
||||
| 'custom' // 自定义模板
|
||||
| 'date_seq' // 日期+序号: PREFIX20241222-0001
|
||||
| 'datetime' // 日期时间: PREFIX20241222103000
|
||||
| 'random' // 随机字符: PREFIX-X7K9M2
|
||||
| 'snowflake' // 雪花ID: PREFIX1234567890123456
|
||||
| 'uuid'; // UUID片段: PREFIX-a1b2c3d4
|
||||
|
||||
export type SeqResetRule = 'daily' | 'monthly' | 'never' | 'yearly';
|
||||
|
||||
export interface CodeGeneratorProps {
|
||||
modelValue?: string;
|
||||
prefix?: string;
|
||||
separator?: string;
|
||||
generateMode?: GenerateMode;
|
||||
dateFormat?: string;
|
||||
seqLength?: number;
|
||||
seqResetRule?: SeqResetRule;
|
||||
randomLength?: number;
|
||||
customTemplate?: string;
|
||||
businessType?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
generateOnMount?: boolean;
|
||||
/** 是否为编辑模式,编辑模式下不自动生成编码 */
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export interface CodeGeneratorEmits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
(e: 'change', value: string): void;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, provide, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import CronParser from 'cron-parser';
|
||||
import { ElInput, ElTabPane, ElTabs, ElTooltip } from 'element-plus';
|
||||
|
||||
import DayUI from './tabs/DayUI.vue';
|
||||
import HourUI from './tabs/HourUI.vue';
|
||||
import MinuteUI from './tabs/MinuteUI.vue';
|
||||
import MonthUI from './tabs/MonthUI.vue';
|
||||
import SecondUI from './tabs/SecondUI.vue';
|
||||
import WeekUI from './tabs/WeekUI.vue';
|
||||
import YearUI from './tabs/YearUI.vue';
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
hideSecond?: boolean;
|
||||
hideYear?: boolean;
|
||||
remote?: (
|
||||
cron: string,
|
||||
timestamp: number,
|
||||
callback: (result: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
hideSecond: true,
|
||||
hideYear: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
provide('prefixCls', 'cron');
|
||||
|
||||
const activeKey = ref('minute');
|
||||
const second = ref('*');
|
||||
const minute = ref('*');
|
||||
const hour = ref('*');
|
||||
const day = ref('*');
|
||||
const month = ref('*');
|
||||
const week = ref('*');
|
||||
const year = ref('*');
|
||||
|
||||
const inputValues = reactive({
|
||||
second: '',
|
||||
minute: '',
|
||||
hour: '',
|
||||
day: '',
|
||||
month: '',
|
||||
week: '',
|
||||
year: '',
|
||||
cron: '',
|
||||
});
|
||||
|
||||
const preTimeList = ref('执行预览,会忽略年份参数。');
|
||||
|
||||
// 计算 cron 表达式
|
||||
const cronValueInner = computed(() => {
|
||||
const result: string[] = [];
|
||||
if (!props.hideSecond) {
|
||||
result.push(second.value ? second.value : '*');
|
||||
}
|
||||
result.push(
|
||||
minute.value ? minute.value : '*',
|
||||
hour.value ? hour.value : '*',
|
||||
day.value ? day.value : '*',
|
||||
month.value ? month.value : '*',
|
||||
week.value ? week.value : '*',
|
||||
);
|
||||
if (!props.hideYear && !props.hideSecond)
|
||||
result.push(year.value ? year.value : '*');
|
||||
return result.join(' ');
|
||||
});
|
||||
|
||||
// 不含年的 cron 表达式
|
||||
const cronValueNoYear = computed(() => {
|
||||
const v = cronValueInner.value;
|
||||
if (props.hideYear || props.hideSecond) return v;
|
||||
let vs = v.split(' ');
|
||||
if (vs.length >= 5) {
|
||||
// 转成 Quartz 的规则
|
||||
vs = ['0', vs[0], vs[1], vs[2], vs[3], convertWeekToQuartz(vs[4]), '*'];
|
||||
}
|
||||
return vs.slice(0, -1).join(' ');
|
||||
});
|
||||
|
||||
const calTriggerList = useDebounceFn(calTriggerListInner, 500);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal === cronValueInner.value) {
|
||||
return;
|
||||
}
|
||||
formatValue();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(cronValueInner, (newValue) => {
|
||||
calTriggerList();
|
||||
emitValue(newValue);
|
||||
assignInput();
|
||||
});
|
||||
|
||||
assignInput();
|
||||
formatValue();
|
||||
calTriggerListInner();
|
||||
|
||||
function assignInput() {
|
||||
inputValues.second = second.value;
|
||||
inputValues.minute = minute.value;
|
||||
inputValues.hour = hour.value;
|
||||
inputValues.day = day.value;
|
||||
inputValues.month = month.value;
|
||||
inputValues.week = week.value;
|
||||
inputValues.year = year.value;
|
||||
inputValues.cron = cronValueInner.value;
|
||||
if (!props.modelValue) emitValue(inputValues.cron);
|
||||
}
|
||||
|
||||
function formatValue() {
|
||||
if (!props.modelValue) return;
|
||||
const values = props.modelValue.split(' ').filter((item) => !!item);
|
||||
if (!values || values.length <= 0) return;
|
||||
let i = 0;
|
||||
if (!props.hideSecond) second.value = values[i++];
|
||||
if (values.length > i) minute.value = values[i++];
|
||||
if (values.length > i) hour.value = values[i++];
|
||||
if (values.length > i) day.value = values[i++];
|
||||
if (values.length > i) month.value = values[i++];
|
||||
if (values.length > i) week.value = values[i++];
|
||||
if (values.length > i) year.value = values[i];
|
||||
assignInput();
|
||||
}
|
||||
|
||||
// Quartz 的规则:
|
||||
// 1 = 周日,2 = 周一,3 = 周二,4 = 周三,5 = 周四,6 = 周五,7 = 周六
|
||||
function convertWeekToQuartz(week: string) {
|
||||
const convert = (v: string) => {
|
||||
if (v === '0') {
|
||||
return '1';
|
||||
}
|
||||
if (v === '1') {
|
||||
return '0';
|
||||
}
|
||||
return (Number.parseInt(v) - 1).toString();
|
||||
};
|
||||
|
||||
const patten1 = /^([0-7])([-/])([0-7])$/;
|
||||
const patten2 = /^([0-7])(,[0-7])+$/;
|
||||
|
||||
if (/^[0-7]$/.test(week)) {
|
||||
return convert(week);
|
||||
} else if (patten1.test(week)) {
|
||||
return week.replace(patten1, (_$0, before, separator, after) => {
|
||||
return separator === '/'
|
||||
? convert(before) + separator + after
|
||||
: convert(before) + separator + convert(after);
|
||||
});
|
||||
} else if (patten2.test(week)) {
|
||||
return week
|
||||
.split(',')
|
||||
.map((v) => convert(v))
|
||||
.join(',');
|
||||
}
|
||||
return week;
|
||||
}
|
||||
|
||||
function calTriggerListInner() {
|
||||
if (props.remote) {
|
||||
props.remote(cronValueInner.value, Date.now(), (v) => {
|
||||
preTimeList.value = v;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
currentDate: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
const iter = CronParser.parseExpression(cronValueNoYear.value, options);
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const nextDate = iter.next();
|
||||
result.push(nextDate.toDate().toLocaleString());
|
||||
}
|
||||
preTimeList.value = result.length > 0 ? result.join('\n') : '无执行时间';
|
||||
} catch {
|
||||
preTimeList.value = '无效的Cron表达式';
|
||||
}
|
||||
}
|
||||
|
||||
function onInputBlur() {
|
||||
second.value = inputValues.second;
|
||||
minute.value = inputValues.minute;
|
||||
hour.value = inputValues.hour;
|
||||
day.value = inputValues.day;
|
||||
month.value = inputValues.month;
|
||||
week.value = inputValues.week;
|
||||
year.value = inputValues.year;
|
||||
}
|
||||
|
||||
function onInputCronBlur() {
|
||||
emitValue(inputValues.cron);
|
||||
}
|
||||
|
||||
function emitValue(value: string) {
|
||||
emit('change', value);
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-inner">
|
||||
<ElTabs v-model="activeKey">
|
||||
<ElTabPane v-if="!hideSecond" label="秒" name="second">
|
||||
<SecondUI v-model="second" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="分" name="minute">
|
||||
<MinuteUI v-model="minute" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="时" name="hour">
|
||||
<HourUI v-model="hour" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="日" name="day">
|
||||
<DayUI v-model="day" :week="week" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="月" name="month">
|
||||
<MonthUI v-model="month" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane label="周" name="week">
|
||||
<WeekUI v-model="week" :day="day" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
<ElTabPane v-if="!hideYear && !hideSecond" label="年" name="year">
|
||||
<YearUI v-model="year" :disabled="disabled" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<!-- 执行时间预览 -->
|
||||
<div class="time-list-container">
|
||||
<div class="time-inputs">
|
||||
<ElInput
|
||||
v-model="inputValues.minute"
|
||||
@blur="onInputBlur"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="label-text" @click="activeKey = 'minute'">分</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElInput
|
||||
v-model="inputValues.hour"
|
||||
@blur="onInputBlur"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="label-text" @click="activeKey = 'hour'">时</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElInput
|
||||
v-model="inputValues.day"
|
||||
@blur="onInputBlur"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="label-text" @click="activeKey = 'day'">日</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElInput
|
||||
v-model="inputValues.month"
|
||||
@blur="onInputBlur"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="label-text" @click="activeKey = 'month'">月</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElInput
|
||||
v-model="inputValues.week"
|
||||
@blur="onInputBlur"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="label-text" @click="activeKey = 'week'">周</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElInput
|
||||
v-model="inputValues.cron"
|
||||
@blur="onInputCronBlur"
|
||||
:disabled="disabled"
|
||||
class="cron-expression-input"
|
||||
>
|
||||
<template #prepend>
|
||||
<ElTooltip title="Cron表达式">Cron表达式</ElTooltip>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="preview-label">近十次执行时间(不含年)</div>
|
||||
<ElInput
|
||||
v-model="preTimeList"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
readonly
|
||||
class="preview-textarea"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-inner {
|
||||
padding: 0 16px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.time-list-container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.label-text:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.cron-expression-input {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preview-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
import CronInner from './cron-inner.vue';
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
hideSecond?: boolean;
|
||||
hideYear?: boolean;
|
||||
remote?: (
|
||||
cron: string,
|
||||
timestamp: number,
|
||||
callback: (result: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
hideSecond: true,
|
||||
hideYear: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'ok']);
|
||||
|
||||
const visible = ref(false);
|
||||
const innerValue = ref(props.modelValue);
|
||||
|
||||
const attrs = computed(() => ({
|
||||
modelValue: innerValue.value,
|
||||
disabled: props.disabled,
|
||||
hideSecond: props.hideSecond,
|
||||
hideYear: props.hideYear,
|
||||
remote: props.remote,
|
||||
}));
|
||||
|
||||
function openModal() {
|
||||
visible.value = true;
|
||||
innerValue.value = props.modelValue;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('update:modelValue', innerValue.value);
|
||||
handleCancel();
|
||||
emit('ok');
|
||||
}
|
||||
|
||||
function handleCronChange(value: string) {
|
||||
innerValue.value = value;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
title="Cron表达式"
|
||||
width="50%"
|
||||
@cancel="handleCancel"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<CronInner v-bind="attrs" @change="handleCronChange" />
|
||||
</ZqDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CronSelectorEmits, CronSelectorProps } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { EditOutlined } from '@vben/icons';
|
||||
|
||||
import { ElButton, ElInput } from 'element-plus';
|
||||
|
||||
import CronModal from './cron-modal.vue';
|
||||
|
||||
interface Props extends CronSelectorProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
hideSecond: true,
|
||||
hideYear: true,
|
||||
placeholder: 'Cron表达式',
|
||||
});
|
||||
|
||||
const emit = defineEmits<CronSelectorEmits>();
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val);
|
||||
},
|
||||
});
|
||||
|
||||
const editCronValue = ref(props.modelValue || '');
|
||||
const cronModalRef = ref();
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal !== editCronValue.value) {
|
||||
editCronValue.value = newVal || '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function showConfigModal() {
|
||||
if (props.disabled) return;
|
||||
cronModalRef.value?.openModal();
|
||||
}
|
||||
|
||||
function handleCronModalUpdate(newValue: string) {
|
||||
editCronValue.value = newValue;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('change', editCronValue.value);
|
||||
emit('update:modelValue', editCronValue.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-selector">
|
||||
<ElInput
|
||||
v-model="value"
|
||||
:placeholder="placeholder"
|
||||
readonly
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
>
|
||||
<template #suffix>
|
||||
<ElButton
|
||||
link
|
||||
:icon="EditOutlined"
|
||||
@click="showConfigModal"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
</ElInput>
|
||||
<CronModal
|
||||
ref="cronModalRef"
|
||||
v-model="editCronValue"
|
||||
:disabled="disabled"
|
||||
:hide-year="hideYear"
|
||||
:hide-second="hideSecond"
|
||||
:remote="remote"
|
||||
@update:model-value="handleCronModalUpdate"
|
||||
@ok="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-selector {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ElButton, ElCard, ElTable, ElTableColumn } from 'element-plus';
|
||||
|
||||
import { CronSelector } from './index';
|
||||
|
||||
const basicCron = ref('0 20 * * *');
|
||||
const secondCron = ref('0 0 * * * * *');
|
||||
const precisionCron = ref('0 0 0 1 1 * 2024');
|
||||
const disabledCron = ref('0 0 * * *');
|
||||
|
||||
const cronExpressions = [
|
||||
{
|
||||
description: '每分钟',
|
||||
expression: '* * * * *',
|
||||
explanation: '在每一分钟的每一秒执行',
|
||||
},
|
||||
{
|
||||
description: '每小时',
|
||||
expression: '0 * * * *',
|
||||
explanation: '在每一小时的整点执行',
|
||||
},
|
||||
{
|
||||
description: '每天午夜',
|
||||
expression: '0 0 * * *',
|
||||
explanation: '每天凌晨 00:00 执行',
|
||||
},
|
||||
{
|
||||
description: '每周一 8 点',
|
||||
expression: '0 8 * * 1',
|
||||
explanation: '每周一上午 8:00 执行',
|
||||
},
|
||||
{
|
||||
description: '每月 1 号',
|
||||
expression: '0 0 1 * *',
|
||||
explanation: '每个月的第 1 天凌晨 00:00 执行',
|
||||
},
|
||||
{
|
||||
description: '工作日 9 点',
|
||||
expression: '0 9 * * 1-5',
|
||||
explanation: '周一到周五上午 9:00 执行',
|
||||
},
|
||||
{
|
||||
description: '每 15 分钟',
|
||||
expression: '0/15 * * * *',
|
||||
explanation: '从 00 分开始,每隔 15 分钟执行',
|
||||
},
|
||||
{
|
||||
description: '每两小时',
|
||||
expression: '0 0/2 * * *',
|
||||
explanation: '从 00 时开始,每隔 2 小时执行',
|
||||
},
|
||||
{
|
||||
description: '午餐时间',
|
||||
expression: '0 12,18 * * *',
|
||||
explanation: '每天中午 12 点和晚上 6 点执行',
|
||||
},
|
||||
{
|
||||
description: '范围',
|
||||
expression: '0 9-17 * * *',
|
||||
explanation: '每天 9 点到 17 点每小时执行',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-example">
|
||||
<h1>Cron 选择器示例</h1>
|
||||
|
||||
<!-- 基础用法 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>基础用法</span>
|
||||
<div class="demo-code">基础配置,隐藏秒和年</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<CronSelector v-model="basicCron" placeholder="输入或编辑Cron表达式" />
|
||||
<div class="demo-result">
|
||||
<p><strong>当前表达式:</strong> {{ basicCron }}</p>
|
||||
<p><strong>说明:</strong> 每天晚上 8 点执行</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 支持秒 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>支持秒</span>
|
||||
<div class="demo-code">显示秒配置</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<CronSelector
|
||||
v-model="secondCron"
|
||||
:hide-second="false"
|
||||
placeholder="包含秒的Cron表达式"
|
||||
/>
|
||||
<div class="demo-result">
|
||||
<p><strong>当前表达式:</strong> {{ secondCron }}</p>
|
||||
<p><strong>说明:</strong> 每分钟的第 0 秒执行</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 支持秒和年 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>支持秒和年</span>
|
||||
<div class="demo-code">精确到年</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<CronSelector
|
||||
v-model="precisionCron"
|
||||
:hide-second="false"
|
||||
:hide-year="false"
|
||||
placeholder="最完整的Cron表达式"
|
||||
/>
|
||||
<div class="demo-result">
|
||||
<p><strong>当前表达式:</strong> {{ precisionCron }}</p>
|
||||
<p><strong>说明:</strong> 仅在 2024 年执行</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 禁用状态 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>禁用状态</span>
|
||||
<div class="demo-code">不可编辑</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<CronSelector
|
||||
v-model="disabledCron"
|
||||
:disabled="true"
|
||||
placeholder="已禁用"
|
||||
/>
|
||||
<div class="demo-result">
|
||||
<p><strong>当前表达式:</strong> {{ disabledCron }}</p>
|
||||
<p><strong>说明:</strong> 此组件已被禁用</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 常见表达式示例 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>快速选择</span>
|
||||
<div class="demo-code">常见的Cron表达式</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<div class="quick-select">
|
||||
<ElButton @click="basicCron = '* * * * *'">每分钟</ElButton>
|
||||
<ElButton @click="basicCron = '0 * * * *'">每小时</ElButton>
|
||||
<ElButton @click="basicCron = '0 0 * * *'">每天</ElButton>
|
||||
<ElButton @click="basicCron = '0 8 * * 1-5'">工作日 8 点</ElButton>
|
||||
<ElButton @click="basicCron = '0 0 1 * *'">每月 1 号</ElButton>
|
||||
<ElButton @click="basicCron = '0 0 * * 0'">每周日</ElButton>
|
||||
<ElButton @click="basicCron = '0/15 * * * *'">每 15 分钟</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- Cron 表达式说明 -->
|
||||
<ElCard class="example-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Cron 表达式说明</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="demo-content">
|
||||
<ElTable :data="cronExpressions" stripe style="width: 100%">
|
||||
<ElTableColumn prop="description" label="描述" width="200" />
|
||||
<ElTableColumn prop="expression" label="表达式" width="200" />
|
||||
<ElTableColumn prop="explanation" label="解释" />
|
||||
<ElTableColumn label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="basicCron = row.expression">
|
||||
使用
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-example {
|
||||
max-width: 1200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cron-example h1 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.example-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.demo-code {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.demo-result {
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-left: 3px solid #409eff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.demo-result p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.demo-result strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quick-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as CronInner } from './cron-inner.vue';
|
||||
export { default as CronModal } from './cron-modal.vue';
|
||||
export { default as CronSelector } from './cron-selector.vue';
|
||||
export type { CronSelectorEmits, CronSelectorProps } from './types';
|
||||
export { TypeEnum } from './types';
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabSetup } from './useTabMixin';
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
week?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '*',
|
||||
disabled: false,
|
||||
week: '*',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const disabledChoice = computed(() => {
|
||||
return (props.week && props.week !== '*') || props.disabled;
|
||||
});
|
||||
|
||||
const setup = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
valueWork: 1,
|
||||
minValue: 1,
|
||||
maxValue: 31,
|
||||
valueRange: { start: 1, end: 31 },
|
||||
valueLoop: { start: 1, interval: 1 },
|
||||
disabled: disabledChoice,
|
||||
},
|
||||
);
|
||||
|
||||
const typeWorkAttrs = computed(() => ({
|
||||
disabled:
|
||||
setup.type.value !== TypeEnum.work ||
|
||||
props.disabled ||
|
||||
disabledChoice.value,
|
||||
...setup.inputNumberAttrs.value,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => props.week,
|
||||
() => {
|
||||
setup.updateValue(disabledChoice.value ? '*' : setup.computeValue.value);
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = setup;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<div class="item tip-item">
|
||||
<span class="tip-info">日和周只能设置其中之一</span>
|
||||
</div>
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" :disabled="disabledChoice">
|
||||
每日
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" :disabled="disabledChoice">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 日 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 日 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" :disabled="disabledChoice">
|
||||
循环
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 日开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 日 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.last" :disabled="disabledChoice">
|
||||
最后一日
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" :disabled="disabledChoice">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ i }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tip-info {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabProps, useTabSetup } from './useTabMixin';
|
||||
|
||||
const props = defineProps({
|
||||
...useTabProps({
|
||||
defaultValue: '*',
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: 0,
|
||||
maxValue: 23,
|
||||
valueRange: { start: 0, end: 23 },
|
||||
valueLoop: { start: 0, interval: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" v-bind="beforeRadioAttrs">
|
||||
每时
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" v-bind="beforeRadioAttrs">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 时 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 时 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 时开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 时 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ i }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabProps, useTabSetup } from './useTabMixin';
|
||||
|
||||
const props = defineProps({
|
||||
...useTabProps({
|
||||
defaultValue: '*',
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: 0,
|
||||
maxValue: 59,
|
||||
valueRange: { start: 0, end: 59 },
|
||||
valueLoop: { start: 0, interval: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" v-bind="beforeRadioAttrs">
|
||||
每分
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" v-bind="beforeRadioAttrs">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 分 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 分 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 分开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 分 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:value="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
/>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabProps, useTabSetup } from './useTabMixin';
|
||||
|
||||
const props = defineProps({
|
||||
...useTabProps({
|
||||
defaultValue: '*',
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: 1,
|
||||
maxValue: 12,
|
||||
valueRange: { start: 1, end: 12 },
|
||||
valueLoop: { start: 1, interval: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" v-bind="beforeRadioAttrs">
|
||||
每月
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" v-bind="beforeRadioAttrs">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 月 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 月 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 月开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 月 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ i }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabProps, useTabSetup } from './useTabMixin';
|
||||
|
||||
const props = defineProps({
|
||||
...useTabProps({
|
||||
defaultValue: '*',
|
||||
}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: 0,
|
||||
maxValue: 59,
|
||||
valueRange: { start: 0, end: 59 },
|
||||
valueLoop: { start: 0, interval: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" v-bind="beforeRadioAttrs">
|
||||
每秒
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" v-bind="beforeRadioAttrs">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 秒 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 秒 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 秒开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 秒 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ i }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabSetup } from './useTabMixin';
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
day?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '*',
|
||||
disabled: false,
|
||||
day: '*',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const weekLabels = {
|
||||
0: '周日',
|
||||
1: '周一',
|
||||
2: '周二',
|
||||
3: '周三',
|
||||
4: '周四',
|
||||
5: '周五',
|
||||
6: '周六',
|
||||
};
|
||||
|
||||
const disabledChoice = computed(() => {
|
||||
return (props.day && props.day !== '*') || props.disabled;
|
||||
});
|
||||
|
||||
const setup = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: 0,
|
||||
maxValue: 6,
|
||||
valueRange: { start: 0, end: 6 },
|
||||
valueLoop: { start: 0, interval: 1 },
|
||||
disabled: disabledChoice,
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.day,
|
||||
() => {
|
||||
setup.updateValue(disabledChoice.value ? '*' : setup.computeValue.value);
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = setup;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<div class="item tip-item">
|
||||
<span class="tip-info">日和周只能设置其中之一</span>
|
||||
</div>
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" :disabled="disabledChoice">
|
||||
每周
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" :disabled="disabledChoice">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从周 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 至周 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" :disabled="disabledChoice">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="checkbox-list">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="(label, value) in weekLabels"
|
||||
:key="value"
|
||||
:label="value"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ label }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tip-info {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
} from 'element-plus';
|
||||
|
||||
import { TypeEnum, useTabProps, useTabSetup } from './useTabMixin';
|
||||
|
||||
const props = defineProps({
|
||||
...useTabProps({
|
||||
defaultValue: '*',
|
||||
}),
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const {
|
||||
type,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueList,
|
||||
specifyRange,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
beforeRadioAttrs,
|
||||
} = useTabSetup(
|
||||
props,
|
||||
{ emit },
|
||||
{
|
||||
defaultValue: '*',
|
||||
minValue: currentYear,
|
||||
maxValue: currentYear + 10,
|
||||
valueRange: { start: currentYear, end: currentYear + 10 },
|
||||
valueLoop: { start: currentYear, interval: 1 },
|
||||
},
|
||||
);
|
||||
|
||||
function handleTypeChange() {
|
||||
// 类型改变时的处理
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-config-list">
|
||||
<ElRadioGroup v-model="type" @change="handleTypeChange">
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.every" v-bind="beforeRadioAttrs">
|
||||
每年
|
||||
</ElRadio>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.range" v-bind="beforeRadioAttrs">
|
||||
区间
|
||||
</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.start"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 年 至 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueRange.end"
|
||||
v-bind="typeRangeAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 年 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</ElRadio>
|
||||
<span class="label"> 从 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.start"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 年开始,间隔 </span>
|
||||
<ElInputNumber
|
||||
v-model="valueLoop.interval"
|
||||
v-bind="typeLoopAttrs"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="label"> 年 </span>
|
||||
</div>
|
||||
<div class="item">
|
||||
<ElRadio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">
|
||||
指定
|
||||
</ElRadio>
|
||||
<div class="year-specify">
|
||||
<ElCheckboxGroup v-model="valueList">
|
||||
<ElCheckbox
|
||||
v-for="i in specifyRange"
|
||||
:key="i"
|
||||
:label="i"
|
||||
:disabled="typeSpecifyAttrs.disabled"
|
||||
>
|
||||
{{ i }}
|
||||
</ElCheckbox>
|
||||
</ElCheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.cron-config-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.year-specify {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 主要用于日和星期的互斥使用
|
||||
*/
|
||||
import { computed, inject, reactive, ref, unref, watch } from 'vue';
|
||||
|
||||
export enum TypeEnum {
|
||||
every = 'EVERY',
|
||||
last = 'LAST',
|
||||
loop = 'LOOP',
|
||||
range = 'RANGE',
|
||||
specify = 'SPECIFY',
|
||||
unset = 'UNSET',
|
||||
work = 'WORK',
|
||||
}
|
||||
|
||||
export interface UseTabOptions {
|
||||
defaultValue?: string;
|
||||
defaultType?: TypeEnum;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
valueRange: { end: number; start: number };
|
||||
valueLoop: { interval: number; start: number };
|
||||
valueWeek?: Record<string, any>;
|
||||
valueWork?: number;
|
||||
disabled?: (() => boolean) | boolean;
|
||||
}
|
||||
|
||||
export interface UseTabProps {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共 props
|
||||
*/
|
||||
export function useTabProps(options?: Partial<UseTabOptions>) {
|
||||
const defaultValue = options?.defaultValue ?? '?';
|
||||
return {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: defaultValue,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
...options?.defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共 setup
|
||||
*/
|
||||
export function useTabSetup(props: any, context: any, options: UseTabOptions) {
|
||||
const { emit } = context;
|
||||
const prefixCls = inject('prefixCls', 'cron');
|
||||
const defaultValue = ref(options?.defaultValue ?? '?');
|
||||
|
||||
// 类型
|
||||
const type = ref(options.defaultType ?? TypeEnum.every);
|
||||
const valueList = ref<any[]>([]);
|
||||
|
||||
// 对于不同的类型,所定义的值也有所不同
|
||||
const valueRange = reactive(options.valueRange);
|
||||
const valueLoop = reactive(options.valueLoop);
|
||||
const valueWeek = reactive(options.valueWeek || {});
|
||||
const valueWork = ref(options.valueWork);
|
||||
const maxValue = ref(options.maxValue);
|
||||
const minValue = ref(options.minValue);
|
||||
|
||||
// 根据不同的类型计算出的value
|
||||
const computeValue = computed(() => {
|
||||
const valueArray: any[] = [];
|
||||
switch (type.value) {
|
||||
case TypeEnum.every: {
|
||||
valueArray.push('*');
|
||||
break;
|
||||
}
|
||||
case TypeEnum.last: {
|
||||
valueArray.push('L');
|
||||
break;
|
||||
}
|
||||
case TypeEnum.loop: {
|
||||
valueArray.push(`${valueLoop.start}/${valueLoop.interval}`);
|
||||
break;
|
||||
}
|
||||
case TypeEnum.range: {
|
||||
valueArray.push(`${valueRange.start}-${valueRange.end}`);
|
||||
break;
|
||||
}
|
||||
case TypeEnum.specify: {
|
||||
if (valueList.value.length === 0) {
|
||||
valueList.value.push(minValue.value);
|
||||
}
|
||||
valueArray.push(valueList.value.join(','));
|
||||
break;
|
||||
}
|
||||
case TypeEnum.unset: {
|
||||
valueArray.push('?');
|
||||
break;
|
||||
}
|
||||
case TypeEnum.work: {
|
||||
valueArray.push(`${valueWork.value}W`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
valueArray.push(defaultValue.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return valueArray.length > 0 ? valueArray.join('') : defaultValue.value;
|
||||
});
|
||||
|
||||
// 指定值范围区间,介于最小值和最大值之间
|
||||
const specifyRange = computed(() => {
|
||||
const range: number[] = [];
|
||||
if (maxValue.value != null) {
|
||||
for (let i = minValue.value; i <= maxValue.value; i++) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
return range;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val !== computeValue.value) {
|
||||
parseValue(val);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(computeValue, (v) => updateValue(v));
|
||||
|
||||
function updateValue(value: string) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
|
||||
/**
|
||||
* parseValue
|
||||
*/
|
||||
function parseValue(value: string) {
|
||||
if (value === computeValue.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!value || value === defaultValue.value) {
|
||||
type.value = TypeEnum.every;
|
||||
} else if (value.includes('?')) {
|
||||
type.value = TypeEnum.unset;
|
||||
} else if (value.includes('-')) {
|
||||
type.value = TypeEnum.range;
|
||||
const values = value.split('-');
|
||||
if (values.length >= 2) {
|
||||
valueRange.start = Number.parseInt(values[0]);
|
||||
valueRange.end = Number.parseInt(values[1]);
|
||||
}
|
||||
} else if (value.includes('/')) {
|
||||
type.value = TypeEnum.loop;
|
||||
const values = value.split('/');
|
||||
if (values.length >= 2) {
|
||||
valueLoop.start = value[0] === '*' ? 0 : Number.parseInt(values[0]);
|
||||
valueLoop.interval = Number.parseInt(values[1]);
|
||||
}
|
||||
} else if (value.includes('W')) {
|
||||
type.value = TypeEnum.work;
|
||||
const values = value.split('W');
|
||||
if (!values[0] && !isNaN(Number.parseInt(values[0]))) {
|
||||
valueWork.value = Number.parseInt(values[0]);
|
||||
}
|
||||
} else if (value.includes('L')) {
|
||||
type.value = TypeEnum.last;
|
||||
} else if (value.includes(',') || !isNaN(Number.parseInt(value))) {
|
||||
type.value = TypeEnum.specify;
|
||||
valueList.value = value.split(',').map((item) => Number.parseInt(item));
|
||||
} else {
|
||||
type.value = TypeEnum.every;
|
||||
}
|
||||
} catch {
|
||||
type.value = TypeEnum.every;
|
||||
}
|
||||
}
|
||||
|
||||
const beforeRadioAttrs = computed(() => ({
|
||||
disabled: props.disabled || unref(options.disabled),
|
||||
}));
|
||||
|
||||
const inputNumberAttrs = computed(() => ({
|
||||
max: maxValue.value,
|
||||
min: minValue.value,
|
||||
}));
|
||||
|
||||
const typeRangeAttrs = computed(() => ({
|
||||
disabled:
|
||||
type.value !== TypeEnum.range ||
|
||||
props.disabled ||
|
||||
unref(options.disabled),
|
||||
...inputNumberAttrs.value,
|
||||
}));
|
||||
|
||||
const typeLoopAttrs = computed(() => ({
|
||||
disabled:
|
||||
type.value !== TypeEnum.loop || props.disabled || unref(options.disabled),
|
||||
...inputNumberAttrs.value,
|
||||
}));
|
||||
|
||||
const typeSpecifyAttrs = computed(() => ({
|
||||
disabled:
|
||||
type.value !== TypeEnum.specify ||
|
||||
props.disabled ||
|
||||
unref(options.disabled),
|
||||
}));
|
||||
|
||||
return {
|
||||
type,
|
||||
TypeEnum,
|
||||
prefixCls,
|
||||
defaultValue,
|
||||
valueRange,
|
||||
valueLoop,
|
||||
valueWeek,
|
||||
valueList,
|
||||
valueWork,
|
||||
maxValue,
|
||||
minValue,
|
||||
computeValue,
|
||||
specifyRange,
|
||||
updateValue,
|
||||
parseValue,
|
||||
beforeRadioAttrs,
|
||||
inputNumberAttrs,
|
||||
typeRangeAttrs,
|
||||
typeLoopAttrs,
|
||||
typeSpecifyAttrs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Cron Selector Props and Types
|
||||
*/
|
||||
export interface CronSelectorProps {
|
||||
/**
|
||||
* Cron 表达式
|
||||
*/
|
||||
modelValue?: string;
|
||||
|
||||
/**
|
||||
* 是否禁用
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* 是否隐藏秒
|
||||
* @default true
|
||||
*/
|
||||
hideSecond?: boolean;
|
||||
|
||||
/**
|
||||
* 是否隐藏年
|
||||
* @default true
|
||||
*/
|
||||
hideYear?: boolean;
|
||||
|
||||
/**
|
||||
* 占位符文本
|
||||
* @default 'Cron表达式'
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* 远程获取执行时间列表的函数
|
||||
*/
|
||||
remote?: (
|
||||
cron: string,
|
||||
timestamp: number,
|
||||
callback: (result: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CronSelectorEmits {
|
||||
/**
|
||||
* 当 Cron 表达式变化时触发
|
||||
*/
|
||||
'update:modelValue': [value: string];
|
||||
|
||||
/**
|
||||
* 当 Cron 表达式变化时触发
|
||||
*/
|
||||
change: [value: string];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab 类型枚举
|
||||
*/
|
||||
export enum TypeEnum {
|
||||
every = 'EVERY',
|
||||
last = 'LAST',
|
||||
loop = 'LOOP',
|
||||
range = 'RANGE',
|
||||
specify = 'SPECIFY',
|
||||
unset = 'UNSET',
|
||||
work = 'WORK',
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CurrentDatetimeEmits, CurrentDatetimeProps } from './types';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { ElInput } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'CurrentDatetime',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<CurrentDatetimeProps>(), {
|
||||
type: 'datetime',
|
||||
disabled: true,
|
||||
placeholder: '当前时间',
|
||||
autoUpdate: false,
|
||||
fillMode: 'onCreate',
|
||||
});
|
||||
|
||||
const emit = defineEmits<CurrentDatetimeEmits>();
|
||||
|
||||
const displayText = ref('');
|
||||
let timer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
const displayFormat = computed(() => {
|
||||
if (props.format) return props.format;
|
||||
|
||||
switch (props.type) {
|
||||
case 'date': {
|
||||
return 'YYYY-MM-DD';
|
||||
}
|
||||
case 'time': {
|
||||
return 'HH:mm:ss';
|
||||
}
|
||||
case 'datetime':
|
||||
default: {
|
||||
return 'YYYY-MM-DD HH:mm:ss';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const valueFormatComputed = computed(() => {
|
||||
if (props.valueFormat) return props.valueFormat;
|
||||
return displayFormat.value;
|
||||
});
|
||||
|
||||
const updateValue = (force = false) => {
|
||||
const now = dayjs();
|
||||
const formattedDisplay = now.format(displayFormat.value);
|
||||
const formattedValue = now.format(valueFormatComputed.value);
|
||||
|
||||
displayText.value = formattedDisplay;
|
||||
|
||||
// 根据 fillMode 决定是否更新值
|
||||
// onCreate: 仅在值为空时填充(创建时间)
|
||||
// onUpdate: 每次都更新(更新时间)
|
||||
// always: 始终更新
|
||||
if (force || props.fillMode === 'onUpdate' || props.fillMode === 'always') {
|
||||
emit('update:modelValue', formattedValue);
|
||||
emit('change', formattedValue);
|
||||
} else if (props.fillMode === 'onCreate' && !props.modelValue) {
|
||||
emit('update:modelValue', formattedValue);
|
||||
emit('change', formattedValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有值,显示已有值
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal && props.fillMode === 'onCreate') {
|
||||
displayText.value = dayjs(newVal).format(displayFormat.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
if (props.fillMode !== 'onCreate' || !props.modelValue) {
|
||||
updateValue();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// onCreate 模式下,只有值为空时才填充
|
||||
if (props.fillMode === 'onCreate') {
|
||||
if (props.modelValue) {
|
||||
displayText.value = dayjs(props.modelValue).format(displayFormat.value);
|
||||
} else {
|
||||
updateValue(true);
|
||||
}
|
||||
} else {
|
||||
// onUpdate 或 always 模式,始终更新
|
||||
updateValue(true);
|
||||
}
|
||||
|
||||
if (props.autoUpdate) {
|
||||
timer = setInterval(() => {
|
||||
updateValue();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-datetime-wrapper w-full">
|
||||
<ElInput
|
||||
v-model="displayText"
|
||||
:placeholder="placeholder"
|
||||
:disabled="true"
|
||||
readonly
|
||||
class="current-datetime-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.current-datetime-input :deep(.el-input__inner) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CurrentDatetime } from './current-datetime.vue';
|
||||
export type { CurrentDatetimeEmits, CurrentDatetimeProps } from './types';
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface CurrentDatetimeProps {
|
||||
modelValue?: string;
|
||||
type?: 'date' | 'datetime' | 'time';
|
||||
format?: string;
|
||||
valueFormat?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
autoUpdate?: boolean;
|
||||
/** 填充模式: onCreate-仅创建时填充, onUpdate-每次更新时填充, always-始终填充 */
|
||||
fillMode?: 'always' | 'onCreate' | 'onUpdate';
|
||||
}
|
||||
|
||||
export interface CurrentDatetimeEmits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
(e: 'change', value: string): void;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CurrentUserEmits, CurrentUserProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { ElInput } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'CurrentUser',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<CurrentUserProps>(), {
|
||||
displayField: 'nickname',
|
||||
valueField: 'realName',
|
||||
showAvatar: false,
|
||||
disabled: true,
|
||||
placeholder: '当前用户',
|
||||
fillMode: 'onCreate',
|
||||
});
|
||||
|
||||
const emit = defineEmits<CurrentUserEmits>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const displayText = ref('');
|
||||
const internalValue = ref('');
|
||||
|
||||
const userInfo = computed(() => userStore.userInfo);
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (!userInfo.value) return '';
|
||||
|
||||
switch (props.displayField) {
|
||||
case 'name':
|
||||
case 'nickname': {
|
||||
return userInfo.value.realName || userInfo.value.username || '';
|
||||
}
|
||||
case 'username': {
|
||||
return userInfo.value.username || '';
|
||||
}
|
||||
default: {
|
||||
return userInfo.value.realName || userInfo.value.username || '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getValue = () => {
|
||||
if (!userInfo.value) return '';
|
||||
|
||||
switch (props.valueField) {
|
||||
case 'realName': {
|
||||
return userInfo.value.realName || userInfo.value.username || '';
|
||||
}
|
||||
case 'username': {
|
||||
return userInfo.value.username || '';
|
||||
}
|
||||
case 'id':
|
||||
default: {
|
||||
return userInfo.value.userId || '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateValue = (force = false) => {
|
||||
const value = getValue();
|
||||
displayText.value = getDisplayText();
|
||||
internalValue.value = value;
|
||||
|
||||
// 根据 fillMode 决定是否更新值
|
||||
// onCreate: 仅在值为空时填充
|
||||
// always: 始终填充
|
||||
if (value) {
|
||||
if (force || props.fillMode === 'always') {
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
} else if (props.fillMode === 'onCreate' && !props.modelValue) {
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有值且是 onCreate 模式,保持显示
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal && props.fillMode === 'onCreate') {
|
||||
// 已有值时,显示已有值(可能是其他用户)
|
||||
displayText.value = newVal;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
userInfo,
|
||||
() => {
|
||||
if (props.fillMode === 'always' || !props.modelValue) {
|
||||
updateValue();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// onCreate 模式下,只有值为空时才填充
|
||||
if (props.fillMode === 'onCreate') {
|
||||
if (!props.modelValue) {
|
||||
updateValue(true);
|
||||
}
|
||||
} else {
|
||||
updateValue(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-user-wrapper flex w-full items-center gap-2">
|
||||
<ElInput
|
||||
v-model="displayText"
|
||||
:placeholder="placeholder"
|
||||
:disabled="true"
|
||||
readonly
|
||||
class="current-user-input w-full"
|
||||
>
|
||||
<template v-if="showAvatar && userInfo?.avatar" #prefix>
|
||||
<img
|
||||
:src="userInfo.avatar"
|
||||
alt="avatar"
|
||||
class="h-5 w-5 rounded-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.current-user-input :deep(.el-input__inner) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CurrentUser } from './current-user.vue';
|
||||
export type { CurrentUserEmits, CurrentUserProps } from './types';
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface CurrentUserProps {
|
||||
modelValue?: string;
|
||||
displayField?: 'name' | 'nickname' | 'username';
|
||||
valueField?: 'id' | 'realName' | 'username';
|
||||
showAvatar?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** 填充模式: onCreate-仅创建时填充, always-始终填充 */
|
||||
fillMode?: 'always' | 'onCreate';
|
||||
}
|
||||
|
||||
export interface CurrentUserEmits {
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
(e: 'change', value: string): void;
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DeptSelectorEmits, DeptSelectorProps } from './types';
|
||||
|
||||
import type { DeptTreeNode } from '#/api/core/dept';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { FolderTree, Search, X } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElScrollbar,
|
||||
ElSelect,
|
||||
ElSkeleton,
|
||||
ElSkeletonItem,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getDeptsByIds, getDeptTreeApi } from '#/api/core/dept';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
defineOptions({
|
||||
name: 'DeptSelector',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
placeholder: () => $t('ui.placeholder.select') || 'Please select',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
autoCurrentDept: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<DeptSelectorEmits>();
|
||||
|
||||
interface Props extends DeptSelectorProps {}
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
// ---- 状态 ----
|
||||
const modalVisible = ref(false);
|
||||
const treeData = ref<DeptTreeNode[]>([]);
|
||||
const treeLoading = ref(false);
|
||||
const treeLoaded = ref(false);
|
||||
const searchText = ref('');
|
||||
const treeRef = ref<InstanceType<typeof ElTree> | null>(null);
|
||||
|
||||
// 已确认的选中 ID
|
||||
const selectedIds = ref<Set<string>>(new Set());
|
||||
// 弹窗内临时选中 ID(未确认前)
|
||||
const tempSelectedIds = ref<Set<string>>(new Set());
|
||||
// 已选部门的名称缓存 { id -> 路径显示名 }
|
||||
const deptNameCache = ref<Map<string, string>>(new Map());
|
||||
// 初始值名称加载中
|
||||
const initLoading = ref(false);
|
||||
|
||||
// ---- 工具函数 ----
|
||||
|
||||
// 从树中递归查找节点
|
||||
const findNodeInTree = (
|
||||
nodes: DeptTreeNode[],
|
||||
id: string,
|
||||
): DeptTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children) {
|
||||
const found = findNodeInTree(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 构建部门路径(从根到目标节点)
|
||||
const buildDeptPath = (id: string): string => {
|
||||
const parentMap = new Map<string, null | string>();
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
const walk = (nodes: DeptTreeNode[], parentId: null | string) => {
|
||||
for (const node of nodes) {
|
||||
parentMap.set(node.id, parentId);
|
||||
nameMap.set(node.id, node.name);
|
||||
if (node.children) walk(node.children, node.id);
|
||||
}
|
||||
};
|
||||
walk(treeData.value, null);
|
||||
|
||||
const parts: string[] = [];
|
||||
let cur: null | string | undefined = id;
|
||||
while (cur) {
|
||||
const name = nameMap.get(cur);
|
||||
if (name) parts.unshift(name);
|
||||
cur = parentMap.get(cur);
|
||||
}
|
||||
return parts.join(' / ');
|
||||
};
|
||||
|
||||
// 更新名称缓存
|
||||
const refreshNameCache = (ids: Iterable<string>) => {
|
||||
for (const id of ids) {
|
||||
if (treeData.value.length > 0) {
|
||||
const path = buildDeptPath(id);
|
||||
if (path) {
|
||||
deptNameCache.value.set(id, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 显示值 ----
|
||||
|
||||
const displayValue = computed({
|
||||
get() {
|
||||
if (selectedIds.value.size === 0) return undefined;
|
||||
return props.multiple ? [...selectedIds.value] : [...selectedIds.value][0];
|
||||
},
|
||||
set(_v) {
|
||||
// ElSelect 内部会尝试修改,忽略
|
||||
},
|
||||
});
|
||||
|
||||
const selectedDeptsWithPath = computed(() => {
|
||||
return [...selectedIds.value].map((id) => ({
|
||||
id,
|
||||
display: initLoading.value
|
||||
? $t('common.loading') || 'Loading...'
|
||||
: deptNameCache.value.get(id) || id,
|
||||
}));
|
||||
});
|
||||
|
||||
const tempSelectedDeptsWithPath = computed(() => {
|
||||
return [...tempSelectedIds.value].map((id) => ({
|
||||
id,
|
||||
display: deptNameCache.value.get(id) || id,
|
||||
}));
|
||||
});
|
||||
|
||||
// ---- 树数据加载 ----
|
||||
|
||||
const loadTree = async () => {
|
||||
if (treeLoaded.value) return;
|
||||
try {
|
||||
treeLoading.value = true;
|
||||
const result = await getDeptTreeApi();
|
||||
treeData.value = Array.isArray(result) ? result : [];
|
||||
treeLoaded.value = true;
|
||||
// 加载完树后刷新所有已选 ID 的名称
|
||||
refreshNameCache(selectedIds.value);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dept tree:', error);
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据 ID 列表加载部门名称(用于初始化时树还没加载的情况)
|
||||
const loadNamesByIds = async (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
// 过滤掉已有缓存的
|
||||
const missing = ids.filter((id) => !deptNameCache.value.has(id));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
try {
|
||||
initLoading.value = true;
|
||||
const result = await getDeptsByIds(missing);
|
||||
if (result && result.length > 0) {
|
||||
// getDeptsByIds 返回的是树形结构,递归提取所有节点名称
|
||||
const extractNames = (nodes: DeptTreeNode[], parentPath: string = '') => {
|
||||
for (const node of nodes) {
|
||||
const currentPath = parentPath
|
||||
? `${parentPath} / ${node.name}`
|
||||
: node.name;
|
||||
// 只缓存目标 ID
|
||||
if (missing.includes(node.id)) {
|
||||
deptNameCache.value.set(node.id, currentPath);
|
||||
}
|
||||
if (node.children) {
|
||||
extractNames(node.children, currentPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
extractNames(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dept names:', error);
|
||||
} finally {
|
||||
initLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- ElTree 搜索过滤 ----
|
||||
|
||||
const filterNode = (value: string, data: DeptTreeNode): boolean => {
|
||||
if (!value) return true;
|
||||
return data.name.toLowerCase().includes(value.toLowerCase());
|
||||
};
|
||||
|
||||
watch(searchText, (val) => {
|
||||
treeRef.value?.filter(val);
|
||||
});
|
||||
|
||||
// ---- 节点点击选择 ----
|
||||
|
||||
const handleNodeClick = (data: DeptTreeNode) => {
|
||||
const id = data.id;
|
||||
if (props.multiple) {
|
||||
if (tempSelectedIds.value.has(id)) {
|
||||
tempSelectedIds.value.delete(id);
|
||||
} else {
|
||||
tempSelectedIds.value.add(id);
|
||||
}
|
||||
// 触发响应式更新
|
||||
tempSelectedIds.value = new Set(tempSelectedIds.value);
|
||||
} else {
|
||||
tempSelectedIds.value = new Set([id]);
|
||||
}
|
||||
// 确保名称缓存
|
||||
if (!deptNameCache.value.has(id)) {
|
||||
const path = buildDeptPath(id);
|
||||
if (path) deptNameCache.value.set(id, path);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 弹窗操作 ----
|
||||
|
||||
const openModal = async () => {
|
||||
if (props.disabled) return;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleModalOpened = async () => {
|
||||
tempSelectedIds.value = new Set(selectedIds.value);
|
||||
searchText.value = '';
|
||||
await loadTree();
|
||||
// 树加载完后展开已选节点的父级
|
||||
await nextTick();
|
||||
for (const id of tempSelectedIds.value) {
|
||||
const node = findNodeInTree(treeData.value, id);
|
||||
if (node) {
|
||||
treeRef.value?.setCurrentKey(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
selectedIds.value = new Set(tempSelectedIds.value);
|
||||
refreshNameCache(selectedIds.value);
|
||||
|
||||
const value = props.multiple
|
||||
? [...selectedIds.value]
|
||||
: selectedIds.value.size > 0
|
||||
? [...selectedIds.value][0]
|
||||
: '';
|
||||
|
||||
// 构建选中项的完整部门信息(用于值关联)
|
||||
const selectedItems = [...selectedIds.value]
|
||||
.map((id) => {
|
||||
const node = findNodeInTree(treeData.value, id);
|
||||
if (node) return node;
|
||||
// 如果树中找不到(路径未加载),使用缓存中的名称
|
||||
const name = deptNameCache.value.get(id);
|
||||
return name ? { id, name } : { id };
|
||||
})
|
||||
.filter(Boolean) as Record<string, any>[];
|
||||
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
// 传递完整部门数据供值关联使用
|
||||
if (selectedItems.length > 0) {
|
||||
emit('select-item', props.multiple ? selectedItems : selectedItems[0]);
|
||||
} else {
|
||||
emit('select-item', undefined);
|
||||
}
|
||||
modalVisible.value = false;
|
||||
};
|
||||
|
||||
const handleClear = (e?: MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
tempSelectedIds.value.clear();
|
||||
selectedIds.value.clear();
|
||||
const emptyValue = props.multiple ? [] : '';
|
||||
emit('update:modelValue', emptyValue);
|
||||
emit('change', emptyValue);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (deptId: string) => {
|
||||
selectedIds.value.delete(deptId);
|
||||
const value = props.multiple ? [...selectedIds.value] : '';
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
const handleRemoveTempTag = (deptId: string) => {
|
||||
tempSelectedIds.value.delete(deptId);
|
||||
tempSelectedIds.value = new Set(tempSelectedIds.value);
|
||||
};
|
||||
|
||||
// ---- 外部 modelValue 同步 ----
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue) => {
|
||||
selectedIds.value.clear();
|
||||
if (Array.isArray(newValue)) {
|
||||
newValue.forEach((v) => selectedIds.value.add(v));
|
||||
} else if (newValue) {
|
||||
selectedIds.value.add(newValue);
|
||||
}
|
||||
|
||||
if (modalVisible.value) {
|
||||
tempSelectedIds.value = new Set(selectedIds.value);
|
||||
}
|
||||
|
||||
// 确保有显示名称
|
||||
const ids = [...selectedIds.value];
|
||||
if (ids.length > 0) {
|
||||
if (treeLoaded.value) {
|
||||
refreshNameCache(ids);
|
||||
} else {
|
||||
await loadNamesByIds(ids);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ---- 自动当前部门 ----
|
||||
|
||||
const applyAutoCurrentDept = async () => {
|
||||
if (!props.autoCurrentDept) return;
|
||||
|
||||
// 已有值时不覆盖
|
||||
const mv = props.modelValue;
|
||||
const hasValue = Array.isArray(mv)
|
||||
? mv.length > 0
|
||||
: mv !== null && mv !== undefined && mv !== '';
|
||||
if (hasValue) return;
|
||||
|
||||
// 从 userStore 获取当前用户信息
|
||||
const { useUserStore } = await import('@vben/stores');
|
||||
const userStore = useUserStore();
|
||||
const info = userStore.userInfo;
|
||||
const deptId = info?.dept_id;
|
||||
if (!deptId) return;
|
||||
|
||||
// 先用 store 中的信息立即设置显示名称
|
||||
if (info?.dept_name) {
|
||||
deptNameCache.value.set(deptId, info.dept_name);
|
||||
}
|
||||
selectedIds.value.add(deptId);
|
||||
const value = props.multiple ? [deptId] : deptId;
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
|
||||
// 异步加载完整部门信息(更新显示名称)
|
||||
try {
|
||||
const { getDeptDetailApi } = await import('#/api/core/dept');
|
||||
const dept = await getDeptDetailApi(deptId);
|
||||
if (dept) {
|
||||
deptNameCache.value.set(deptId, dept.name);
|
||||
}
|
||||
} catch {
|
||||
// 已有 fallback 信息,忽略
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 autoCurrentDept 变化(设计器中开关切换时也能生效)
|
||||
watch(
|
||||
() => props.autoCurrentDept,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
applyAutoCurrentDept();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 组件挂载时:用 nextTick 确保在 modelValue watch immediate 执行完毕后再执行
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
applyAutoCurrentDept();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 自定义节点样式类 ----
|
||||
|
||||
const getNodeClass = (data: DeptTreeNode): string => {
|
||||
return tempSelectedIds.value.has(data.id) ? 'dept-tree-node--selected' : '';
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dept-selector">
|
||||
<!-- 选择框 -->
|
||||
<div
|
||||
class="dept-selector-input"
|
||||
:class="{ disabled, loading: initLoading }"
|
||||
>
|
||||
<ElSelect
|
||||
v-bind="attrs"
|
||||
v-model="displayValue"
|
||||
:placeholder="
|
||||
initLoading ? $t('common.loading') || 'Loading...' : placeholder
|
||||
"
|
||||
:disabled="disabled || initLoading"
|
||||
:clearable="clearable && selectedIds.size > 0"
|
||||
:multiple="multiple"
|
||||
:suffix-icon="FolderTree"
|
||||
readonly
|
||||
@click="openModal"
|
||||
@clear="() => handleClear()"
|
||||
@remove-tag="handleRemoveTag"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in selectedDeptsWithPath"
|
||||
:key="item.id"
|
||||
:label="item.display"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<!-- Loading 提示 -->
|
||||
<!-- <div v-if="initLoading" class="loading-indicator">
|
||||
<Loader class="size-4 animate-spin" />
|
||||
<span class="ml-2 text-xs text-gray-500">{{
|
||||
$t('common.loading') || 'Loading...'
|
||||
}}</span>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<ZqDialog
|
||||
v-model="modalVisible"
|
||||
:title="$t('system.user.selectDept') || 'Select Departments'"
|
||||
width="45%"
|
||||
:show-fullscreen-button="false"
|
||||
@opened="handleModalOpened"
|
||||
>
|
||||
<div class="dept-selector-content">
|
||||
<!-- 左侧:搜索 + 部门树 -->
|
||||
<div class="dept-selector-left">
|
||||
<div v-if="filterable" class="tree-search">
|
||||
<ElInput
|
||||
v-model="searchText"
|
||||
:placeholder="$t('common.search') || 'Search'"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
<ElScrollbar class="tree-scroll">
|
||||
<ElSkeleton :loading="treeLoading" animated :count="8">
|
||||
<template #template>
|
||||
<div class="tree-skeleton">
|
||||
<div v-for="i in 8" :key="i" class="dept-skeleton-item">
|
||||
<ElSkeletonItem
|
||||
variant="text"
|
||||
style="width: 100%; height: 36px; margin: 4px 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="tree-body">
|
||||
<ElEmpty
|
||||
v-if="treeData.length === 0"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
<ElTree
|
||||
v-else
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
:filter-node-method="filterNode as any"
|
||||
:default-expand-all="false"
|
||||
:expand-on-click-node="false"
|
||||
highlight-current
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="dept-tree-node" :class="getNodeClass(data)">
|
||||
{{ data.name }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
</template>
|
||||
</ElSkeleton>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选值 -->
|
||||
<div class="dept-selector-right">
|
||||
<div class="right-header">
|
||||
<span class="right-title">
|
||||
{{ $t('common.selected') || 'Selected' }}
|
||||
<span v-if="tempSelectedIds.size > 0" class="right-count">
|
||||
({{ tempSelectedIds.size }})
|
||||
</span>
|
||||
</span>
|
||||
<ElButton
|
||||
v-if="tempSelectedIds.size > 0"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="tempSelectedIds = new Set()"
|
||||
>
|
||||
{{ $t('common.clear') || 'Clear' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElScrollbar class="right-scroll">
|
||||
<div
|
||||
v-if="tempSelectedDeptsWithPath.length === 0"
|
||||
class="right-empty"
|
||||
>
|
||||
<ElEmpty
|
||||
:image-size="64"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="right-list">
|
||||
<div
|
||||
v-for="item in tempSelectedDeptsWithPath"
|
||||
:key="item.id"
|
||||
class="right-item"
|
||||
>
|
||||
<span class="right-item-name" :title="item.display">
|
||||
{{ item.display }}
|
||||
</span>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
class="right-item-remove"
|
||||
@click="handleRemoveTempTag(item.id)"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<ElButton @click="modalVisible = false">
|
||||
{{ $t('common.cancel') || 'Cancel' }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">
|
||||
{{ $t('common.confirm') || 'Confirm' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ZqDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dept-selector {
|
||||
width: 100%;
|
||||
|
||||
&-input {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
&.is-disabled {
|
||||
background-color: var(--background-deep, #f5f7fa);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-info);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
|
||||
.size-4 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
box-shadow: 0 1px 3px hsl(var(--border) / 12%);
|
||||
}
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
// border-right: 1px solid hsl(var(--border));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
|
||||
.tree-search {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tree-skeleton,
|
||||
.tree-body {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
:deep(.el-tree) {
|
||||
--el-tree-node-hover-bg-color: var(--el-fill-color-light);
|
||||
|
||||
background: transparent;
|
||||
|
||||
.el-tree-node__content {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-tree-node__expand-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-tree-node {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&--selected {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
margin-left: 12px;
|
||||
.right-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 8px;
|
||||
|
||||
.right-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
.right-count {
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.right-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.right-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.right-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-remove {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dept-skeleton-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const DeptSelector = defineAsyncComponent(() =>
|
||||
import('./dept-selector.vue').then((module) => module.default),
|
||||
);
|
||||
|
||||
export * from './types';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface DeptSelectorDept {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: DeptSelectorDept[];
|
||||
parent_id?: null | string;
|
||||
status?: number;
|
||||
child_count?: number;
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface DeptSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
autoCurrentDept?: boolean;
|
||||
}
|
||||
|
||||
export interface DeptSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(e: 'change', value: string | string[] | undefined): void;
|
||||
(e: 'select-item', item: Record<string, any> | Record<string, any>[] | undefined): void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface FileSelectorFile {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'folder';
|
||||
size?: number;
|
||||
file_ext?: string;
|
||||
mime_type?: string;
|
||||
url?: string;
|
||||
sys_create_datetime?: string;
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface FileSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
/** 允许的文件类型,如 ['image/*', '.pdf', '.doc'] */
|
||||
accept?: string[];
|
||||
/** 最大文件大小(MB) */
|
||||
maxSize?: number;
|
||||
/** 是否显示文件大小 */
|
||||
showSize?: boolean;
|
||||
/** 是否显示文件类型图标 */
|
||||
showIcon?: boolean;
|
||||
/** 已选文件显示方式:'popover' - 悬停显示 | 'list' - 选择框下方列表显示 */
|
||||
displayMode?: 'list' | 'popover';
|
||||
/** 触发方式:'default' - 显示触发器UI | 'button' - 不渲染触发器,通过 openModal() 外部控制 */
|
||||
trigger?: 'button' | 'default';
|
||||
/** 上传文件的来源标识,用于文件自动归类,默认 'form' */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface FileSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(e: 'change', value: string | string[] | undefined): void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'FormSelector' });
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
modelValue?: unknown;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown];
|
||||
}>();
|
||||
|
||||
function clearValue() {
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElButton :disabled="disabled" plain @click="clearValue">
|
||||
{{ placeholder || 'Form selector unavailable' }}
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const FormSelector = defineAsyncComponent(() =>
|
||||
import('./form-selector.vue').then((module) => module.default),
|
||||
);
|
||||
|
||||
export * from './types';
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface FormSelectorProps {
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormulaInputEmits, FormulaInputProps } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ElInput, ElTooltip } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'FormulaInput',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<FormulaInputProps>(), {
|
||||
precision: 2,
|
||||
disabled: true,
|
||||
placeholder: '自动计算',
|
||||
showFormula: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<FormulaInputEmits>();
|
||||
|
||||
const AGGREGATE_REGEX = /(?:(SUM|AVG|MAX|MIN|COUNT)\{([^}]+)\})/g;
|
||||
const DATEDIFF_REGEX =
|
||||
/DATEDIFF\{\s*([^,}]+)\s*,\s*([^,}]+)\s*(?:,\s*(days|hours|minutes)\s*)?\}/g;
|
||||
const SIMPLE_FIELD_REGEX = /\B\{([^}]+)\}/g;
|
||||
|
||||
const parseDateValue = (value: any): Date | null => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const computeDateDiff = (
|
||||
endField: string,
|
||||
startField: string,
|
||||
unit: string,
|
||||
data: Record<string, any>,
|
||||
): null | number => {
|
||||
const endVal = parseDateValue(data[endField.trim()]);
|
||||
const startVal = parseDateValue(data[startField.trim()]);
|
||||
if (!endVal || !startVal) return null;
|
||||
const diffMs = endVal.getTime() - startVal.getTime();
|
||||
switch (unit) {
|
||||
case 'hours': {
|
||||
return diffMs / (1000 * 60 * 60);
|
||||
}
|
||||
case 'minutes': {
|
||||
return diffMs / (1000 * 60);
|
||||
}
|
||||
case 'days':
|
||||
default: {
|
||||
return diffMs / (1000 * 60 * 60 * 24);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const computeAggregate = (
|
||||
fn: string,
|
||||
subTableField: string,
|
||||
childField: string,
|
||||
data: Record<string, any>,
|
||||
): null | number => {
|
||||
const rows = data[subTableField];
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return fn === 'COUNT' ? 0 : null;
|
||||
}
|
||||
const values: number[] = [];
|
||||
for (const row of rows) {
|
||||
const v = row[childField];
|
||||
const num = typeof v === 'number' ? v : Number.parseFloat(v);
|
||||
if (!Number.isNaN(num)) {
|
||||
values.push(num);
|
||||
}
|
||||
}
|
||||
if (fn === 'COUNT') return values.length;
|
||||
if (values.length === 0) return null;
|
||||
switch (fn) {
|
||||
case 'AVG': {
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
}
|
||||
case 'MAX': {
|
||||
return Math.max(...values);
|
||||
}
|
||||
case 'MIN': {
|
||||
return Math.min(...values);
|
||||
}
|
||||
case 'SUM': {
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseFormula = (formula: string): string[] => {
|
||||
const regex = /\B\{([^}]+)\}/g;
|
||||
const fields: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(formula)) !== null) {
|
||||
if (match[1]) {
|
||||
fields.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const evaluateFormula = (
|
||||
formula: string,
|
||||
data: Record<string, any>,
|
||||
): null | number => {
|
||||
if (!formula || !data) return null;
|
||||
|
||||
try {
|
||||
let expression = formula;
|
||||
|
||||
// DATEDIFF{end, start, unit} -> 数值
|
||||
expression = expression.replaceAll(
|
||||
new RegExp(DATEDIFF_REGEX.source, 'g'),
|
||||
(_match, endField, startField, unit) => {
|
||||
const result = computeDateDiff(
|
||||
endField,
|
||||
startField,
|
||||
unit || 'days',
|
||||
data,
|
||||
);
|
||||
return result === null ? 'NaN' : result.toString();
|
||||
},
|
||||
);
|
||||
|
||||
// SUM{subTable.field} -> 数值
|
||||
expression = expression.replaceAll(
|
||||
new RegExp(AGGREGATE_REGEX.source, 'g'),
|
||||
(_match, fn, path) => {
|
||||
const dotIdx = path.indexOf('.');
|
||||
if (dotIdx === -1) return 'NaN';
|
||||
const subTableField = path.slice(0, dotIdx);
|
||||
const childField = path.slice(dotIdx + 1);
|
||||
const result = computeAggregate(fn, subTableField, childField, data);
|
||||
return result === null ? 'NaN' : result.toString();
|
||||
},
|
||||
);
|
||||
|
||||
// {field} -> 数值
|
||||
expression = expression.replaceAll(
|
||||
new RegExp(SIMPLE_FIELD_REGEX.source, 'g'),
|
||||
(_match, field) => {
|
||||
const value = data[field];
|
||||
const numValue =
|
||||
typeof value === 'number' ? value : Number.parseFloat(value);
|
||||
return Number.isNaN(numValue) ? 'NaN' : numValue.toString();
|
||||
},
|
||||
);
|
||||
|
||||
if (expression.includes('NaN')) return null;
|
||||
|
||||
expression = expression.replaceAll(/[^0-9+\-*/().]/g, '');
|
||||
|
||||
if (!expression) return null;
|
||||
|
||||
const result = new Function(`return ${expression}`)();
|
||||
|
||||
if (
|
||||
typeof result === 'number' &&
|
||||
!Number.isNaN(result) &&
|
||||
Number.isFinite(result)
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
console.warn('Formula evaluation failed:', formula);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 收集公式中引用的所有依赖值(用于 watch 触发)
|
||||
const collectDeps = (): any[] => {
|
||||
if (!props.formula || !props.formData) return [];
|
||||
const deps: any[] = [];
|
||||
const data = props.formData;
|
||||
let match;
|
||||
// DATEDIFF 引用
|
||||
const dateDiffRe = new RegExp(DATEDIFF_REGEX.source, 'g');
|
||||
while ((match = dateDiffRe.exec(props.formula)) !== null) {
|
||||
deps.push(data[(match[1] || '').trim()]);
|
||||
deps.push(data[(match[2] || '').trim()]);
|
||||
}
|
||||
// 聚合引用
|
||||
const aggRe = new RegExp(AGGREGATE_REGEX.source, 'g');
|
||||
while ((match = aggRe.exec(props.formula)) !== null) {
|
||||
const path = match[2] || '';
|
||||
const dotIdx = path.indexOf('.');
|
||||
if (dotIdx !== -1) {
|
||||
deps.push(JSON.stringify(data[path.slice(0, dotIdx)]));
|
||||
}
|
||||
}
|
||||
// 普通字段引用
|
||||
const simpleRe = new RegExp(SIMPLE_FIELD_REGEX.source, 'g');
|
||||
while ((match = simpleRe.exec(props.formula)) !== null) {
|
||||
deps.push(data[match[1] || '']);
|
||||
}
|
||||
return deps;
|
||||
};
|
||||
|
||||
const calculatedValue = ref<null | number>(null);
|
||||
|
||||
const recalculate = () => {
|
||||
if (!props.formula || !props.formData) {
|
||||
calculatedValue.value = null;
|
||||
return;
|
||||
}
|
||||
const result = evaluateFormula(props.formula, props.formData);
|
||||
calculatedValue.value =
|
||||
result === null ? null : Number(result.toFixed(props.precision));
|
||||
};
|
||||
|
||||
// 显式收集依赖并深度监听,确保子表单行内属性变化时重新计算
|
||||
watch(
|
||||
() => collectDeps(),
|
||||
() => recalculate(),
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (calculatedValue.value !== null) {
|
||||
return calculatedValue.value.toFixed(props.precision);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const formulaDisplay = computed(() => {
|
||||
if (!props.formula) return '';
|
||||
|
||||
let display = props.formula;
|
||||
|
||||
// DATEDIFF 显示
|
||||
display = display.replaceAll(
|
||||
new RegExp(DATEDIFF_REGEX.source, 'g'),
|
||||
(_match, endField, startField, unit) => {
|
||||
const endVal = props.formData?.[endField.trim()];
|
||||
const startVal = props.formData?.[startField.trim()];
|
||||
const endStr = endVal ? `[${endVal}]` : endField.trim();
|
||||
const startStr = startVal ? `[${startVal}]` : startField.trim();
|
||||
return `DATEDIFF{${endStr}, ${startStr}, ${unit || 'days'}}`;
|
||||
},
|
||||
);
|
||||
|
||||
// 普通字段显示
|
||||
const fields = parseFormula(display);
|
||||
for (const field of fields) {
|
||||
const value = props.formData?.[field];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
display = display.replace(`{${field}}`, `[${value}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return display;
|
||||
});
|
||||
|
||||
watch(
|
||||
calculatedValue,
|
||||
(newVal) => {
|
||||
if (newVal !== null && newVal !== props.modelValue) {
|
||||
emit('update:modelValue', newVal);
|
||||
emit('change', newVal);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="formula-input-wrapper">
|
||||
<ElInput
|
||||
:model-value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="true"
|
||||
readonly
|
||||
class="formula-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElTooltip
|
||||
v-if="showFormula && formula"
|
||||
:content="`公式: ${formulaDisplay}`"
|
||||
placement="top"
|
||||
>
|
||||
<span
|
||||
class="cursor-help text-xs text-[var(--el-text-color-placeholder)]"
|
||||
>fx</span>
|
||||
</ElTooltip>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.formula-input :deep(.el-input__inner) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FormulaInput } from './formula-input.vue';
|
||||
export type { FormulaInputEmits, FormulaInputProps } from './types';
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface FormulaInputProps {
|
||||
modelValue?: number | string;
|
||||
formula?: string;
|
||||
formData?: Record<string, any>;
|
||||
precision?: number;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
showFormula?: boolean;
|
||||
}
|
||||
|
||||
export interface FormulaInputEmits {
|
||||
(e: 'update:modelValue', value: number | string): void;
|
||||
(e: 'change', value: number | string): void;
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageSelectorEmits, ImageSelectorFile, ImageSelectorProps } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Check,
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElDialog, ElImageViewer, ElMessage, ElProgress } from 'element-plus';
|
||||
|
||||
import {
|
||||
getFilesInfo,
|
||||
getRecentImages,
|
||||
uploadFile as uploadFileApi,
|
||||
} from '#/api/core/file';
|
||||
import { getFileUrl } from '#/composables/useFileUrl';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageSelector',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
interface Props extends ImageSelectorProps {}
|
||||
|
||||
type UploadingImage = ImageSelectorFile & {
|
||||
failed?: boolean;
|
||||
progress?: number;
|
||||
uploading?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
placeholder: () => $t('ui.placeholder.select') || '?????',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
showImageInfo: true,
|
||||
maxSize: 10,
|
||||
maxWidth: 0,
|
||||
maxHeight: 0,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
accept: () => ['image/*'],
|
||||
gridColumns: 4,
|
||||
sortable: false,
|
||||
enableCrop: false,
|
||||
cropAspectRatio: undefined,
|
||||
cropShape: 'rect',
|
||||
aspectRatioPreset: '',
|
||||
aspectRatioWidth: 16,
|
||||
aspectRatioHeight: 9,
|
||||
aspectRatioMode: 'validate',
|
||||
aspectRatioTolerance: 0.02,
|
||||
size: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
source: 'form',
|
||||
});
|
||||
|
||||
const emit = defineEmits<ImageSelectorEmits>();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const uploadInputRef = ref<HTMLInputElement>();
|
||||
const confirmedImages = ref<UploadingImage[]>([]);
|
||||
const uploadedImages = ref<UploadingImage[]>([]);
|
||||
const recentImages = ref<UploadingImage[]>([]);
|
||||
const selectedImages = ref<Set<string>>(new Set());
|
||||
const recentLoading = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const previewImageUrls = ref<string[]>([]);
|
||||
const previewInitialIndex = ref(0);
|
||||
|
||||
const acceptText = computed(() => props.accept?.join(',') || 'image/*');
|
||||
const triggerWidth = computed(() => props.width ?? props.size ?? undefined);
|
||||
const triggerHeight = computed(() => props.height ?? props.size ?? undefined);
|
||||
const triggerStyle = computed(() => ({
|
||||
'--image-selector-width': triggerWidth.value ? `${triggerWidth.value}px` : undefined,
|
||||
'--image-selector-height': triggerHeight.value ? `${triggerHeight.value}px` : undefined,
|
||||
}));
|
||||
|
||||
const selectedCount = computed(() => selectedImages.value.size);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
const allImages = computed(() => [...uploadedImages.value, ...recentImages.value]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (value) => {
|
||||
const ids = normalizeModelValue(value);
|
||||
if (ids.length === 0) {
|
||||
confirmedImages.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
confirmedImages.value = ids.map((id) => ({ id, name: '', path: '', url: '' }));
|
||||
|
||||
try {
|
||||
const infos = await getFilesInfo(ids);
|
||||
const infoMap = new Map(infos.filter(Boolean).map((item: any) => [String(item.id), item]));
|
||||
confirmedImages.value = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const info = infoMap.get(id) as any;
|
||||
return {
|
||||
id,
|
||||
name: info?.name || info?.file_name || '',
|
||||
path: info?.path || '',
|
||||
size: info?.file_size ?? info?.size,
|
||||
mime_type: info?.mime_type,
|
||||
sys_create_datetime: info?.sys_create_datetime,
|
||||
url: await resolveImageUrl(id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
confirmedImages.value = await Promise.all(
|
||||
ids.map(async (id) => ({ id, name: '', path: '', url: await resolveImageUrl(id) })),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function normalizeModelValue(value: Props['modelValue']): string[] {
|
||||
if (!value) return [];
|
||||
return (Array.isArray(value) ? value : [value]).filter(Boolean).map(String);
|
||||
}
|
||||
|
||||
async function resolveImageUrl(id: string) {
|
||||
try {
|
||||
return await getFileUrl(id);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
if (props.disabled) return;
|
||||
modalVisible.value = true;
|
||||
selectedImages.value = new Set(confirmedImages.value.map((item) => item.id));
|
||||
loadRecentImages();
|
||||
}
|
||||
|
||||
async function loadRecentImages() {
|
||||
recentLoading.value = true;
|
||||
try {
|
||||
const items = await getRecentImages(20);
|
||||
recentImages.value = await Promise.all(
|
||||
(items || []).map(async (item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || item.file_name || '',
|
||||
path: item.path || '',
|
||||
size: item.file_size ?? item.size,
|
||||
mime_type: item.mime_type,
|
||||
sys_create_datetime: item.sys_create_datetime,
|
||||
url: await resolveImageUrl(String(item.id)),
|
||||
})),
|
||||
);
|
||||
} finally {
|
||||
recentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImage(id: string) {
|
||||
const next = new Set(selectedImages.value);
|
||||
if (props.multiple) {
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(id);
|
||||
}
|
||||
selectedImages.value = next;
|
||||
}
|
||||
|
||||
function removeConfirmed(id: string) {
|
||||
const next = confirmedImages.value.filter((item) => item.id !== id);
|
||||
confirmedImages.value = next;
|
||||
emitValue(next.map((item) => item.id));
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
confirmedImages.value = [];
|
||||
emitValue([]);
|
||||
}
|
||||
|
||||
function emitValue(ids: string[]) {
|
||||
const value = props.multiple ? ids : (ids[0] ?? null);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
const selectedIds = [...selectedImages.value];
|
||||
const imageMap = new Map(allImages.value.map((item) => [item.id, item]));
|
||||
confirmedImages.value = selectedIds.map(
|
||||
(id) => imageMap.get(id) || confirmedImages.value.find((item) => item.id === id) || { id, name: '', path: '' },
|
||||
);
|
||||
emitValue(selectedIds);
|
||||
modalVisible.value = false;
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
uploadInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
await uploadFiles(Array.from(input.files || []));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const validFiles = await filterValidFiles(files);
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
for (const file of validFiles) {
|
||||
const tempId = `local-${crypto.randomUUID?.() || Date.now()}`;
|
||||
const item: UploadingImage = {
|
||||
id: tempId,
|
||||
name: file.name,
|
||||
path: '',
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: URL.createObjectURL(file),
|
||||
progress: 0,
|
||||
uploading: true,
|
||||
};
|
||||
uploadedImages.value.unshift(item);
|
||||
|
||||
try {
|
||||
const result: any = await uploadFileApi(file, {
|
||||
isPublic: false,
|
||||
source: props.source,
|
||||
onProgress: ({ percentage }) => {
|
||||
item.progress = percentage;
|
||||
},
|
||||
});
|
||||
const data = result?.data ?? result;
|
||||
const id = String(data?.id ?? data?.file_id ?? data?.fileId);
|
||||
if (!id || id === 'undefined') throw new Error('upload response missing id');
|
||||
item.id = id;
|
||||
item.path = data?.path || '';
|
||||
item.size = data?.file_size ?? data?.size ?? file.size;
|
||||
item.mime_type = data?.mime_type || file.type;
|
||||
item.url = await resolveImageUrl(id);
|
||||
item.uploading = false;
|
||||
item.progress = 100;
|
||||
selectedImages.value = props.multiple
|
||||
? new Set([...selectedImages.value, id])
|
||||
: new Set([id]);
|
||||
} catch (error: any) {
|
||||
item.uploading = false;
|
||||
item.failed = true;
|
||||
ElMessage.error(error?.message || '??????');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function filterValidFiles(files: File[]) {
|
||||
const result: File[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning(`${file.name} ??????`);
|
||||
continue;
|
||||
}
|
||||
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.warning(`${file.name} ?? ${props.maxSize}MB`);
|
||||
continue;
|
||||
}
|
||||
if (!(await validateImageSize(file))) continue;
|
||||
result.push(file);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateImageSize(file: File) {
|
||||
if (!props.maxWidth && !props.maxHeight && !props.minWidth && !props.minHeight) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const { width, height } = image;
|
||||
if (props.maxWidth && width > props.maxWidth) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.maxWidth}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.maxHeight && height > props.maxHeight) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.maxHeight}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.minWidth && width < props.minWidth) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.minWidth}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.minHeight && height < props.minHeight) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.minHeight}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(false);
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (!props.disabled) isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
if (props.disabled) return;
|
||||
await uploadFiles(Array.from(event.dataTransfer?.files || []));
|
||||
}
|
||||
|
||||
function openPreview(images: UploadingImage[], index: number) {
|
||||
const urls = images.map((item) => item.url || item.previewUrl || '').filter(Boolean);
|
||||
if (urls.length === 0) return;
|
||||
previewImageUrls.value = urls;
|
||||
previewInitialIndex.value = index;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-selector" :style="triggerStyle">
|
||||
<div
|
||||
class="image-selector-trigger-grid"
|
||||
:class="{ disabled: disabled, 'has-size': !!(size || width || height) }"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in confirmedImages"
|
||||
:key="image.id"
|
||||
class="image-grid-item image-preview"
|
||||
:class="{ 'is-circle': cropShape === 'circle' }"
|
||||
@click="openPreview(confirmedImages, index)"
|
||||
>
|
||||
<img v-if="image.url || image.previewUrl" class="preview-image" :src="image.url || image.previewUrl" />
|
||||
<FileImageOutlined v-else class="placeholder-icon" />
|
||||
<button
|
||||
v-if="clearable && !disabled"
|
||||
class="image-remove-btn"
|
||||
type="button"
|
||||
@click.stop="removeConfirmed(image.id)"
|
||||
>
|
||||
<DeleteOutlined class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!disabled && (multiple || confirmedImages.length === 0)"
|
||||
class="image-grid-item upload-placeholder"
|
||||
:class="{ 'is-circle': cropShape === 'circle' }"
|
||||
type="button"
|
||||
@click="openModal"
|
||||
>
|
||||
<CloudUploadOutlined class="placeholder-icon" />
|
||||
<span class="placeholder-text">{{ placeholder }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="clearable && !disabled && confirmedImages.length > 0"
|
||||
class="clear-btn"
|
||||
type="button"
|
||||
@click="clearValue"
|
||||
>
|
||||
<DeleteOutlined class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="modalVisible"
|
||||
append-to-body
|
||||
class="image-selector-dialog"
|
||||
title="????"
|
||||
width="860px"
|
||||
>
|
||||
<div class="image-selector-layout" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<div class="upload-area" :class="{ 'is-dragging': isDragging }" @click="handleUploadClick">
|
||||
<CloudUploadOutlined class="upload-area-icon" />
|
||||
<div class="upload-area-title">????</div>
|
||||
<div class="upload-area-hint">???????????? {{ maxSize }}MB</div>
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
class="hidden-input"
|
||||
:accept="acceptText"
|
||||
:multiple="multiple"
|
||||
type="file"
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-selector-content">
|
||||
<div class="toolbar">
|
||||
<span>??? {{ selectedCount }} ?</span>
|
||||
<ElButton size="small" :loading="recentLoading" @click="loadRecentImages">??</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="image-grid">
|
||||
<div
|
||||
v-for="(image, index) in allImages"
|
||||
:key="image.id"
|
||||
class="image-card"
|
||||
:class="{ selected: selectedImages.has(image.id), uploading: image.uploading, failed: image.failed }"
|
||||
@click="!image.uploading && !image.failed && toggleImage(image.id)"
|
||||
>
|
||||
<div class="image-card-preview">
|
||||
<img v-if="image.url || image.previewUrl" class="image-card-img" :src="image.url || image.previewUrl" />
|
||||
<FileImageOutlined v-else class="image-card-empty" />
|
||||
<div v-if="selectedImages.has(image.id)" class="image-card-check">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div v-if="image.uploading" class="image-card-mask">
|
||||
<ElProgress type="circle" :percentage="image.progress || 0" :width="56" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-card-info">
|
||||
<span class="image-card-name">{{ image.name || image.id }}</span>
|
||||
<ElButton text size="small" @click.stop="openPreview(allImages, index)">
|
||||
<EyeOutlined class="h-4 w-4" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="modalVisible = false">??</ElButton>
|
||||
<ElButton type="primary" :disabled="!hasSelection" @click="confirmSelection">??</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElImageViewer
|
||||
v-if="previewVisible"
|
||||
:initial-index="previewInitialIndex"
|
||||
:url-list="previewImageUrls"
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid.has-size {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-grid-item {
|
||||
position: relative;
|
||||
width: var(--image-selector-width, 100%);
|
||||
height: var(--image-selector-height, auto);
|
||||
min-width: var(--image-selector-width, 96px);
|
||||
min-height: var(--image-selector-height, 76px);
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.image-grid-item.is-circle,
|
||||
.image-grid-item.is-circle .preview-image {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-placeholder:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-image,
|
||||
.image-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-remove-btn,
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-remove-btn {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.image-selector-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
min-height: 320px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 40%);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-area.is-dragging,
|
||||
.upload-area:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
}
|
||||
|
||||
.upload-area-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.upload-area-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.upload-area-hint {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-selector-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
max-height: 472px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-card.selected {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.image-card-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.image-card-empty {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: calc(50% - 16px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.image-card-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.image-card-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.image-card-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.image-card-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-selector-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ImageSelector } from './image-selector.vue';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface ImageSelectorFile {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
sys_create_datetime?: string;
|
||||
// 图片特有属性
|
||||
width?: number;
|
||||
height?: number;
|
||||
previewUrl?: string; // 本地预览URL
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface ImageSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange' | 'size'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
/** 最大文件大小(MB) */
|
||||
maxSize?: number;
|
||||
/** 最大图片宽度(像素),0表示不限制 */
|
||||
maxWidth?: number;
|
||||
/** 最大图片高度(像素),0表示不限制 */
|
||||
maxHeight?: number;
|
||||
/** 最小图片宽度(像素),0表示不限制 */
|
||||
minWidth?: number;
|
||||
/** 最小图片高度(像素),0表示不限制 */
|
||||
minHeight?: number;
|
||||
/** 允许的图片格式 */
|
||||
accept?: string[];
|
||||
/** 网格列数 */
|
||||
gridColumns?: number;
|
||||
/** 是否显示图片信息 */
|
||||
showImageInfo?: boolean;
|
||||
/** 是否支持拖拽排序(仅多选时有效) */
|
||||
sortable?: boolean;
|
||||
/** 是否启用裁剪功能 */
|
||||
enableCrop?: boolean;
|
||||
/** 裁剪宽高比,例如 16/9, 4/3, 1 (正方形), undefined (自由裁剪) */
|
||||
cropAspectRatio?: number;
|
||||
/** 比例预设(表单设计器配置) */
|
||||
aspectRatioPreset?: '' | '1:1' | '16:9' | '3:4' | '4:3' | '9:16' | 'custom';
|
||||
/** 自定义比例宽 */
|
||||
aspectRatioWidth?: number;
|
||||
/** 自定义比例高 */
|
||||
aspectRatioHeight?: number;
|
||||
/** 比例限制方式:validate-上传校验 crop-裁剪强制 */
|
||||
aspectRatioMode?: 'crop' | 'validate';
|
||||
/** 比例容差(相对误差),默认 0.02 */
|
||||
aspectRatioTolerance?: number;
|
||||
/** 裁剪形状:'rect' - 矩形 | 'circle' - 圆形 */
|
||||
cropShape?: 'circle' | 'rect';
|
||||
/** 触发器大小(像素),用于控制图片预览和上传按钮的尺寸(正方形) */
|
||||
size?: number;
|
||||
/** 触发器宽度(像素),优先级高于 size */
|
||||
width?: number;
|
||||
/** 触发器高度(像素),优先级高于 size */
|
||||
height?: number;
|
||||
/** 上传文件的来源标识,用于文件自动归类,默认 'form' */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ImageSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | null | undefined): void;
|
||||
(e: 'change', value: string | string[] | null | undefined): void;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LinkedField } from './linked-field.vue';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LinkedFieldEmits, LinkedFieldProps } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElInput } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'LinkedField',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<LinkedFieldProps>(), {
|
||||
placeholder: '',
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<LinkedFieldEmits>();
|
||||
|
||||
// 计算显示值
|
||||
const displayValue = computed(() => {
|
||||
if (!props.sourceField || !props.displayField || !props.formData) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 获取源字段的值(可能是选中项的完整对象或者ID)
|
||||
const sourceValue = props.formData[props.sourceField];
|
||||
|
||||
if (!sourceValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 如果源字段值是对象,直接从中取 displayField
|
||||
if (typeof sourceValue === 'object' && sourceValue !== null) {
|
||||
return sourceValue[props.displayField] ?? '';
|
||||
}
|
||||
|
||||
// 如果源字段值是数组(多选),取第一个的 displayField
|
||||
if (Array.isArray(sourceValue) && sourceValue.length > 0) {
|
||||
const firstItem = sourceValue[0];
|
||||
if (typeof firstItem === 'object' && firstItem !== null) {
|
||||
return firstItem[props.displayField] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 _selectedItem 存储的完整对象
|
||||
const selectedItemKey = `${props.sourceField}_selectedItem`;
|
||||
const selectedItem = props.formData[selectedItemKey];
|
||||
if (selectedItem && typeof selectedItem === 'object') {
|
||||
return selectedItem[props.displayField] ?? '';
|
||||
}
|
||||
|
||||
// 如果有 _selectedItems 存储的完整对象数组
|
||||
const selectedItemsKey = `${props.sourceField}_selectedItems`;
|
||||
const selectedItems = props.formData[selectedItemsKey];
|
||||
if (Array.isArray(selectedItems) && selectedItems.length > 0) {
|
||||
const firstItem = selectedItems[0];
|
||||
if (typeof firstItem === 'object' && firstItem !== null) {
|
||||
return firstItem[props.displayField] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
// 监听显示值变化,更新 modelValue
|
||||
// 注意:当 displayValue 为空但已有保存的 modelValue 时(如编辑回显场景),不覆盖
|
||||
watch(
|
||||
displayValue,
|
||||
(newVal) => {
|
||||
if (newVal !== props.modelValue) {
|
||||
if (newVal) {
|
||||
emit('update:modelValue', newVal);
|
||||
emit('change', newVal);
|
||||
} else if (!props.modelValue) {
|
||||
emit('update:modelValue', undefined);
|
||||
emit('change', undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="linked-field-wrapper">
|
||||
<ElInput
|
||||
:model-value="displayValue || props.modelValue"
|
||||
:placeholder="
|
||||
placeholder || $t('form-design.attribute.linkedFieldPlaceholder')
|
||||
"
|
||||
:disabled="true"
|
||||
readonly
|
||||
class="linked-field"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.linked-field :deep(.el-input__inner) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface LinkedFieldProps {
|
||||
modelValue?: number | string;
|
||||
// 源字段(选择组件的字段名)
|
||||
sourceField?: string;
|
||||
// 要显示的字段名(从选中项中取哪个字段的值)
|
||||
displayField?: string;
|
||||
// 表单数据
|
||||
formData?: Record<string, any>;
|
||||
// 占位符
|
||||
placeholder?: string;
|
||||
// 是否禁用
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkedFieldEmits {
|
||||
(e: 'update:modelValue', value: number | string | undefined): void;
|
||||
(e: 'change', value: number | string | undefined): void;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as MoneyInput } from './money-input.vue';
|
||||
export type { MoneyInputEmits, MoneyInputProps } from './types';
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MoneyInputEmits, MoneyInputProps } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ElInput, ElTooltip } from 'element-plus';
|
||||
|
||||
defineOptions({
|
||||
name: 'MoneyInput',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<MoneyInputProps>(), {
|
||||
precision: 2,
|
||||
currencySymbol: '¥',
|
||||
showCurrency: true,
|
||||
showThousandSeparator: true,
|
||||
showCapital: false,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
placeholder: '请输入金额',
|
||||
});
|
||||
|
||||
const emit = defineEmits<MoneyInputEmits>();
|
||||
|
||||
const inputValue = ref('');
|
||||
const isFocused = ref(false);
|
||||
|
||||
const CAPITAL_DIGITS = [
|
||||
'零',
|
||||
'壹',
|
||||
'贰',
|
||||
'叁',
|
||||
'肆',
|
||||
'伍',
|
||||
'陆',
|
||||
'柒',
|
||||
'捌',
|
||||
'玖',
|
||||
];
|
||||
const CAPITAL_UNITS = ['', '拾', '佰', '仟'];
|
||||
const CAPITAL_BIG_UNITS = ['', '万', '亿', '兆'];
|
||||
|
||||
const numberToCapital = (num: number): string => {
|
||||
if (num === 0) return '零元整';
|
||||
if (num < 0) return `负${numberToCapital(-num)}`;
|
||||
|
||||
const numStr = num.toFixed(2);
|
||||
const [intPart, decPart] = numStr.split('.');
|
||||
|
||||
let result = '';
|
||||
|
||||
if (intPart && Number.parseInt(intPart, 10) > 0) {
|
||||
const intNum = Number.parseInt(intPart, 10);
|
||||
const intStr = intNum.toString();
|
||||
const len = intStr.length;
|
||||
|
||||
let zeroFlag = false;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const digit = Number.parseInt(intStr[i]!, 10);
|
||||
const pos = len - i - 1;
|
||||
const unitPos = pos % 4;
|
||||
const bigUnitPos = Math.floor(pos / 4);
|
||||
|
||||
if (digit === 0) {
|
||||
zeroFlag = true;
|
||||
if (unitPos === 0 && bigUnitPos > 0) {
|
||||
result += CAPITAL_BIG_UNITS[bigUnitPos];
|
||||
}
|
||||
} else {
|
||||
if (zeroFlag) {
|
||||
result += '零';
|
||||
zeroFlag = false;
|
||||
}
|
||||
result +=
|
||||
(CAPITAL_DIGITS[digit] || '') + (CAPITAL_UNITS[unitPos] || '');
|
||||
if (unitPos === 0 && bigUnitPos > 0) {
|
||||
result += CAPITAL_BIG_UNITS[bigUnitPos];
|
||||
}
|
||||
}
|
||||
}
|
||||
result += '元';
|
||||
}
|
||||
|
||||
if (decPart) {
|
||||
const jiao = Number.parseInt(decPart[0]!, 10);
|
||||
const fen = Number.parseInt(decPart[1]!, 10);
|
||||
|
||||
if (jiao === 0 && fen === 0) {
|
||||
result += '整';
|
||||
} else {
|
||||
if (jiao > 0) {
|
||||
result += `${CAPITAL_DIGITS[jiao]}角`;
|
||||
} else if (result) {
|
||||
result += '零';
|
||||
}
|
||||
if (fen > 0) {
|
||||
result += `${CAPITAL_DIGITS[fen]}分`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result += '整';
|
||||
}
|
||||
|
||||
return result || '零元整';
|
||||
};
|
||||
|
||||
const formatWithThousandSeparator = (value: string): string => {
|
||||
if (!value) return '';
|
||||
|
||||
const parts = value.split('.');
|
||||
const intPart = parts[0]!.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
if (parts.length > 1) {
|
||||
return `${intPart}.${parts[1]}`;
|
||||
}
|
||||
return intPart;
|
||||
};
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isFocused.value) {
|
||||
return inputValue.value;
|
||||
}
|
||||
|
||||
if (!inputValue.value) return '';
|
||||
|
||||
let result = inputValue.value;
|
||||
|
||||
if (props.showThousandSeparator) {
|
||||
result = formatWithThousandSeparator(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const capitalValue = computed(() => {
|
||||
if (!props.showCapital || !inputValue.value) return '';
|
||||
|
||||
const num = Number.parseFloat(inputValue.value);
|
||||
if (Number.isNaN(num)) return '';
|
||||
|
||||
return numberToCapital(num);
|
||||
});
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
let cleanValue = value.replaceAll(/[^\d.-]/g, '');
|
||||
|
||||
const parts = cleanValue.split('.');
|
||||
if (parts.length > 2) {
|
||||
cleanValue = `${parts[0]}.${parts.slice(1).join('')}`;
|
||||
}
|
||||
|
||||
if (parts.length === 2 && parts[1]!.length > props.precision) {
|
||||
cleanValue = `${parts[0]}.${parts[1]!.slice(0, props.precision)}`;
|
||||
}
|
||||
|
||||
inputValue.value = cleanValue;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false;
|
||||
|
||||
if (!inputValue.value) {
|
||||
emit('update:modelValue', '');
|
||||
emit('change', '');
|
||||
return;
|
||||
}
|
||||
|
||||
let num = Number.parseFloat(inputValue.value);
|
||||
|
||||
if (Number.isNaN(num)) {
|
||||
inputValue.value = '';
|
||||
emit('update:modelValue', '');
|
||||
emit('change', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.min !== undefined && num < props.min) {
|
||||
num = props.min;
|
||||
}
|
||||
if (props.max !== undefined && num > props.max) {
|
||||
num = props.max;
|
||||
}
|
||||
|
||||
const finalValue = num.toFixed(props.precision);
|
||||
inputValue.value = finalValue;
|
||||
|
||||
emit('update:modelValue', num);
|
||||
emit('change', num);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal === undefined || newVal === null || newVal === '') {
|
||||
inputValue.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const num = typeof newVal === 'string' ? Number.parseFloat(newVal) : newVal;
|
||||
if (!Number.isNaN(num)) {
|
||||
inputValue.value = num.toFixed(props.precision);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="money-input-wrapper">
|
||||
<ElInput
|
||||
:model-value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
@input="handleInput"
|
||||
@blur="handleBlur"
|
||||
@focus="handleFocus"
|
||||
>
|
||||
<template v-if="showCurrency" #prefix>
|
||||
<span class="text-[var(--el-text-color-regular)]">{{
|
||||
currencySymbol
|
||||
}}</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElTooltip
|
||||
v-if="showCapital && capitalValue"
|
||||
:content="capitalValue"
|
||||
placement="top"
|
||||
>
|
||||
<div class="mt-1 truncate text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ capitalValue }}
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.money-input-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface MoneyInputProps {
|
||||
modelValue?: number | string;
|
||||
precision?: number;
|
||||
currencySymbol?: string;
|
||||
showCurrency?: boolean;
|
||||
showThousandSeparator?: boolean;
|
||||
showCapital?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MoneyInputEmits {
|
||||
(e: 'update:modelValue', value: number | string): void;
|
||||
(e: 'change', value: number | string): void;
|
||||
}
|
||||
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,5 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const PostSelector = defineAsyncComponent(() =>
|
||||
import('./post-selector.vue').then((module) => module.default),
|
||||
);
|
||||
@@ -0,0 +1,784 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PostSelectorEmits, PostSelectorProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { Award, Search, X } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElScrollbar,
|
||||
ElSelect,
|
||||
ElSkeleton,
|
||||
ElSkeletonItem,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getPostListApi, getPostsByIds } from '#/api/core/post';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
defineOptions({
|
||||
name: 'PostSelector',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
placeholder: () => $t('ui.placeholder.select') || 'Please select',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<PostSelectorEmits>();
|
||||
|
||||
interface Props extends PostSelectorProps {}
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const posts = ref<any[]>([]);
|
||||
const selectedPosts = ref<Set<string>>(
|
||||
new Set(
|
||||
Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: props.modelValue
|
||||
? [props.modelValue]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
// 临时选择(用于 modal 中的选择,未确认前)
|
||||
const tempSelectedPosts = ref<Set<string>>(new Set());
|
||||
const postLoading = ref(false);
|
||||
const searchText = ref('');
|
||||
// 分页相关
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalPosts = ref(0);
|
||||
const isLoadingMore = ref(false);
|
||||
// 标记是否已经尝试过加载更多(用于显示"没有更多数据"提示)
|
||||
const hasTriedLoadMore = ref(false);
|
||||
// 标记是否已加载过岗位数据
|
||||
const hasLoadedPosts = ref(false);
|
||||
// 标记是否已加载过完整列表(用于弹窗)
|
||||
const hasLoadedFullList = ref(false);
|
||||
|
||||
// 计算显示值(只显示已确认的值)
|
||||
const displayValue = computed({
|
||||
get() {
|
||||
if (selectedPosts.value.size === 0) return undefined;
|
||||
if (props.multiple) {
|
||||
return [...selectedPosts.value];
|
||||
}
|
||||
return [...selectedPosts.value][0];
|
||||
},
|
||||
set(_value) {
|
||||
// ElSelect 会改变这个值,但我们不需要处理
|
||||
},
|
||||
});
|
||||
|
||||
// 获取已选岗位的信息
|
||||
const selectedPostsWithInfo = computed(() => {
|
||||
const result = [];
|
||||
const seenIds = new Set<string>(); // 用于去重
|
||||
|
||||
for (const postId of selectedPosts.value) {
|
||||
// 避免重复添加
|
||||
if (seenIds.has(postId)) continue;
|
||||
seenIds.add(postId);
|
||||
|
||||
const post = posts.value.find((p) => p.id === postId);
|
||||
if (post) {
|
||||
result.push({
|
||||
id: post.id,
|
||||
name: post.name,
|
||||
code: post.code,
|
||||
});
|
||||
} else {
|
||||
// 找不到岗位信息时显示"正在加载中"
|
||||
result.push({
|
||||
id: postId,
|
||||
name: $t('common.loading') || 'Loading...',
|
||||
code: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// 获取临时选择岗位的信息
|
||||
const tempSelectedPostsWithInfo = computed(() => {
|
||||
const result = [];
|
||||
const seenIds = new Set<string>(); // 用于去重
|
||||
|
||||
for (const postId of tempSelectedPosts.value) {
|
||||
// 避免重复添加
|
||||
if (seenIds.has(postId)) continue;
|
||||
|
||||
const post = posts.value.find((p) => p.id === postId);
|
||||
if (post) {
|
||||
seenIds.add(postId);
|
||||
result.push({
|
||||
id: post.id,
|
||||
name: post.name,
|
||||
code: post.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// 加载岗位数据(分页)
|
||||
const loadPosts = async (page: number = 1, append: boolean = false) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
postLoading.value = true;
|
||||
} else {
|
||||
isLoadingMore.value = true;
|
||||
}
|
||||
|
||||
const result = await getPostListApi({
|
||||
page,
|
||||
pageSize: pageSize.value,
|
||||
name: searchText.value || undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// 无论是追加还是重新加载,都需要去重
|
||||
const existingIds = new Set(posts.value.map((p) => p.id));
|
||||
const newItems = (result.items || []).filter(
|
||||
(item: any) => !existingIds.has(item.id),
|
||||
);
|
||||
|
||||
if (append) {
|
||||
// 追加数据(触底加载)
|
||||
posts.value = [...posts.value, ...newItems];
|
||||
} else {
|
||||
// 重新加载(首次加载或搜索)
|
||||
// 合并已有数据(已选项)和新加载的数据
|
||||
posts.value = [...posts.value, ...newItems];
|
||||
}
|
||||
|
||||
totalPosts.value = result.total || 0;
|
||||
currentPage.value = page;
|
||||
hasLoadedPosts.value = true;
|
||||
// 标记已加载完整列表
|
||||
hasLoadedFullList.value = true;
|
||||
}
|
||||
|
||||
postLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
postLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据ID加载特定岗位信息(用于编辑时显示已选岗位的名称)
|
||||
const loadPostsByIds = async (ids: string[]) => {
|
||||
if (!ids || ids.length === 0) return;
|
||||
|
||||
try {
|
||||
postLoading.value = true;
|
||||
|
||||
// 调用后端API按ID查询岗位信息
|
||||
const result = await getPostsByIds(ids);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
// 合并数据,去重
|
||||
const existingIds = new Set(posts.value.map((p) => p.id));
|
||||
const newPosts = result.filter((p: any) => !existingIds.has(p.id));
|
||||
posts.value = [...posts.value, ...newPosts];
|
||||
hasLoadedPosts.value = true;
|
||||
}
|
||||
|
||||
postLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts by ids:', error);
|
||||
postLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 岗位列表直接使用 posts
|
||||
const filteredPosts = computed(() => {
|
||||
return posts.value;
|
||||
});
|
||||
|
||||
// 判断是否还有更多数据
|
||||
const hasMoreData = computed(() => {
|
||||
return posts.value.length < totalPosts.value;
|
||||
});
|
||||
|
||||
// 防抖搜索定时器
|
||||
let searchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
// 监听搜索文本变化,执行服务端搜索
|
||||
watch(searchText, () => {
|
||||
// 清除之前的定时器
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器
|
||||
searchTimer = setTimeout(() => {
|
||||
// 重置分页并重新加载
|
||||
currentPage.value = 1;
|
||||
loadPosts(1, false);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 处理岗位选择
|
||||
const handlePostSelect = (postId: string) => {
|
||||
if (props.multiple) {
|
||||
if (tempSelectedPosts.value.has(postId)) {
|
||||
tempSelectedPosts.value.delete(postId);
|
||||
} else {
|
||||
tempSelectedPosts.value.add(postId);
|
||||
}
|
||||
} else {
|
||||
// 单选模式
|
||||
tempSelectedPosts.value.clear();
|
||||
tempSelectedPosts.value.add(postId);
|
||||
// 单选时直接确认并关闭
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
// 打开modal
|
||||
const openModal = async () => {
|
||||
if (props.disabled) return;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
// 打开modal后加载数据
|
||||
const handleModalOpened = async () => {
|
||||
// 初始化临时选择为已选择的值
|
||||
tempSelectedPosts.value = new Set(selectedPosts.value);
|
||||
|
||||
// 只有在未加载过完整列表时才加载第一页数据
|
||||
if (!hasLoadedFullList.value) {
|
||||
await loadPosts(1, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 触底加载更多
|
||||
const handleScroll = ({
|
||||
scrollTop,
|
||||
}: {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
}) => {
|
||||
const scrollbarRef = document.querySelector(
|
||||
'.post-selector-left .el-scrollbar__wrap',
|
||||
);
|
||||
if (!scrollbarRef) return;
|
||||
|
||||
const scrollHeight = scrollbarRef.scrollHeight;
|
||||
const clientHeight = scrollbarRef.clientHeight;
|
||||
|
||||
// 当滚动到底部附近 50px 时触发加载
|
||||
if (
|
||||
scrollTop + clientHeight >= scrollHeight - 50 &&
|
||||
hasMoreData.value &&
|
||||
!isLoadingMore.value &&
|
||||
!postLoading.value
|
||||
) {
|
||||
hasTriedLoadMore.value = true;
|
||||
loadPosts(currentPage.value + 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
// 将临时选择的值保存到 selectedPosts(已确认)
|
||||
selectedPosts.value = new Set(tempSelectedPosts.value);
|
||||
|
||||
const value = props.multiple
|
||||
? [...selectedPosts.value]
|
||||
: selectedPosts.value.size > 0
|
||||
? [...selectedPosts.value][0]
|
||||
: '';
|
||||
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
modalVisible.value = false;
|
||||
};
|
||||
|
||||
// 清除选择
|
||||
const handleClear = (e?: MouseEvent) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
tempSelectedPosts.value.clear();
|
||||
selectedPosts.value.clear();
|
||||
const emptyValue = props.multiple ? [] : '';
|
||||
emit('update:modelValue', emptyValue);
|
||||
emit('change', emptyValue);
|
||||
};
|
||||
|
||||
// 删除单个选中项(多选模式下点击标签删除按钮)
|
||||
const handleRemoveTag = (postId: string) => {
|
||||
selectedPosts.value.delete(postId);
|
||||
const value = props.multiple ? [...selectedPosts.value] : '';
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
// 监听外部 modelValue 变化
|
||||
const updateInternalValue = () => {
|
||||
selectedPosts.value.clear();
|
||||
tempSelectedPosts.value.clear();
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
props.modelValue.forEach((v) => selectedPosts.value.add(v));
|
||||
} else if (props.modelValue) {
|
||||
selectedPosts.value.add(props.modelValue);
|
||||
}
|
||||
// 打开 modal 时初始化临时选择
|
||||
if (modalVisible.value) {
|
||||
tempSelectedPosts.value = new Set(selectedPosts.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化,如果有值且岗位数据未加载,则加载
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue) => {
|
||||
updateInternalValue();
|
||||
|
||||
// 如果有选中值且岗位数据未加载,则加载岗位数据
|
||||
if (
|
||||
((Array.isArray(newValue) && newValue.length > 0) ||
|
||||
(typeof newValue === 'string' && newValue)) &&
|
||||
!hasLoadedPosts.value
|
||||
) {
|
||||
const ids = Array.isArray(newValue) ? newValue : [newValue];
|
||||
await loadPostsByIds(ids);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 组件挂载时,如果有初始值,则加载岗位数据
|
||||
onMounted(async () => {
|
||||
if (
|
||||
(Array.isArray(props.modelValue) && props.modelValue.length > 0) ||
|
||||
(typeof props.modelValue === 'string' && props.modelValue)
|
||||
) {
|
||||
const ids = Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: [props.modelValue];
|
||||
await loadPostsByIds(ids);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="post-selector">
|
||||
<!-- 选择框 -->
|
||||
<div class="post-selector-input" :class="{ disabled }">
|
||||
<ElSelect
|
||||
v-bind="attrs"
|
||||
v-model="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable && selectedPosts.size > 0"
|
||||
:multiple="multiple"
|
||||
:suffix-icon="Award"
|
||||
readonly
|
||||
@click="openModal"
|
||||
@clear="() => handleClear()"
|
||||
@remove-tag="handleRemoveTag"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in selectedPostsWithInfo"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<ZqDialog
|
||||
v-model="modalVisible"
|
||||
:title="$t('system.user.selectPost') || 'Select Posts'"
|
||||
width="45%"
|
||||
:show-fullscreen-button="false"
|
||||
@opened="handleModalOpened"
|
||||
>
|
||||
<div class="post-selector-content">
|
||||
<!-- 左侧:搜索 + 岗位列表 -->
|
||||
<div class="post-selector-left">
|
||||
<div v-if="filterable" class="list-search">
|
||||
<ElInput
|
||||
v-model="searchText"
|
||||
:placeholder="$t('common.search') || 'Search'"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
<ElScrollbar class="list-scroll" @scroll="handleScroll">
|
||||
<ElSkeleton :loading="postLoading" animated :count="8">
|
||||
<template #template>
|
||||
<div class="list-skeleton">
|
||||
<div v-for="i in 8" :key="i" class="post-skeleton-item">
|
||||
<ElSkeletonItem
|
||||
variant="text"
|
||||
style="width: 100%; height: 36px; margin: 4px 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="list-body">
|
||||
<ElEmpty
|
||||
v-if="filteredPosts.length === 0 && !postLoading"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
<div v-else class="post-list">
|
||||
<div
|
||||
v-for="post in filteredPosts"
|
||||
:key="post.id"
|
||||
class="post-item"
|
||||
:class="{
|
||||
'post-item--selected': tempSelectedPosts.has(post.id),
|
||||
}"
|
||||
@click="handlePostSelect(post.id)"
|
||||
>
|
||||
<div class="post-name">{{ post.name }}</div>
|
||||
<div v-if="post.code" class="post-code">
|
||||
{{ post.code }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<ElSkeletonItem
|
||||
variant="text"
|
||||
style="width: 100%; height: 36px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多数据提示 -->
|
||||
<div
|
||||
v-if="
|
||||
!hasMoreData &&
|
||||
filteredPosts.length > 0 &&
|
||||
hasTriedLoadMore
|
||||
"
|
||||
class="no-more-data"
|
||||
>
|
||||
{{ $t('common.noMoreData') || 'No more data' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElSkeleton>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选值 -->
|
||||
<div class="post-selector-right">
|
||||
<div class="right-header">
|
||||
<span class="right-title">
|
||||
{{ $t('common.selected') || 'Selected' }}
|
||||
<span v-if="tempSelectedPosts.size > 0" class="right-count">
|
||||
({{ tempSelectedPosts.size }})
|
||||
</span>
|
||||
</span>
|
||||
<ElButton
|
||||
v-if="tempSelectedPosts.size > 0"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="
|
||||
() => {
|
||||
tempSelectedPosts.clear();
|
||||
tempSelectedPosts = new Set();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('common.clear') || 'Clear' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElScrollbar class="right-scroll">
|
||||
<div
|
||||
v-if="tempSelectedPostsWithInfo.length === 0"
|
||||
class="right-empty"
|
||||
>
|
||||
<ElEmpty
|
||||
:image-size="64"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="right-list">
|
||||
<div
|
||||
v-for="item in tempSelectedPostsWithInfo"
|
||||
:key="item.id"
|
||||
class="right-item"
|
||||
>
|
||||
<span class="right-item-name" :title="item.name">
|
||||
{{ item.name }}
|
||||
<span v-if="item.code" class="right-item-code"
|
||||
>({{ item.code }})</span
|
||||
>
|
||||
</span>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
class="right-item-remove"
|
||||
@click="
|
||||
() => {
|
||||
tempSelectedPosts.delete(item.id);
|
||||
tempSelectedPosts = new Set(tempSelectedPosts);
|
||||
}
|
||||
"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<ElButton @click="modalVisible = false">
|
||||
{{ $t('common.cancel') || 'Cancel' }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">
|
||||
{{ $t('common.confirm') || 'Confirm' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ZqDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.post-selector {
|
||||
width: 100%;
|
||||
|
||||
&-input {
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
&.is-disabled {
|
||||
background-color: var(--background-deep, #f5f7fa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
box-shadow: 0 1px 3px hsl(var(--border) / 12%);
|
||||
}
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
|
||||
.list-search {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-skeleton,
|
||||
.list-body {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
|
||||
.post-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.post-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.post-code {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
background: hsl(var(--background-deep) / 50%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-more-data {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
margin-left: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
|
||||
.right-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 8px;
|
||||
|
||||
.right-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
.right-count {
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.right-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.right-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.right-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-code {
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&-remove {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.post-skeleton-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface PostSelectorPost {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface PostSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
export interface PostSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(e: 'change', value: string | string[] | undefined): void;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as QRCodeGenerator } from './qrcode-generator.vue';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,341 @@
|
||||
<script setup lang="ts">
|
||||
import type { QRCodeGeneratorEmits, QRCodeGeneratorProps } from './types';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { Copy, Download, QrCode } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElMessage, ElTooltip } from 'element-plus';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import { useCodeContent } from '../shared/useCodeContent';
|
||||
|
||||
defineOptions({
|
||||
name: 'QRCodeGenerator',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<QRCodeGeneratorProps>(), {
|
||||
dataSource: 'static',
|
||||
qrcodeType: 'text',
|
||||
size: 200,
|
||||
errorCorrectionLevel: 'M',
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
logoSize: 40,
|
||||
margin: 2,
|
||||
showContent: false,
|
||||
enableDownload: false,
|
||||
enableCopy: false,
|
||||
downloadFilename: 'qrcode',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
placeholder: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<QRCodeGeneratorEmits>();
|
||||
|
||||
const qrcodeUrl = ref<string>('');
|
||||
const isGenerating = ref(false);
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
|
||||
const contentOptions = computed(() => ({
|
||||
dataSource: props.dataSource,
|
||||
modelValue: props.modelValue,
|
||||
boundField: props.boundField,
|
||||
formula: props.formula,
|
||||
formData: props.formData,
|
||||
contentType: props.qrcodeType,
|
||||
vcardInfo: props.vcardInfo,
|
||||
wifiInfo: props.wifiInfo,
|
||||
}));
|
||||
|
||||
const qrcodeContent = useCodeContent(contentOptions);
|
||||
|
||||
async function generateQRCode() {
|
||||
const content = qrcodeContent.value;
|
||||
if (!content) {
|
||||
qrcodeUrl.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating.value = true;
|
||||
|
||||
try {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
await QRCode.toCanvas(canvas, content, {
|
||||
width: props.size,
|
||||
margin: props.margin,
|
||||
errorCorrectionLevel: props.errorCorrectionLevel,
|
||||
color: {
|
||||
dark: props.foregroundColor,
|
||||
light: props.backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
if (props.logoUrl) {
|
||||
await drawLogo(canvas, props.logoUrl);
|
||||
}
|
||||
|
||||
qrcodeUrl.value = canvas.toDataURL('image/png');
|
||||
emit('generated', content);
|
||||
} catch (error) {
|
||||
console.error('Generate QRCode failed:', error);
|
||||
ElMessage.error($t('form-design.qrcode.generateError'));
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function drawLogo(
|
||||
canvas: HTMLCanvasElement,
|
||||
logoUrl: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.addEventListener('load', () => {
|
||||
const logoSize = props.logoSize;
|
||||
const x = (canvas.width - logoSize) / 2;
|
||||
const y = (canvas.height - logoSize) / 2;
|
||||
|
||||
ctx.fillStyle = props.backgroundColor;
|
||||
ctx.fillRect(x - 2, y - 2, logoSize + 4, logoSize + 4);
|
||||
ctx.drawImage(img, x, y, logoSize, logoSize);
|
||||
resolve();
|
||||
});
|
||||
img.onerror = () => {
|
||||
console.warn('Load logo failed');
|
||||
resolve();
|
||||
};
|
||||
img.src = logoUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadQRCode() {
|
||||
if (!qrcodeUrl.value) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `${props.downloadFilename}.png`;
|
||||
link.href = qrcodeUrl.value;
|
||||
link.click();
|
||||
|
||||
emit('downloaded');
|
||||
ElMessage.success($t('form-design.qrcode.downloadSuccess'));
|
||||
}
|
||||
|
||||
async function copyContent() {
|
||||
const content = qrcodeContent.value;
|
||||
if (!content) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
emit('copied');
|
||||
ElMessage.success($t('form-design.qrcode.copySuccess'));
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
ElMessage.error($t('form-design.qrcode.copyError'));
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderText = computed(
|
||||
() => props.placeholder || $t('form-design.qrcode.placeholder'),
|
||||
);
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => [
|
||||
qrcodeContent.value,
|
||||
props.size,
|
||||
props.foregroundColor,
|
||||
props.backgroundColor,
|
||||
props.errorCorrectionLevel,
|
||||
props.margin,
|
||||
props.logoUrl,
|
||||
props.logoSize,
|
||||
],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
generateQRCode();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qrcode-generator">
|
||||
<div
|
||||
class="qrcode-generator__container"
|
||||
:class="{
|
||||
'qrcode-generator__container--empty': !qrcodeContent,
|
||||
'qrcode-generator__container--disabled': disabled,
|
||||
}"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<canvas ref="canvasRef" style="display: none"></canvas>
|
||||
|
||||
<img
|
||||
v-if="qrcodeUrl"
|
||||
:src="qrcodeUrl"
|
||||
alt="QRCode"
|
||||
class="qrcode-generator__image"
|
||||
/>
|
||||
|
||||
<div v-else class="qrcode-generator__placeholder">
|
||||
<QrCode class="qrcode-generator__placeholder-icon" />
|
||||
<span class="qrcode-generator__placeholder-text">{{
|
||||
placeholderText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isGenerating" class="qrcode-generator__loading">
|
||||
<div class="qrcode-generator__spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showContent && qrcodeContent" class="qrcode-generator__content">
|
||||
<ElTooltip :content="qrcodeContent" placement="top" :show-after="300">
|
||||
<span class="qrcode-generator__content-text">{{ qrcodeContent }}</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(enableDownload || enableCopy) && qrcodeUrl && !disabled"
|
||||
class="qrcode-generator__actions"
|
||||
>
|
||||
<ElButton
|
||||
v-if="enableDownload"
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Download"
|
||||
@click="downloadQRCode"
|
||||
>
|
||||
{{ $t('form-design.qrcode.download') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="enableCopy"
|
||||
size="small"
|
||||
:icon="Copy"
|
||||
@click="copyContent"
|
||||
>
|
||||
{{ $t('form-design.qrcode.copy') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qrcode-generator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qrcode-generator__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qrcode-generator__container--empty {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.qrcode-generator__container--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.qrcode-generator__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qrcode-generator__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.qrcode-generator__placeholder-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.qrcode-generator__placeholder-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.qrcode-generator__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: color-mix(in srgb, var(--el-bg-color) 80%, transparent);
|
||||
}
|
||||
|
||||
.qrcode-generator__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--el-border-color);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode-generator__content {
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.qrcode-generator__content-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qrcode-generator__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
CodeDisplayBaseProps,
|
||||
ErrorCorrectionLevel,
|
||||
QRCodeType,
|
||||
VCardInfo,
|
||||
WiFiInfo,
|
||||
} from '../shared/types';
|
||||
|
||||
export type { QRCodeType, ErrorCorrectionLevel, VCardInfo, WiFiInfo };
|
||||
|
||||
export type QRCodeDataSource = 'field' | 'formula' | 'static';
|
||||
|
||||
export interface QRCodeGeneratorProps extends CodeDisplayBaseProps {
|
||||
modelValue?: null | string;
|
||||
qrcodeType?: QRCodeType;
|
||||
size?: number;
|
||||
errorCorrectionLevel?: ErrorCorrectionLevel;
|
||||
foregroundColor?: string;
|
||||
backgroundColor?: string;
|
||||
logoUrl?: string;
|
||||
logoSize?: number;
|
||||
margin?: number;
|
||||
vcardInfo?: VCardInfo;
|
||||
wifiInfo?: WiFiInfo;
|
||||
}
|
||||
|
||||
export interface QRCodeGeneratorEmits {
|
||||
(e: 'update:modelValue', value: null | string): void;
|
||||
(e: 'change', value: null | string): void;
|
||||
(e: 'generated', content: string): void;
|
||||
(e: 'downloaded'): void;
|
||||
(e: 'copied'): void;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { regionData } from './region-data';
|
||||
export { default as RegionSelector } from './region-selector.vue';
|
||||
export * from './types';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 省市区街道村庄级联选择器组件
|
||||
* 支持静态数据和动态 API 加载两种模式
|
||||
* 支持五级行政区划:省、市、区、街道、村庄
|
||||
*/
|
||||
import type { RegionItem, RegionLevel } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { ElCascader, ElMessage } from 'element-plus';
|
||||
|
||||
import { RegionApi } from '#/api/core/region';
|
||||
|
||||
import { regionData as staticRegionData } from './region-data';
|
||||
|
||||
defineOptions({
|
||||
name: 'RegionSelector',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
apiUrl?: string;
|
||||
checkStrictly?: boolean;
|
||||
clearable?: boolean;
|
||||
dataSource?: 'api' | 'static';
|
||||
disabled?: boolean;
|
||||
expandTrigger?: 'click' | 'hover';
|
||||
lazy?: boolean;
|
||||
level?: RegionLevel;
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
separator?: string;
|
||||
showAllLevels?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: undefined,
|
||||
level: 3,
|
||||
placeholder: undefined,
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
multiple: false,
|
||||
showAllLevels: true,
|
||||
separator: '/',
|
||||
dataSource: 'api',
|
||||
apiUrl: '/api/core/regions',
|
||||
lazy: true,
|
||||
checkStrictly: false,
|
||||
expandTrigger: 'click',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(
|
||||
e: 'change',
|
||||
value: string | string[] | undefined,
|
||||
selectedOptions: RegionItem[],
|
||||
): void;
|
||||
}>();
|
||||
|
||||
// 内部值
|
||||
const innerValue = ref<string[]>([]);
|
||||
|
||||
// 区域数据(转换为 ElCascader 格式后的数据)
|
||||
const regionData = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 转换数据格式为 ElCascader 需要的格式
|
||||
const convertToElCascaderFormat = (
|
||||
items: RegionItem[],
|
||||
currentLevel: number = 1,
|
||||
): any[] => {
|
||||
return items.map((item) => {
|
||||
const node: any = {
|
||||
value: item.code,
|
||||
label: item.name,
|
||||
};
|
||||
|
||||
// 如果是懒加载模式且未达到最大级别,标记为可展开
|
||||
if (props.lazy && currentLevel < props.level) {
|
||||
node.leaf = false;
|
||||
} else if (item.children && item.children.length > 0) {
|
||||
node.children = convertToElCascaderFormat(
|
||||
item.children,
|
||||
currentLevel + 1,
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// 根据级别过滤数据
|
||||
const filterDataByLevel = (data: RegionItem[], level: number): any[] => {
|
||||
let filtered = data;
|
||||
|
||||
if (level === 1) {
|
||||
// 只保留省级
|
||||
filtered = data.map((province) => ({
|
||||
code: province.code,
|
||||
name: province.name,
|
||||
}));
|
||||
} else if (level === 2) {
|
||||
// 保留省市两级
|
||||
filtered = data.map((province) => ({
|
||||
code: province.code,
|
||||
name: province.name,
|
||||
children: province.children?.map((city) => ({
|
||||
code: city.code,
|
||||
name: city.name,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
// 转换为 ElCascader 格式
|
||||
return convertToElCascaderFormat(filtered);
|
||||
};
|
||||
|
||||
// 加载静态数据
|
||||
const loadStaticData = () => {
|
||||
console.log('loadStaticData 被调用');
|
||||
console.log('staticRegionData:', staticRegionData);
|
||||
console.log('staticRegionData.length:', staticRegionData.length);
|
||||
const result = filterDataByLevel(staticRegionData, props.level);
|
||||
console.log('转换后的数据:', result);
|
||||
console.log('转换后的数据长度:', result.length);
|
||||
regionData.value = result;
|
||||
console.log('赋值后 regionData.value:', regionData.value);
|
||||
};
|
||||
|
||||
// 加载 API 数据(初始加载省份)
|
||||
const loadApiData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const provinces = await RegionApi.getProvinces();
|
||||
regionData.value = convertToElCascaderFormat(provinces, 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to load region data:', error);
|
||||
ElMessage.error('加载省份数据失败');
|
||||
regionData.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 懒加载子节点
|
||||
const lazyLoad = async (node: any, resolve: any) => {
|
||||
const { level, value } = node;
|
||||
|
||||
try {
|
||||
let children: any[] = [];
|
||||
|
||||
// 根据当前级别加载下一级数据
|
||||
switch (level) {
|
||||
case 0: {
|
||||
// 加载省份(不应该走到这里,因为省份在初始化时已加载)
|
||||
const provinces = await RegionApi.getProvinces();
|
||||
children = provinces;
|
||||
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
// 加载城市
|
||||
const cities = await RegionApi.getCities(value);
|
||||
children = cities;
|
||||
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
// 加载区县
|
||||
const areas = await RegionApi.getAreas(value);
|
||||
children = areas;
|
||||
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
// 加载街道
|
||||
const streets = await RegionApi.getStreets(value);
|
||||
children = streets;
|
||||
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
// 加载村庄
|
||||
const villages = await RegionApi.getVillages(value);
|
||||
children = villages;
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
|
||||
// 转换格式
|
||||
const nodes = children.map((item: any) => ({
|
||||
value: item.code,
|
||||
label: item.name,
|
||||
leaf: level + 1 >= props.level, // 如果达到指定级别,标记为叶子节点
|
||||
}));
|
||||
|
||||
resolve(nodes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load children:', error);
|
||||
ElMessage.error('加载数据失败');
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = () => {
|
||||
if (props.dataSource === 'api') {
|
||||
loadApiData();
|
||||
} else {
|
||||
loadStaticData();
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化加载
|
||||
loadData();
|
||||
|
||||
// 监听数据源变化
|
||||
watch(
|
||||
() => [props.dataSource, props.apiUrl, props.level],
|
||||
() => {
|
||||
loadData();
|
||||
},
|
||||
);
|
||||
|
||||
// 同步外部值到内部
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val === undefined || val === null) {
|
||||
innerValue.value = [];
|
||||
} else if (Array.isArray(val)) {
|
||||
innerValue.value = val;
|
||||
} else {
|
||||
// 兼容旧格式:如果传入的是字符串,尝试解析
|
||||
innerValue.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 获取选中的区域对象
|
||||
const getSelectedRegions = (codes: string[]): RegionItem[] => {
|
||||
const regions: RegionItem[] = [];
|
||||
let currentLevel = regionData.value;
|
||||
|
||||
for (const code of codes) {
|
||||
const node = currentLevel.find((n) => n.code === code);
|
||||
if (node) {
|
||||
regions.push({ code: node.code, name: node.name });
|
||||
currentLevel = node.children || [];
|
||||
}
|
||||
}
|
||||
|
||||
return regions;
|
||||
};
|
||||
|
||||
// 处理值变化
|
||||
const handleChange = (value: any) => {
|
||||
const codes = Array.isArray(value) ? value : [];
|
||||
innerValue.value = codes;
|
||||
|
||||
// 输出完整路径数组,而不是单个编码
|
||||
const outputValue = codes.length > 0 ? codes : undefined;
|
||||
const selectedRegions = codes.length > 0 ? getSelectedRegions(codes) : [];
|
||||
|
||||
emit('update:modelValue', outputValue);
|
||||
emit('change', outputValue, selectedRegions);
|
||||
};
|
||||
|
||||
// 计算 placeholder
|
||||
const computedPlaceholder = computed(() => {
|
||||
if (props.placeholder) return props.placeholder;
|
||||
|
||||
const levelTexts: Record<number, string> = {
|
||||
1: '请选择省份',
|
||||
2: '请选择省/市',
|
||||
3: '请选择省/市/区',
|
||||
4: '请选择省/市/区/街道',
|
||||
5: '请选择省/市/区/街道/村庄',
|
||||
};
|
||||
|
||||
return levelTexts[props.level] || levelTexts[3];
|
||||
});
|
||||
|
||||
// Cascader Props
|
||||
const cascaderProps = computed(() => {
|
||||
const baseProps: any = {
|
||||
checkStrictly: props.checkStrictly,
|
||||
expandTrigger: props.expandTrigger,
|
||||
};
|
||||
|
||||
// 如果是懒加载模式
|
||||
if (props.lazy && props.dataSource === 'api') {
|
||||
baseProps.lazy = true;
|
||||
baseProps.lazyLoad = lazyLoad;
|
||||
}
|
||||
|
||||
return baseProps;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElCascader
|
||||
v-model="innerValue"
|
||||
:options="regionData"
|
||||
:props="cascaderProps"
|
||||
:placeholder="computedPlaceholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
:show-all-levels="showAllLevels"
|
||||
:separator="separator"
|
||||
:loading="loading"
|
||||
filterable
|
||||
class="w-full"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 省市区街道村庄选择器类型定义
|
||||
*/
|
||||
|
||||
// 区域数据项
|
||||
export interface RegionItem {
|
||||
code: string;
|
||||
name: string;
|
||||
children?: RegionItem[];
|
||||
}
|
||||
|
||||
// 数据源类型
|
||||
export type DataSourceType = 'api' | 'static';
|
||||
|
||||
// 级别类型:1-省 2-市 3-区 4-街道 5-村庄
|
||||
export type RegionLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
// 组件 Props
|
||||
export interface RegionSelectorProps {
|
||||
modelValue?: string | string[];
|
||||
level?: RegionLevel;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
multiple?: boolean;
|
||||
showAllLevels?: boolean;
|
||||
separator?: string;
|
||||
dataSource?: DataSourceType;
|
||||
apiUrl?: string;
|
||||
lazy?: boolean; // 是否懒加载
|
||||
checkStrictly?: boolean; // 是否严格的选择任意一级
|
||||
expandTrigger?: 'click' | 'hover'; // 展开触发方式
|
||||
}
|
||||
|
||||
// 组件 Emits
|
||||
export interface RegionSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(
|
||||
e: 'change',
|
||||
value: string | string[] | undefined,
|
||||
selectedOptions: RegionItem[],
|
||||
): 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as RichTextEditor } from './rich-text-editor.vue';
|
||||
export type * from './types';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import type { Editor } from '@tiptap/vue-3';
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
/** 编辑器内容 (HTML 格式) */
|
||||
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: Editor): 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 = {
|
||||
link: true,
|
||||
image: true,
|
||||
table: true,
|
||||
attachment: false, // 附件默认关闭
|
||||
video: false, // 视频默认关闭
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const RoleSelector = defineAsyncComponent(() =>
|
||||
import('./role-selector.vue').then((module) => module.default),
|
||||
);
|
||||
@@ -0,0 +1,790 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RoleSelectorEmits, RoleSelectorProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { Key, Search, X } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElScrollbar,
|
||||
ElSelect,
|
||||
ElSkeleton,
|
||||
ElSkeletonItem,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getRoleListApi, getRolesByIds } from '#/api/core/role';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
defineOptions({
|
||||
name: 'RoleSelector',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
placeholder: () => $t('ui.placeholder.select') || 'Please select',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<RoleSelectorEmits>();
|
||||
|
||||
interface Props extends RoleSelectorProps {}
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const roles = ref<any[]>([]);
|
||||
const selectedRoles = ref<Set<string>>(
|
||||
new Set(
|
||||
Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: props.modelValue
|
||||
? [props.modelValue]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
// 临时选择(用于 modal 中的选择,未确认前)
|
||||
const tempSelectedRoles = ref<Set<string>>(new Set());
|
||||
const roleLoading = ref(false);
|
||||
const searchText = ref('');
|
||||
// 分页相关
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalRoles = ref(0);
|
||||
const isLoadingMore = ref(false);
|
||||
// 标记是否已经尝试过加载更多(用于显示"没有更多数据"提示)
|
||||
const hasTriedLoadMore = ref(false);
|
||||
// 搜索相关
|
||||
const searchResults = ref<any[]>([]);
|
||||
// 标记是否已加载过角色数据
|
||||
const hasLoadedRoles = ref(false);
|
||||
// 标记是否已加载过完整列表(用于弹窗)
|
||||
const hasLoadedFullList = ref(false);
|
||||
|
||||
// 计算显示值(只显示已确认的值)
|
||||
const displayValue = computed({
|
||||
get() {
|
||||
if (selectedRoles.value.size === 0) return undefined;
|
||||
if (props.multiple) {
|
||||
return [...selectedRoles.value];
|
||||
}
|
||||
return [...selectedRoles.value][0];
|
||||
},
|
||||
set(_value) {
|
||||
// ElSelect 会改变这个值,但我们不需要处理
|
||||
},
|
||||
});
|
||||
|
||||
// 获取已选角色的信息
|
||||
const selectedRolesWithInfo = computed(() => {
|
||||
const result = [];
|
||||
const seenIds = new Set<string>(); // 用于去重
|
||||
|
||||
for (const roleId of selectedRoles.value) {
|
||||
// 避免重复添加
|
||||
if (seenIds.has(roleId)) continue;
|
||||
seenIds.add(roleId);
|
||||
|
||||
const role =
|
||||
roles.value.find((r) => r.id === roleId) ||
|
||||
searchResults.value.find((r) => r.id === roleId);
|
||||
if (role) {
|
||||
result.push({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
});
|
||||
} else {
|
||||
// 找不到角色信息时显示"正在加载中"
|
||||
result.push({
|
||||
id: roleId,
|
||||
name: $t('common.loading') || 'Loading...',
|
||||
code: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// 获取临时选择角色的信息
|
||||
const tempSelectedRolesWithInfo = computed(() => {
|
||||
const result = [];
|
||||
const seenIds = new Set<string>(); // 用于去重
|
||||
|
||||
for (const roleId of tempSelectedRoles.value) {
|
||||
// 避免重复添加
|
||||
if (seenIds.has(roleId)) continue;
|
||||
|
||||
const role =
|
||||
roles.value.find((r) => r.id === roleId) ||
|
||||
searchResults.value.find((r) => r.id === roleId);
|
||||
if (role) {
|
||||
seenIds.add(roleId);
|
||||
result.push({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// 加载角色数据(分页)
|
||||
const loadRoles = async (page: number = 1, append: boolean = false) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
roleLoading.value = true;
|
||||
} else {
|
||||
isLoadingMore.value = true;
|
||||
}
|
||||
|
||||
const result = await getRoleListApi({
|
||||
page,
|
||||
pageSize: pageSize.value,
|
||||
name: searchText.value || undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// 无论是追加还是重新加载,都需要去重
|
||||
const existingIds = new Set(roles.value.map((r) => r.id));
|
||||
const newItems = (result.items || []).filter(
|
||||
(item: any) => !existingIds.has(item.id),
|
||||
);
|
||||
|
||||
if (append) {
|
||||
// 追加数据(触底加载)
|
||||
roles.value = [...roles.value, ...newItems];
|
||||
} else {
|
||||
// 重新加载(首次加载或搜索)
|
||||
// 合并已有数据(已选项)和新加载的数据
|
||||
roles.value = [...roles.value, ...newItems];
|
||||
}
|
||||
|
||||
totalRoles.value = result.total || 0;
|
||||
currentPage.value = page;
|
||||
hasLoadedRoles.value = true;
|
||||
// 标记已加载完整列表
|
||||
hasLoadedFullList.value = true;
|
||||
}
|
||||
|
||||
roleLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load roles:', error);
|
||||
roleLoading.value = false;
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 根据ID加载特定角色信息(用于编辑时显示已选角色的名称)
|
||||
const loadRolesByIds = async (ids: string[]) => {
|
||||
if (!ids || ids.length === 0) return;
|
||||
|
||||
try {
|
||||
roleLoading.value = true;
|
||||
|
||||
// 调用后端API按ID查询角色信息
|
||||
const result = await getRolesByIds(ids);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
// 合并数据,去重
|
||||
const existingIds = new Set(roles.value.map((r) => r.id));
|
||||
const newRoles = result.filter((r: any) => !existingIds.has(r.id));
|
||||
roles.value = [...roles.value, ...newRoles];
|
||||
hasLoadedRoles.value = true;
|
||||
}
|
||||
|
||||
roleLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load roles by ids:', error);
|
||||
roleLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 角色列表直接使用 roles
|
||||
const filteredRoles = computed(() => {
|
||||
return roles.value;
|
||||
});
|
||||
|
||||
// 判断是否还有更多数据
|
||||
const hasMoreData = computed(() => {
|
||||
return roles.value.length < totalRoles.value;
|
||||
});
|
||||
|
||||
// 防抖搜索定时器
|
||||
let searchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
// 监听搜索文本变化,执行服务端搜索
|
||||
watch(searchText, () => {
|
||||
// 清除之前的定时器
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器
|
||||
searchTimer = setTimeout(() => {
|
||||
// 重置分页并重新加载
|
||||
currentPage.value = 1;
|
||||
loadRoles(1, false);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// 处理角色选择
|
||||
const handleRoleSelect = (roleId: string) => {
|
||||
if (props.multiple) {
|
||||
if (tempSelectedRoles.value.has(roleId)) {
|
||||
tempSelectedRoles.value.delete(roleId);
|
||||
} else {
|
||||
tempSelectedRoles.value.add(roleId);
|
||||
}
|
||||
} else {
|
||||
// 单选模式
|
||||
tempSelectedRoles.value.clear();
|
||||
tempSelectedRoles.value.add(roleId);
|
||||
// 单选时直接确认并关闭
|
||||
handleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
// 打开modal
|
||||
const openModal = async () => {
|
||||
if (props.disabled) return;
|
||||
modalVisible.value = true;
|
||||
};
|
||||
|
||||
// 打开modal后加载数据
|
||||
const handleModalOpened = async () => {
|
||||
// 初始化临时选择为已选择的值
|
||||
tempSelectedRoles.value = new Set(selectedRoles.value);
|
||||
|
||||
// 只有在未加载过完整列表时才加载第一页数据
|
||||
if (!hasLoadedFullList.value) {
|
||||
await loadRoles(1, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 触底加载更多
|
||||
const handleScroll = ({
|
||||
scrollTop,
|
||||
}: {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
}) => {
|
||||
const scrollbarRef = document.querySelector(
|
||||
'.role-selector-left .el-scrollbar__wrap',
|
||||
);
|
||||
if (!scrollbarRef) return;
|
||||
|
||||
const scrollHeight = scrollbarRef.scrollHeight;
|
||||
const clientHeight = scrollbarRef.clientHeight;
|
||||
|
||||
// 当滚动到底部附近 50px 时触发加载
|
||||
if (
|
||||
scrollTop + clientHeight >= scrollHeight - 50 &&
|
||||
hasMoreData.value &&
|
||||
!isLoadingMore.value &&
|
||||
!roleLoading.value
|
||||
) {
|
||||
hasTriedLoadMore.value = true;
|
||||
loadRoles(currentPage.value + 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
// 将临时选择的值保存到 selectedRoles(已确认)
|
||||
selectedRoles.value = new Set(tempSelectedRoles.value);
|
||||
|
||||
const value = props.multiple
|
||||
? [...selectedRoles.value]
|
||||
: selectedRoles.value.size > 0
|
||||
? [...selectedRoles.value][0]
|
||||
: '';
|
||||
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
modalVisible.value = false;
|
||||
};
|
||||
|
||||
// 清除选择
|
||||
const handleClear = (e?: MouseEvent) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
tempSelectedRoles.value.clear();
|
||||
selectedRoles.value.clear();
|
||||
const emptyValue = props.multiple ? [] : '';
|
||||
emit('update:modelValue', emptyValue);
|
||||
emit('change', emptyValue);
|
||||
};
|
||||
|
||||
// 删除单个选中项(多选模式下点击标签删除按钮)
|
||||
const handleRemoveTag = (roleId: string) => {
|
||||
selectedRoles.value.delete(roleId);
|
||||
const value = props.multiple ? [...selectedRoles.value] : '';
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
// 监听外部 modelValue 变化
|
||||
const updateInternalValue = () => {
|
||||
selectedRoles.value.clear();
|
||||
tempSelectedRoles.value.clear();
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
props.modelValue.forEach((v) => selectedRoles.value.add(v));
|
||||
} else if (props.modelValue) {
|
||||
selectedRoles.value.add(props.modelValue);
|
||||
}
|
||||
// 打开 modal 时初始化临时选择
|
||||
if (modalVisible.value) {
|
||||
tempSelectedRoles.value = new Set(selectedRoles.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化,如果有值且角色数据未加载,则加载
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue) => {
|
||||
updateInternalValue();
|
||||
|
||||
// 如果有选中值且角色数据未加载,则加载角色数据
|
||||
if (
|
||||
((Array.isArray(newValue) && newValue.length > 0) ||
|
||||
(typeof newValue === 'string' && newValue)) &&
|
||||
!hasLoadedRoles.value
|
||||
) {
|
||||
const ids = Array.isArray(newValue) ? newValue : [newValue];
|
||||
await loadRolesByIds(ids);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 组件挂载时,如果有初始值,则加载角色数据
|
||||
onMounted(async () => {
|
||||
if (
|
||||
(Array.isArray(props.modelValue) && props.modelValue.length > 0) ||
|
||||
(typeof props.modelValue === 'string' && props.modelValue)
|
||||
) {
|
||||
const ids = Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: [props.modelValue];
|
||||
await loadRolesByIds(ids);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="role-selector">
|
||||
<!-- 选择框 -->
|
||||
<div class="role-selector-input" :class="{ disabled }">
|
||||
<ElSelect
|
||||
v-bind="attrs"
|
||||
v-model="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable && selectedRoles.size > 0"
|
||||
:multiple="multiple"
|
||||
:suffix-icon="Key"
|
||||
readonly
|
||||
@click="openModal"
|
||||
@clear="() => handleClear()"
|
||||
@remove-tag="handleRemoveTag"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in selectedRolesWithInfo"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<ZqDialog
|
||||
v-model="modalVisible"
|
||||
:title="$t('system.user.selectRole') || 'Select Roles'"
|
||||
width="45%"
|
||||
:show-fullscreen-button="false"
|
||||
@opened="handleModalOpened"
|
||||
>
|
||||
<div class="role-selector-content">
|
||||
<!-- 左侧:搜索 + 角色列表 -->
|
||||
<div class="role-selector-left">
|
||||
<div v-if="filterable" class="list-search">
|
||||
<ElInput
|
||||
v-model="searchText"
|
||||
:placeholder="$t('common.search') || 'Search'"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
<ElScrollbar class="list-scroll" @scroll="handleScroll">
|
||||
<ElSkeleton :loading="roleLoading" animated :count="8">
|
||||
<template #template>
|
||||
<div class="list-skeleton">
|
||||
<div v-for="i in 8" :key="i" class="role-skeleton-item">
|
||||
<ElSkeletonItem
|
||||
variant="text"
|
||||
style="width: 100%; height: 36px; margin: 4px 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="list-body">
|
||||
<ElEmpty
|
||||
v-if="filteredRoles.length === 0 && !roleLoading"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
<div v-else class="role-list">
|
||||
<div
|
||||
v-for="role in filteredRoles"
|
||||
:key="role.id"
|
||||
class="role-item"
|
||||
:class="{
|
||||
'role-item--selected': tempSelectedRoles.has(role.id),
|
||||
}"
|
||||
@click="handleRoleSelect(role.id)"
|
||||
>
|
||||
<div class="role-name">{{ role.name }}</div>
|
||||
<div v-if="role.code" class="role-code">
|
||||
{{ role.code }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<ElSkeletonItem
|
||||
variant="text"
|
||||
style="width: 100%; height: 36px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多数据提示 -->
|
||||
<div
|
||||
v-if="
|
||||
!hasMoreData &&
|
||||
filteredRoles.length > 0 &&
|
||||
hasTriedLoadMore
|
||||
"
|
||||
class="no-more-data"
|
||||
>
|
||||
{{ $t('common.noMoreData') || 'No more data' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElSkeleton>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:已选值 -->
|
||||
<div class="role-selector-right">
|
||||
<div class="right-header">
|
||||
<span class="right-title">
|
||||
{{ $t('common.selected') || 'Selected' }}
|
||||
<span v-if="tempSelectedRoles.size > 0" class="right-count">
|
||||
({{ tempSelectedRoles.size }})
|
||||
</span>
|
||||
</span>
|
||||
<ElButton
|
||||
v-if="tempSelectedRoles.size > 0"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="
|
||||
() => {
|
||||
tempSelectedRoles.clear();
|
||||
tempSelectedRoles = new Set();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('common.clear') || 'Clear' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElScrollbar class="right-scroll">
|
||||
<div
|
||||
v-if="tempSelectedRolesWithInfo.length === 0"
|
||||
class="right-empty"
|
||||
>
|
||||
<ElEmpty
|
||||
:image-size="64"
|
||||
:description="$t('common.noData') || 'No Data'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="right-list">
|
||||
<div
|
||||
v-for="item in tempSelectedRolesWithInfo"
|
||||
:key="item.id"
|
||||
class="right-item"
|
||||
>
|
||||
<span class="right-item-name" :title="item.name">
|
||||
{{ item.name }}
|
||||
<span v-if="item.code" class="right-item-code"
|
||||
>({{ item.code }})</span
|
||||
>
|
||||
</span>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
class="right-item-remove"
|
||||
@click="
|
||||
() => {
|
||||
tempSelectedRoles.delete(item.id);
|
||||
tempSelectedRoles = new Set(tempSelectedRoles);
|
||||
}
|
||||
"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<ElButton @click="modalVisible = false">
|
||||
{{ $t('common.cancel') || 'Cancel' }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">
|
||||
{{ $t('common.confirm') || 'Confirm' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ZqDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-selector {
|
||||
width: 100%;
|
||||
|
||||
&-input {
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
&.is-disabled {
|
||||
background-color: var(--background-deep, #f5f7fa);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
box-shadow: 0 1px 3px hsl(var(--border) / 12%);
|
||||
}
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
|
||||
.list-search {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-skeleton,
|
||||
.list-body {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
|
||||
.role-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.role-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.role-code {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
background: hsl(var(--background-deep) / 50%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-more-data {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
margin-left: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
|
||||
.right-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 8px;
|
||||
|
||||
.right-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
.right-count {
|
||||
font-weight: 400;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.right-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.right-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.right-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-code {
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
&-remove {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.role-skeleton-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface RoleSelectorRole {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface RoleSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(e: 'change', value: string | string[] | undefined): void;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScanMode } from '../shared/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { QrcodeCapture, QrcodeStream } from 'vue-qrcode-reader';
|
||||
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean;
|
||||
scanMode?: ScanMode;
|
||||
continuousScan?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
scanMode: 'all',
|
||||
continuousScan: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'scan', value: string): void;
|
||||
}>();
|
||||
|
||||
const lastScanAt = ref(0);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const scanFormats = computed(() => {
|
||||
switch (props.scanMode) {
|
||||
case 'barcode': {
|
||||
return [
|
||||
'code_128',
|
||||
'code_39',
|
||||
'ean_13',
|
||||
'ean_8',
|
||||
'upc_a',
|
||||
'upc_e',
|
||||
] as const;
|
||||
}
|
||||
case 'qr': {
|
||||
return ['qr_code'] as const;
|
||||
}
|
||||
default: {
|
||||
return [
|
||||
'qr_code',
|
||||
'code_128',
|
||||
'code_39',
|
||||
'ean_13',
|
||||
'ean_8',
|
||||
'upc_a',
|
||||
'upc_e',
|
||||
] as const;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function handleDetect(detectedCodes: Array<{ rawValue: string }>) {
|
||||
const code = detectedCodes[0]?.rawValue;
|
||||
if (!code) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastScanAt.value < 800) return;
|
||||
lastScanAt.value = now;
|
||||
|
||||
emit('scan', code);
|
||||
|
||||
if (!props.continuousScan) {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error: Error) {
|
||||
errorMessage.value = error.message || $t('form-design.scanInput.scanFailed');
|
||||
}
|
||||
|
||||
function handleCapture(detectedCodes: Array<{ rawValue: string }>) {
|
||||
if (detectedCodes[0]?.rawValue) {
|
||||
emit('scan', detectedCodes[0].rawValue);
|
||||
if (!props.continuousScan) {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
:model-value="modelValue"
|
||||
:title="$t('form-design.scanInput.scanDialogTitle')"
|
||||
width="480px"
|
||||
:show-footer="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="scan-dialog">
|
||||
<QrcodeStream
|
||||
v-if="modelValue"
|
||||
class="scan-dialog__stream"
|
||||
:formats="[...scanFormats]"
|
||||
@detect="handleDetect"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div v-if="errorMessage" class="scan-dialog__error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div class="scan-dialog__fallback">
|
||||
<span class="scan-dialog__fallback-label">{{
|
||||
$t('form-design.scanInput.uploadImage')
|
||||
}}</span>
|
||||
<QrcodeCapture @detect="handleCapture" />
|
||||
</div>
|
||||
</div>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scan-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scan-dialog__stream {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.scan-dialog__error {
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.scan-dialog__fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.scan-dialog__fallback-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -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,375 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Check, Clock, RefreshCw, Smartphone } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElIcon, ElMessage, ElResult } from 'element-plus';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
import { checkSignatureStatus, createSignatureToken } from '#/api/core/file';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
source: 'form',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'complete', fileId: string): void;
|
||||
}>();
|
||||
|
||||
// 状态
|
||||
const qrcodeUrl = ref('');
|
||||
const callbackKey = ref('');
|
||||
const expiredAt = ref('');
|
||||
const isLoading = ref(false);
|
||||
const isWaiting = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
const completedFileId = ref('');
|
||||
let pollInterval: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
// 计算属性
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value),
|
||||
});
|
||||
|
||||
// 过期时间显示
|
||||
const expiredTimeText = computed(() => {
|
||||
if (!expiredAt.value) return '';
|
||||
const date = new Date(expiredAt.value);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
});
|
||||
|
||||
// 生成签名令牌和二维码
|
||||
async function generateQRCode() {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// 调用后端API创建签名令牌
|
||||
const result = await createSignatureToken(props.source, 30);
|
||||
|
||||
callbackKey.value = result.callback_key;
|
||||
expiredAt.value = result.expired_at;
|
||||
|
||||
// 构建手机签名页面URL
|
||||
const baseUrl = window.location.origin;
|
||||
const signatureUrl = `${baseUrl}/mobile-signature/${result.token}`;
|
||||
|
||||
// 生成二维码
|
||||
qrcodeUrl.value = await QRCode.toDataURL(signatureUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
// 开始轮询检查签名状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
console.error('Generate QRCode failed:', error);
|
||||
ElMessage.error($t('form-design.signaturePad.qrcodeError'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询检查签名状态
|
||||
function startPolling() {
|
||||
isWaiting.value = true;
|
||||
|
||||
// 调用后端API检查签名状态
|
||||
pollInterval = setInterval(async () => {
|
||||
if (!callbackKey.value) return;
|
||||
|
||||
try {
|
||||
const result = await checkSignatureStatus(callbackKey.value);
|
||||
|
||||
if (result.status === 'completed' && result.file_id) {
|
||||
completedFileId.value = result.file_id;
|
||||
isCompleted.value = true;
|
||||
isWaiting.value = false;
|
||||
stopPolling();
|
||||
} else if (result.status === 'expired') {
|
||||
isWaiting.value = false;
|
||||
stopPolling();
|
||||
ElMessage.warning($t('form-design.signaturePad.qrcodeExpired'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check signature status failed:', error);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确认使用签名
|
||||
function confirmSignature() {
|
||||
if (completedFileId.value) {
|
||||
emit('complete', completedFileId.value);
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
function handleCancel() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
// 重新生成二维码
|
||||
function regenerateQRCode() {
|
||||
stopPolling();
|
||||
isCompleted.value = false;
|
||||
completedFileId.value = '';
|
||||
qrcodeUrl.value = '';
|
||||
callbackKey.value = '';
|
||||
expiredAt.value = '';
|
||||
generateQRCode();
|
||||
}
|
||||
|
||||
// 弹窗打开时生成二维码
|
||||
function handleOpened() {
|
||||
generateQRCode();
|
||||
}
|
||||
|
||||
// 弹窗关闭时清理
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (!visible) {
|
||||
stopPolling();
|
||||
qrcodeUrl.value = '';
|
||||
callbackKey.value = '';
|
||||
expiredAt.value = '';
|
||||
isWaiting.value = false;
|
||||
isCompleted.value = false;
|
||||
completedFileId.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="dialogVisible"
|
||||
:title="$t('form-design.signaturePad.mobileSign')"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<div class="mobile-signature-dialog">
|
||||
<!-- 签名完成状态 -->
|
||||
<template v-if="isCompleted">
|
||||
<ElResult
|
||||
icon="success"
|
||||
:title="$t('form-design.signaturePad.signCompleted')"
|
||||
:sub-title="$t('form-design.signaturePad.signCompletedTip')"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="success-icon">
|
||||
<Check class="success-icon__check" />
|
||||
</div>
|
||||
</template>
|
||||
</ElResult>
|
||||
</template>
|
||||
|
||||
<!-- 等待签名状态 -->
|
||||
<template v-else>
|
||||
<div v-loading="isLoading" class="mobile-signature-dialog__content">
|
||||
<div class="mobile-signature-dialog__icon">
|
||||
<Smartphone class="mobile-signature-dialog__smartphone" />
|
||||
</div>
|
||||
|
||||
<div class="mobile-signature-dialog__tip">
|
||||
{{ $t('form-design.signaturePad.scanQrcodeTip') }}
|
||||
</div>
|
||||
|
||||
<div class="mobile-signature-dialog__qrcode">
|
||||
<img v-if="qrcodeUrl" :src="qrcodeUrl" alt="QRCode" />
|
||||
<div v-else class="mobile-signature-dialog__loading">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isWaiting" class="mobile-signature-dialog__waiting">
|
||||
<span class="mobile-signature-dialog__dot"></span>
|
||||
{{ $t('form-design.signaturePad.waitingForSign') }}
|
||||
</div>
|
||||
|
||||
<div v-if="expiredAt" class="mobile-signature-dialog__expired">
|
||||
<ElIcon><Clock /></ElIcon>
|
||||
<span>{{
|
||||
$t('form-design.signaturePad.qrcodeExpiredAt', {
|
||||
time: expiredTimeText,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="isCompleted">
|
||||
<ElButton @click="regenerateQRCode">
|
||||
{{ $t('form-design.signaturePad.resignMobile') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="confirmSignature">
|
||||
{{ $t('form-design.signaturePad.useThisSignature') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mobile-signature-dialog__footer">
|
||||
<ElButton
|
||||
:icon="RefreshCw"
|
||||
:loading="isLoading"
|
||||
@click="regenerateQRCode"
|
||||
>
|
||||
{{ $t('form-design.signaturePad.refreshQrcode') }}
|
||||
</ElButton>
|
||||
<ElButton @click="handleCancel">
|
||||
{{ $t('common.cancel') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-signature-dialog {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__smartphone {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__tip {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__qrcode {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__qrcode img {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__loading {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--el-color-success-light-9);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.success-icon__check {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__expired {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.mobile-signature-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const TableSelector = defineAsyncComponent(() =>
|
||||
import('./table-selector.vue').then((module) => module.default),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'TableSelector' });
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
modelValue?: unknown;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown];
|
||||
}>();
|
||||
|
||||
function clearValue() {
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElButton :disabled="disabled" plain @click="clearValue">
|
||||
{{ placeholder || 'Table selector unavailable' }}
|
||||
</ElButton>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const UserSelector = defineAsyncComponent(() =>
|
||||
import('./user-selector.vue').then((module) => module.default),
|
||||
);
|
||||
|
||||
export * from './types';
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface UserSelectorUser {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface UserSelectorDept {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: UserSelectorDept[];
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface UserSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
displayMode?: 'button' | 'select'; // 显示方式:select 或 button
|
||||
onConfirm?: (userIds: string | string[]) => Promise<void> | void; // 确认回调(button 模式下使用)
|
||||
autoCurrentUser?: boolean; // 自动获取当前用户作为默认值
|
||||
readonly?: boolean; // 只读模式(显示值但不可修改)
|
||||
}
|
||||
|
||||
export interface UserSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | undefined): void;
|
||||
(e: 'change', value: string | string[] | undefined): void;
|
||||
(e: 'select-item', item: Record<string, any> | Record<string, any>[] | undefined): void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const ZqApiSelect = defineAsyncComponent(() =>
|
||||
import('./zq-api-select.vue').then((module) => module.default),
|
||||
);
|
||||
|
||||
export * from './types';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user