Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,807 @@
<script setup lang="ts">
import type { AiOcrConfig } from '#/components/form-design/store/formDesignStore';
import { computed, ref, watch } from 'vue';
import {
DeleteOutlined,
EyeOutlined,
FileImageOutlined,
FileOutlined,
Sparkles,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElImageViewer,
ElMessage,
ElProgress,
ElTag,
} from 'element-plus';
import { uploadFile as uploadFileApi } from '#/api/core/file';
import { requestClient } from '#/api/request';
import { getFileUrl } from '#/composables/useFileUrl';
defineOptions({
name: 'AiImageOcr',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: () => $t('form-design.aiImageOcr.uploadPlaceholder'),
disabled: false,
clearable: true,
maxSize: 10,
});
const emit = defineEmits<Emits>();
interface Props {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
height?: number;
aiOcrConfig?: AiOcrConfig;
formData?: Record<string, any>;
}
interface Emits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
// 状态
const uploadInputRef = ref<HTMLInputElement>();
const isUploading = ref(false);
const isRecognizing = ref(false);
const uploadProgress = ref(0);
const currentImageId = ref<string>('');
const currentImageUrl = ref<string>('');
const currentFileMimeType = ref<string>('');
const previewVisible = ref(false);
const ocrResult = ref<null | {
extractedData: null | Record<string, any>;
rawText: string;
}>(null);
// 文件类型到accept属性的映射
const FILE_TYPE_ACCEPT_MAP: Record<string, string[]> = {
image: ['image/*'],
text: [
'.txt',
'.md',
'.log',
'.py',
'.js',
'.ts',
'.jsx',
'.tsx',
'.vue',
'.html',
'.css',
'.json',
'.yaml',
'.yml',
'.xml',
'.csv',
],
pdf: ['.pdf', 'application/pdf'],
word: [
'.doc',
'.docx',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
excel: [
'.xls',
'.xlsx',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
};
// 计算属性
const hasFile = computed(() => !!currentImageId.value);
// 计算接受的文件类型
const acceptString = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return '*/*';
}
const accepts: string[] = [];
types.forEach((type) => {
if (FILE_TYPE_ACCEPT_MAP[type]) {
accepts.push(...FILE_TYPE_ACCEPT_MAP[type]);
}
});
return accepts.join(',') || 'image/*';
});
// 判断是否为图片文件
const isImageFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType.startsWith('image/') ||
url.includes('image/') ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
);
});
// 判断是否为PDF文件
const isPdfFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType === 'application/pdf' ||
url.includes('application/pdf') ||
url.endsWith('.pdf')
);
});
const templateTypeLabel = computed(() => {
const labels: Record<string, string> = {
custom: $t('form-design.aiImageOcr.customTemplate'),
id_card: $t('form-design.aiImageOcr.idCard'),
business_license: $t('form-design.aiImageOcr.businessLicense'),
invoice: $t('form-design.aiImageOcr.invoice'),
receipt: $t('form-design.aiImageOcr.receipt'),
contract: $t('form-design.aiImageOcr.contract'),
};
return labels[props.aiOcrConfig?.templateType || 'custom'] || labels.custom;
});
// 获取支持的文件类型标签
const acceptedFileTypesLabel = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return $t('form-design.aiImageOcr.allFiles');
}
const labels: Record<string, string> = {
image: $t('form-design.aiImageOcr.imageFile'),
text: $t('form-design.aiImageOcr.textFile'),
pdf: 'PDF',
word: 'Word',
excel: 'Excel',
};
return types.map((t) => labels[t] || t).join(', ');
});
// 监听 modelValue 变化
watch(
() => props.modelValue,
async (newValue) => {
if (!newValue) {
currentImageId.value = '';
currentImageUrl.value = '';
currentFileMimeType.value = '';
return;
}
const id = Array.isArray(newValue) ? newValue[0] : newValue;
if (id && id !== currentImageId.value) {
currentImageId.value = id;
currentImageUrl.value = await getFileUrl(id);
// 从后端获取文件信息
try {
const fileInfo = await requestClient.get<{ mime_type?: string }>(
`/api/core/file_manager/${id}`,
);
currentFileMimeType.value = fileInfo.mime_type || '';
} catch (error) {
console.warn('Failed to fetch file info:', error);
}
}
},
{ immediate: true },
);
// 打开文件选择器
function openFileSelector() {
if (props.disabled) return;
uploadInputRef.value?.click();
}
// 处理文件选择
async function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
target.value = '';
// 验证文件类型
const acceptedTypes = props.aiOcrConfig?.acceptFileTypes || ['image'];
const isValidType = validateFileType(file, acceptedTypes);
if (!isValidType) {
ElMessage.error($t('form-design.aiImageOcr.invalidFileType'));
return;
}
// 验证文件大小
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
ElMessage.error(
$t('form-design.aiImageOcr.fileTooLarge', { size: props.maxSize }),
);
return;
}
await uploadAndRecognize(file);
}
// 验证文件类型
function validateFileType(file: File, acceptedTypes: string[]): boolean {
if (acceptedTypes.includes('all')) return true;
const fileName = file.name.toLowerCase();
const mimeType = file.type.toLowerCase();
for (const type of acceptedTypes) {
const accepts = FILE_TYPE_ACCEPT_MAP[type] || [];
for (const accept of accepts) {
if (accept.startsWith('.')) {
// 扩展名匹配
if (fileName.endsWith(accept)) return true;
} else if (accept.endsWith('/*')) {
// MIME 类型通配符匹配
const prefix = accept.replace('/*', '');
if (mimeType.startsWith(prefix)) return true;
} else {
// 完整 MIME 类型匹配
if (mimeType === accept) return true;
}
}
}
return false;
}
// 上传并识别
async function uploadAndRecognize(file: File) {
try {
isUploading.value = true;
uploadProgress.value = 0;
// 上传文件
const response = await uploadFileApi(file, {
source: 'form',
onProgress: (progressEvent) => {
uploadProgress.value = progressEvent.percentage;
},
});
if (!response?.id) {
throw new Error($t('form-design.aiImageOcr.uploadFailed'));
}
currentImageId.value = response.id;
currentImageUrl.value = await getFileUrl(response.id);
currentFileMimeType.value = file.type;
isUploading.value = false;
// 更新 modelValue
emit('update:modelValue', response.id);
emit('change', response.id);
// 如果启用了 AI 识别,开始识别
if (props.aiOcrConfig?.enabled) {
await recognizeImage(response.id);
}
} catch (error: any) {
console.error('Upload failed:', error);
ElMessage.error(error.message || $t('form-design.aiImageOcr.uploadFailed'));
isUploading.value = false;
}
}
// AI 识别文件
async function recognizeImage(fileId: string) {
if (!props.aiOcrConfig?.enabled) return;
try {
isRecognizing.value = true;
// 构建请求参数
const requestData: any = {
fileId,
};
// 如果有结构化输出配置
if (
props.aiOcrConfig.outputSchema &&
props.aiOcrConfig.outputSchema.length > 0
) {
requestData.outputSchema = props.aiOcrConfig.outputSchema;
}
// 如果有自定义提示词
if (props.aiOcrConfig.customPrompt) {
requestData.prompt = props.aiOcrConfig.customPrompt;
}
// 调用 OCR API
const result = await requestClient.post<{
error: null | string;
extractedData: null | Record<string, any>;
rawText: null | string;
success: boolean;
}>('/api/core/file_manager/ocr/recognize', requestData);
if (!result.success) {
throw new Error(
result.error || $t('form-design.aiImageOcr.recognizeFailed'),
);
}
ocrResult.value = {
rawText: result.rawText || '',
extractedData: result.extractedData,
};
emit('ocr-success', ocrResult.value);
if (result.extractedData) {
applyFieldMapping(result.extractedData);
} else {
ElMessage.success($t('form-design.aiImageOcr.recognizeSuccess'));
}
} catch (error: any) {
console.error('OCR failed:', error);
ElMessage.error(
error.message || $t('form-design.aiImageOcr.recognizeFailed'),
);
} finally {
isRecognizing.value = false;
}
}
// 应用字段映射
function applyFieldMapping(extractedData: Record<string, any>) {
if (!props.aiOcrConfig?.fieldMapping?.length) {
// 没有配置映射,直接发送原始数据
emit('fill-fields', extractedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
return;
}
const mappedData: Record<string, any> = {};
for (const mapping of props.aiOcrConfig.fieldMapping) {
const value = extractedData[mapping.source];
if (value !== undefined) {
mappedData[mapping.target] = value;
}
}
emit('fill-fields', mappedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
}
// 预览图片
function handlePreview() {
if (currentImageUrl.value) {
previewVisible.value = true;
}
}
// 删除图片
function handleDelete() {
currentImageId.value = '';
currentImageUrl.value = '';
ocrResult.value = null;
emit('update:modelValue', undefined);
emit('change', undefined);
}
// 重新识别
async function handleReRecognize() {
if (currentImageId.value) {
await recognizeImage(currentImageId.value);
}
}
</script>
<template>
<div class="ai-image-ocr">
<!-- 上传区域 -->
<div
class="upload-area"
:class="{
'has-file': hasFile,
'is-disabled': disabled,
'is-uploading': isUploading,
'is-recognizing': isRecognizing,
}"
:style="{ height: height ? `${height}px` : undefined }"
@click="!hasFile && openFileSelector()"
>
<!-- 隐藏的文件输入 -->
<input
ref="uploadInputRef"
type="file"
:accept="acceptString"
style="display: none"
@change="handleFileChange"
/>
<!-- 空状态 -->
<template v-if="!hasFile && !isUploading">
<div class="upload-placeholder">
<div class="upload-icon">
<Sparkles class="ai-icon" />
<FileImageOutlined class="image-icon" />
</div>
<div class="upload-text">{{ placeholder }}</div>
<div class="upload-hint">
<ElTag size="small" type="primary">
{{ templateTypeLabel }}
</ElTag>
<ElTag size="small" type="info" class="ml-1">
{{ acceptedFileTypesLabel }}
</ElTag>
</div>
</div>
</template>
<!-- 上传中 -->
<template v-else-if="isUploading">
<div class="upload-progress">
<ElProgress
type="circle"
:percentage="uploadProgress"
:width="80"
:stroke-width="6"
/>
<div class="progress-text">
{{ $t('form-design.aiImageOcr.uploading') }}
</div>
</div>
</template>
<!-- 已上传文件 -->
<template v-else>
<div class="file-preview">
<!-- 图片预览 -->
<img
v-if="isImageFile"
:src="currentImageUrl"
alt="uploaded"
class="preview-img"
/>
<!-- PDF预览 -->
<iframe
v-else-if="isPdfFile"
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
class="pdf-preview"
frameborder="0"
></iframe>
<!-- 其他文件显示图标 -->
<div v-else class="file-icon-wrapper">
<FileOutlined class="file-icon" />
<div class="file-name">
{{ $t('form-design.aiImageOcr.fileUploaded') }}
</div>
</div>
<!-- 识别中遮罩 -->
<div v-if="isRecognizing" class="recognizing-mask">
<div class="recognizing-content">
<Sparkles class="recognizing-icon" />
<div class="recognizing-text">
{{ $t('form-design.aiImageOcr.recognizing') }}
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!isRecognizing" class="file-actions">
<ElButton
v-if="isImageFile || isPdfFile"
circle
size="small"
@click.stop="handlePreview"
>
<EyeOutlined />
</ElButton>
<ElButton
v-if="aiOcrConfig?.enabled"
circle
size="small"
type="primary"
@click.stop="handleReRecognize"
>
<Sparkles />
</ElButton>
<ElButton
v-if="clearable"
circle
size="small"
type="danger"
@click.stop="handleDelete"
>
<DeleteOutlined />
</ElButton>
</div>
<!-- AI 标识 -->
<div v-if="aiOcrConfig?.enabled" class="ai-badge">
<Sparkles class="badge-icon" />
AI
</div>
</div>
</template>
</div>
<!-- 图片预览 -->
<ElImageViewer
v-if="previewVisible && isImageFile"
:url-list="[currentImageUrl]"
:z-index="3000"
@close="previewVisible = false"
/>
<!-- PDF预览对话框 -->
<ElDialog
v-model="previewVisible"
v-if="isPdfFile"
:title="$t('form-design.aiImageOcr.preview')"
width="80%"
top="5vh"
>
<iframe
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
style="width: 100%; height: 70vh; border: none"
></iframe>
</ElDialog>
</div>
</template>
<style lang="scss" scoped>
.ai-image-ocr {
width: 100%;
}
.upload-area {
position: relative;
width: 100%;
min-height: 120px;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: var(--el-fill-color-lighter);
&:hover:not(.is-disabled):not(.has-file) {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.has-file {
cursor: default;
border-style: solid;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 8px;
.upload-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
.image-icon {
font-size: 40px;
color: var(--el-color-primary);
}
.ai-icon {
position: absolute;
top: -8px;
right: -12px;
font-size: 20px;
color: var(--el-color-warning);
animation: sparkle 1.5s ease-in-out infinite;
}
}
.upload-text {
font-size: 14px;
color: var(--el-text-color-regular);
}
.upload-hint {
margin-top: 4px;
}
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
.progress-text {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.file-preview {
position: relative;
width: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
.preview-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 4px;
}
.pdf-preview {
width: 100%;
height: 100%;
min-height: 400px;
border: none;
border-radius: 4px;
}
.file-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
.file-icon {
font-size: 48px;
color: var(--el-color-primary);
}
.file-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.file-actions {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
&:hover .file-actions {
opacity: 1;
}
.ai-badge {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(
135deg,
var(--el-color-primary),
var(--el-color-primary-light-3)
);
color: white;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
.badge-icon {
font-size: 14px;
}
}
}
.recognizing-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
.recognizing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: white;
.recognizing-icon {
font-size: 32px;
animation: sparkle 1s ease-in-out infinite;
}
.recognizing-text {
font-size: 14px;
}
}
}
.confirm-content {
.extracted-data {
max-height: 300px;
overflow-y: auto;
text-align: left;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
.data-item {
display: flex;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.data-label {
flex-shrink: 0;
width: 120px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.data-value {
flex: 1;
color: var(--el-text-color-primary);
word-break: break-all;
}
}
}
}
@keyframes sparkle {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
</style>
@@ -0,0 +1,2 @@
export { default as AiImageOcr } from './ai-image-ocr.vue';
export type { AiImageOcrEmits, AiImageOcrProps } from './types';
@@ -0,0 +1,18 @@
export interface AiImageOcrProps {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
}
export interface AiImageOcrEmits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
@@ -0,0 +1,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
@@ -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;
}
@@ -0,0 +1,654 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
Maximize2,
RotateCw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElInputNumber } from 'element-plus';
const props = defineProps<{
deleteNode: () => void;
editor: any;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 图片属性
const imgRef = ref<HTMLImageElement | null>(null);
const isResizing = ref(false);
const startX = ref(0);
const startY = ref(0);
const startWidth = ref(0);
const startHeight = ref(0);
const resizeDirection = ref<string>('');
// 右键菜单
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showSizePanel = ref(false);
// 尺寸输入
const widthInput = ref<number>(0);
const heightInput = ref<number>(0);
const keepRatio = ref(true);
const originalRatio = ref(1);
// 预设尺寸
const presetSizes = [
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
// 计算图片样式
const imageStyle = computed(() => {
const style: Record<string, string> = {};
if (props.node.attrs.width) {
style.width =
typeof props.node.attrs.width === 'number'
? `${props.node.attrs.width}px`
: props.node.attrs.width;
}
if (props.node.attrs.height) {
style.height =
typeof props.node.attrs.height === 'number'
? `${props.node.attrs.height}px`
: props.node.attrs.height;
}
return style;
});
// 计算容器样式(对齐)
const wrapperStyle = computed(() => {
const alignment = props.node.attrs.alignment || 'center';
const justifyMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
return {
justifyContent: justifyMap[alignment] || 'center',
};
});
// 图片加载完成后获取原始尺寸
const handleImageLoad = () => {
if (imgRef.value) {
const img = imgRef.value;
originalRatio.value = img.naturalWidth / img.naturalHeight;
if (props.node.attrs.width) {
widthInput.value =
typeof props.node.attrs.width === 'number'
? props.node.attrs.width
: Number.parseInt(props.node.attrs.width) || img.naturalWidth;
heightInput.value =
typeof props.node.attrs.height === 'number'
? props.node.attrs.height
: Number.parseInt(props.node.attrs.height) || img.naturalHeight;
} else {
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
}
};
// 右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
contextMenuVisible.value = true;
showSizePanel.value = false;
// 点击其他地方关闭菜单
nextTick(() => {
document.addEventListener('click', closeContextMenu);
document.addEventListener('contextmenu', closeContextMenu);
});
};
const closeContextMenu = () => {
contextMenuVisible.value = false;
showSizePanel.value = false;
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
};
// 开始调整大小
const startResize = (e: MouseEvent, direction: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeDirection.value = direction;
startX.value = e.clientX;
startY.value = e.clientY;
if (imgRef.value) {
startWidth.value = imgRef.value.offsetWidth;
startHeight.value = imgRef.value.offsetHeight;
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
// 处理调整大小
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - startX.value;
const deltaY = e.clientY - startY.value;
let newWidth = startWidth.value;
let newHeight = startHeight.value;
if (resizeDirection.value.includes('e')) {
newWidth = Math.max(50, startWidth.value + deltaX);
}
if (resizeDirection.value.includes('w')) {
newWidth = Math.max(50, startWidth.value - deltaX);
}
if (resizeDirection.value.includes('s')) {
newHeight = Math.max(50, startHeight.value + deltaY);
}
if (resizeDirection.value.includes('n')) {
newHeight = Math.max(50, startHeight.value - deltaY);
}
// 保持比例
if (keepRatio.value && originalRatio.value) {
if (
resizeDirection.value.includes('e') ||
resizeDirection.value.includes('w')
) {
newHeight = Math.round(newWidth / originalRatio.value);
} else {
newWidth = Math.round(newHeight * originalRatio.value);
}
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
};
// 停止调整大小
const stopResize = () => {
isResizing.value = false;
resizeDirection.value = '';
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 设置对齐方式
const setAlignment = (alignment: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment });
closeContextMenu();
};
// 设置预设尺寸(百分比)
const setPresetSize = (percent: number) => {
if (imgRef.value) {
const editorEl = props.editor?.view?.dom?.parentElement;
const containerWidth = editorEl?.clientWidth || 800;
const maxWidth = containerWidth - 32;
const newWidth = Math.round((maxWidth * percent) / 100);
const newHeight = Math.round(newWidth / originalRatio.value);
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
}
closeContextMenu();
};
// 切换自定义尺寸面板
const toggleSizePanel = () => {
showSizePanel.value = !showSizePanel.value;
};
// 手动输入宽度
const handleWidthChange = (value: number | undefined) => {
if (!value) return;
const newWidth = value;
let newHeight = heightInput.value;
if (keepRatio.value && originalRatio.value) {
newHeight = Math.round(newWidth / originalRatio.value);
heightInput.value = newHeight;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 手动输入高度
const handleHeightChange = (value: number | undefined) => {
if (!value) return;
const newHeight = value;
let newWidth = widthInput.value;
if (keepRatio.value && originalRatio.value) {
newWidth = Math.round(newHeight * originalRatio.value);
widthInput.value = newWidth;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 重置为原始尺寸
const resetSize = () => {
if (imgRef.value) {
const img = imgRef.value;
props.updateAttributes({
width: img.naturalWidth,
height: img.naturalHeight,
});
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
closeContextMenu();
};
// 删除图片
const handleDelete = () => {
props.deleteNode();
closeContextMenu();
};
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
});
</script>
<template>
<NodeViewWrapper class="resizable-image-wrapper flex" :style="wrapperStyle">
<div
class="resizable-image-container relative inline-block"
:class="{ 'is-selected': selected, 'is-resizing': isResizing }"
>
<!-- 图片 -->
<img
ref="imgRef"
:src="node.attrs.src"
:alt="node.attrs.alt"
:title="node.attrs.title"
:style="imageStyle"
class="block max-w-full rounded"
draggable="false"
@load="handleImageLoad"
@contextmenu="handleContextMenu"
/>
<!-- 选中时显示调整手柄 -->
<template v-if="selected">
<div
class="resize-handle resize-handle-nw"
@mousedown="(e) => startResize(e, 'nw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="(e) => startResize(e, 'ne')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="(e) => startResize(e, 'sw')"
></div>
<div
class="resize-handle resize-handle-se"
@mousedown="(e) => startResize(e, 'se')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="contextMenuVisible"
class="image-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-group">
<div class="menu-group-title">对齐方式</div>
<div class="menu-row">
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'left' }"
title="左对齐"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'center' }"
title="居中"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'right' }"
title="右对齐"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 快速缩放 -->
<div class="menu-group">
<div class="menu-group-title">快速缩放</div>
<div class="menu-row">
<button
v-for="size in presetSizes"
:key="size.value"
class="menu-size-btn"
@click="setPresetSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 自定义尺寸 -->
<button class="menu-item" @click="toggleSizePanel">
<Maximize2 class="menu-item-icon" />
<span>自定义尺寸</span>
</button>
<!-- 尺寸面板 -->
<div v-if="showSizePanel" class="size-panel">
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="widthInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleWidthChange"
/>
</div>
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="heightInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleHeightChange"
/>
</div>
<label class="size-checkbox">
<input v-model="keepRatio" type="checkbox" />
<span>保持比例</span>
</label>
</div>
<div class="menu-divider"></div>
<!-- 重置尺寸 -->
<button class="menu-item" @click="resetSize">
<RotateCw class="menu-item-icon" />
<span>重置尺寸</span>
</button>
<!-- 删除 -->
<button class="menu-item menu-item-danger" @click="handleDelete">
<Trash2 class="menu-item-icon" />
<span>删除图片</span>
</button>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-image-wrapper {
margin: 0.5em 0;
}
.resizable-image-container {
position: relative;
display: inline-block;
line-height: 0;
&.is-selected {
img {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
}
&.is-resizing {
user-select: none;
}
}
/* 调整手柄样式 - 只保留四个角 */
.resize-handle {
position: absolute;
z-index: 10;
width: 10px;
height: 10px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 2px;
&:hover {
background-color: var(--el-color-primary-dark-2);
}
}
.resize-handle-nw {
top: -5px;
left: -5px;
cursor: nw-resize;
}
.resize-handle-ne {
top: -5px;
right: -5px;
cursor: ne-resize;
}
.resize-handle-sw {
bottom: -5px;
left: -5px;
cursor: sw-resize;
}
.resize-handle-se {
right: -5px;
bottom: -5px;
cursor: se-resize;
}
/* 右键菜单样式 */
.image-context-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
padding: 6px 0;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-group {
padding: 6px 12px;
}
.menu-group-title {
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.menu-row {
display: flex;
gap: 4px;
}
.menu-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
&.active {
color: #fff;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
.menu-size-btn {
flex: 1;
height: 28px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
}
.menu-divider {
height: 1px;
margin: 6px 0;
background-color: var(--el-border-color-lighter);
}
.menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: none;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
&.menu-item-danger {
color: var(--el-color-danger);
&:hover {
background-color: var(--el-color-danger-light-9);
}
}
}
.menu-item-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 尺寸面板 */
.size-panel {
padding: 8px 12px;
background-color: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
}
.size-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.size-label {
width: 20px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.size-checkbox {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
input {
width: 14px;
height: 14px;
cursor: pointer;
}
}
:deep(.el-input-number--small) {
width: 100px;
}
</style>
@@ -0,0 +1,536 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
RotateCcw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElButton, ElCheckbox, ElInputNumber } from 'element-plus';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
deleteNode: () => void;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 状态
const isSelected = ref(false);
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showCustomSize = ref(false);
const keepRatio = ref(true);
const videoRef = ref<HTMLVideoElement | null>(null);
const originalSize = ref({ width: 0, height: 0 });
// 视频URL(响应式,支持token刷新)
const resolvedVideoSrc = ref('');
// 异步解析视频URL
async function resolveVideoUrl() {
const id = props.node.attrs.id;
const src = props.node.attrs.src;
// 如果有文件ID,优先使用ID获取带token的URL
if (id) {
resolvedVideoSrc.value = await getFileUrl(id);
} else if (src) {
resolvedVideoSrc.value = src;
} else {
resolvedVideoSrc.value = '';
}
}
// 监听属性变化
watch(
() => [props.node.attrs.id, props.node.attrs.src],
() => {
resolveVideoUrl();
},
{ immediate: true },
);
// 计算属性
const videoSrc = computed(() => resolvedVideoSrc.value);
const currentWidth = computed(() => {
const w = props.node.attrs.width;
if (typeof w === 'string' && w.endsWith('%')) {
return Number.parseInt(w);
}
return w || 100;
});
const currentHeight = computed(() => props.node.attrs.height || 'auto');
const alignment = computed(() => props.node.attrs.alignment || 'center');
const nodeStyle = computed(() => {
const styles: Record<string, string> = {
display: 'flex',
};
if (alignment.value === 'left') {
styles.justifyContent = 'flex-start';
} else if (alignment.value === 'right') {
styles.justifyContent = 'flex-end';
} else {
styles.justifyContent = 'center';
}
return styles;
});
const containerStyle = computed(() => {
const styles: Record<string, string> = {};
const w = props.node.attrs.width;
if (typeof w === 'number') {
styles.width = `${w}px`;
} else if (typeof w === 'string') {
styles.width = w;
} else {
styles.width = '100%';
}
return styles;
});
// 方法
const handleClick = () => {
isSelected.value = true;
};
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isSelected.value = true;
showContextMenu.value = true;
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
};
const hideContextMenu = () => {
showContextMenu.value = false;
showCustomSize.value = false;
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest('.resizable-video-wrapper') &&
!target.closest('.video-context-menu')
) {
isSelected.value = false;
hideContextMenu();
}
};
const setAlignment = (align: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment: align });
};
const setPresetSize = (percent: number) => {
props.updateAttributes({ width: `${percent}%`, height: 'auto' });
hideContextMenu();
};
const updateWidth = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.height / originalSize.value.width;
props.updateAttributes({ width: value, height: Math.round(value * ratio) });
} else {
props.updateAttributes({ width: value });
}
};
const updateHeight = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.width / originalSize.value.height;
props.updateAttributes({ height: value, width: Math.round(value * ratio) });
} else {
props.updateAttributes({ height: value });
}
};
const resetSize = () => {
props.updateAttributes({ width: '100%', height: 'auto' });
hideContextMenu();
};
const handleDelete = () => {
props.deleteNode();
};
// 拖拽调整大小
const isResizing = ref(false);
const resizeStartPos = ref({ x: 0, y: 0 });
const resizeStartSize = ref({ width: 0, height: 0 });
const resizeCorner = ref('');
const startResize = (e: MouseEvent, corner: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeCorner.value = corner;
resizeStartPos.value = { x: e.clientX, y: e.clientY };
const wrapper = (e.target as HTMLElement).closest(
'.resizable-video-wrapper',
) as HTMLElement;
if (wrapper) {
resizeStartSize.value = {
width: wrapper.offsetWidth,
height: wrapper.offsetHeight,
};
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStartPos.value.x;
// 左侧控制点需要反向计算
const isLeftCorner =
resizeCorner.value === 'nw' || resizeCorner.value === 'sw';
const adjustedDeltaX = isLeftCorner ? -deltaX : deltaX;
let newWidth = resizeStartSize.value.width + adjustedDeltaX;
let newHeight = resizeStartSize.value.height;
// 保持比例
if (originalSize.value.width && originalSize.value.height) {
const ratio = originalSize.value.height / originalSize.value.width;
newHeight = Math.round(newWidth * ratio);
}
// 限制最小尺寸
newWidth = Math.max(100, newWidth);
newHeight = Math.max(60, newHeight);
props.updateAttributes({ width: newWidth, height: newHeight });
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 获取视频原始尺寸
const handleVideoLoaded = () => {
if (videoRef.value) {
originalSize.value = {
width: videoRef.value.videoWidth,
height: videoRef.value.videoHeight,
};
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
});
</script>
<template>
<NodeViewWrapper class="resizable-video-node" :style="nodeStyle">
<div
class="resizable-video-wrapper"
:class="{ 'is-selected': isSelected || selected }"
:style="containerStyle"
@click="handleClick"
@contextmenu="handleContextMenu"
>
<video
ref="videoRef"
:src="videoSrc"
controls
class="resizable-video"
@loadedmetadata="handleVideoLoaded"
>
您的浏览器不支持视频播放
</video>
<!-- 调整大小的控制点 -->
<template v-if="isSelected || selected">
<div
class="resize-handle resize-handle-se"
@mousedown="startResize($event, 'se')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="startResize($event, 'sw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="startResize($event, 'ne')"
></div>
<div
class="resize-handle resize-handle-nw"
@mousedown="startResize($event, 'nw')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="showContextMenu"
class="video-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-section">
<div class="menu-label">对齐方式</div>
<div class="menu-buttons">
<ElButton
:type="alignment === 'left' ? 'primary' : 'default'"
size="small"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'center' ? 'primary' : 'default'"
size="small"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'right' ? 'primary' : 'default'"
size="small"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</ElButton>
</div>
</div>
<!-- 快速缩放 -->
<div class="menu-section">
<div class="menu-label">快速缩放</div>
<div class="menu-buttons">
<ElButton size="small" @click="setPresetSize(25)">25%</ElButton>
<ElButton size="small" @click="setPresetSize(50)">50%</ElButton>
<ElButton size="small" @click="setPresetSize(75)">75%</ElButton>
<ElButton size="small" @click="setPresetSize(100)">100%</ElButton>
</div>
</div>
<!-- 自定义尺寸 -->
<div class="menu-section">
<ElButton
size="small"
class="w-full"
@click="showCustomSize = !showCustomSize"
>
自定义尺寸
</ElButton>
<div v-if="showCustomSize" class="custom-size-panel">
<div class="size-inputs">
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentWidth === 'number' ? currentWidth : undefined
"
:min="100"
:max="1920"
size="small"
controls-position="right"
@update:model-value="updateWidth"
/>
</div>
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentHeight === 'number'
? currentHeight
: undefined
"
:min="60"
:max="1080"
size="small"
controls-position="right"
@update:model-value="updateHeight"
/>
</div>
</div>
<ElCheckbox v-model="keepRatio" size="small">保持比例</ElCheckbox>
</div>
</div>
<!-- 操作按钮 -->
<div class="menu-section menu-actions">
<ElButton size="small" @click="resetSize">
<RotateCcw class="mr-1 h-3 w-3" />
重置尺寸
</ElButton>
<ElButton size="small" type="danger" @click="handleDelete">
<Trash2 class="mr-1 h-3 w-3" />
删除视频
</ElButton>
</div>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-video-node {
display: block;
margin: 12px 0;
}
.resizable-video-wrapper {
position: relative;
display: inline-block;
max-width: 100%;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.resizable-video-wrapper:hover {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
.resizable-video-wrapper.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.resizable-video {
display: block;
width: 100%;
height: auto;
background-color: var(--el-fill-color-darker);
border-radius: 8px;
}
/* 调整大小控制点 */
.resize-handle {
position: absolute;
z-index: 10;
width: 12px;
height: 12px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 50%;
}
.resize-handle-se {
right: -6px;
bottom: -6px;
cursor: se-resize;
}
.resize-handle-sw {
bottom: -6px;
left: -6px;
cursor: sw-resize;
}
.resize-handle-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.resize-handle-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
/* 右键菜单 */
.video-context-menu {
position: fixed;
z-index: 9999;
min-width: 200px;
padding: 12px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-section {
margin-bottom: 12px;
}
.menu-section:last-child {
margin-bottom: 0;
}
.menu-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.menu-buttons {
display: flex;
gap: 4px;
}
.custom-size-panel {
padding: 8px;
margin-top: 8px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.size-inputs {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.size-input-group {
display: flex;
gap: 8px;
align-items: center;
}
.size-label {
min-width: 24px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.menu-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,115 @@
import { mergeAttributes, Node } from '@tiptap/core';
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export interface AttachmentAttributes {
id: string;
name: string;
size: number;
type: string;
url: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
attachment: {
setAttachment: (options: AttachmentAttributes) => ReturnType;
};
}
}
export const Attachment = Node.create<AttachmentOptions>({
name: 'attachment',
group: 'block',
atom: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
id: {
default: null,
},
name: {
default: null,
},
size: {
default: 0,
},
type: {
default: null,
},
url: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="attachment"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, name, size, type, url } = HTMLAttributes;
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'attachment',
'data-id': id,
'data-name': name,
'data-size': size,
'data-file-type': type,
'data-url': url,
class: 'attachment-node',
}),
[
'a',
{
href: url,
target: '_blank',
download: name,
class: 'attachment-link',
},
['span', { class: 'attachment-icon' }, '📎'],
['span', { class: 'attachment-name' }, name],
['span', { class: 'attachment-size' }, formatFileSize(size)],
],
];
},
addCommands() {
return {
setAttachment:
(options: AttachmentAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
export default Attachment;
@@ -0,0 +1,66 @@
import { Extension } from '@tiptap/core';
export interface FontSizeOptions {
types: string[];
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const FontSize = Extension.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) =>
element.style.fontSize?.replace(/['"]+/g, ''),
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {};
}
return {
style: `font-size: ${attributes.fontSize}`,
};
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark('textStyle', { fontSize: null })
.removeEmptyTextStyle()
.run();
},
};
},
});
@@ -0,0 +1,57 @@
import Image from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableImageComponent from './ResizableImageComponent.vue';
export const ResizableImage = Image.extend({
name: 'resizableImage',
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (element) =>
element.getAttribute('width') ||
element.style.width?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
height: {
default: null,
parseHTML: (element) =>
element.getAttribute('height') ||
element.style.height?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.height) return {};
return { height: attributes.height };
},
},
alignment: {
default: 'center',
parseHTML: (element) => element.dataset.alignment || 'center',
renderHTML: (attributes) => {
const alignment = attributes.alignment || 'center';
// 同时输出 data-alignment 和 style
const styleMap: Record<string, string> = {
left: 'display: block; margin-left: 0; margin-right: auto;',
center: 'display: block; margin-left: auto; margin-right: auto;',
right: 'display: block; margin-left: auto; margin-right: 0;',
};
return {
'data-alignment': alignment,
style: styleMap[alignment] || styleMap.center,
};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ResizableImageComponent as any);
},
});
export default ResizableImage;
@@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableVideoComponent from './ResizableVideoComponent.vue';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
allowFullscreen: boolean;
}
export interface VideoAttributes {
id?: string;
src: string;
width?: number | string;
height?: number | string;
alignment?: 'center' | 'left' | 'right';
poster?: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: VideoAttributes) => ReturnType;
};
}
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
allowFullscreen: true,
};
},
addAttributes() {
return {
id: {
default: null,
},
src: {
default: null,
},
width: {
default: '100%',
},
height: {
default: 'auto',
},
alignment: {
default: 'center',
},
poster: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="video"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, src, width, poster, alignment } = HTMLAttributes;
let marginStyle = 'margin-left: auto; margin-right: auto;';
if (alignment === 'left') {
marginStyle = 'margin-right: auto;';
} else if (alignment === 'right') {
marginStyle = 'margin-left: auto;';
}
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'video',
'data-id': id,
class: 'video-node',
style: `max-width: ${typeof width === 'number' ? `${width}px` : width}; ${marginStyle}`,
}),
[
'video',
{
src,
controls: true,
poster,
style: 'width: 100%; height: auto; border-radius: 8px;',
},
'您的浏览器不支持视频播放',
],
];
},
addNodeView() {
return VueNodeViewRenderer(ResizableVideoComponent as any);
},
addCommands() {
return {
setVideo:
(options: VideoAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
export default Video;
@@ -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