mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat: improve error handling and code quality
后端改进: - 添加统一异常处理系统 (exceptions.py, response.py) - 实现自定义异常类 (ValidationError, AuthorizationError, ResourceNotFoundError, BusinessLogicError) - 配置全局异常处理器,统一 API 错误响应格式 - 迁移业务逻辑错误到自定义异常 (users.py, auth.py) - 添加 SQL LIKE 通配符转义,防止通配符滥用 - 使用 EmailStr 进行邮箱格式验证 - 移除敏感字段暴露 (jwt_sub) 前端改进: - 配置 ESLint 9 (flat config) 和 Prettier - 修复所有 ESLint 错误和警告 - 移除未使用的变量和导入 - 为组件添加 PropTypes 默认值 - 统一代码格式和风格
This commit is contained in:
@@ -6,9 +6,9 @@
|
||||
v-for="m in modes"
|
||||
:key="m"
|
||||
:class="{ active: mode === m }"
|
||||
@click.prevent="switchMode(m)"
|
||||
class="mode-tab"
|
||||
type="button"
|
||||
@click.prevent="switchMode(m)"
|
||||
>
|
||||
{{ modeLabels[m] }}
|
||||
</button>
|
||||
@@ -36,16 +36,12 @@
|
||||
format="HH:mm"
|
||||
placeholder="选择时间"
|
||||
:minute-step="30"
|
||||
@change="onCustomTimeChange"
|
||||
style="width: 100%"
|
||||
@change="onCustomTimeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="频率" name="customFrequency">
|
||||
<a-select
|
||||
id="cron-custom-frequency"
|
||||
v-model:value="customFrequency"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select id="cron-custom-frequency" v-model:value="customFrequency" style="width: 100%">
|
||||
<a-select-option value="daily">每天</a-select-option>
|
||||
<a-select-option value="weekday">工作日(周一-周五)</a-select-option>
|
||||
<a-select-option value="weekend">周末(周六-周日)</a-select-option>
|
||||
@@ -86,67 +82,70 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import client from '@/api/client'
|
||||
import { ref, watch, onBeforeUnmount } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import client from '@/api/client';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String, // 当前 cron 表达式
|
||||
})
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '0 0 * * *',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const mode = ref('quick')
|
||||
const mode = ref('quick');
|
||||
const modeLabels = {
|
||||
quick: '快速',
|
||||
custom: '自定义',
|
||||
advanced: '高级'
|
||||
}
|
||||
const modes = ['quick', 'custom', 'advanced']
|
||||
advanced: '高级',
|
||||
};
|
||||
const modes = ['quick', 'custom', 'advanced'];
|
||||
|
||||
// 快速模式
|
||||
const selectedQuick = ref('20:00')
|
||||
const selectedQuick = ref('20:00');
|
||||
|
||||
// 自定义模式
|
||||
const customTime = ref('20:00')
|
||||
const customTimeValue = ref(dayjs('20:00', 'HH:mm'))
|
||||
const customFrequency = ref('daily')
|
||||
const customTime = ref('20:00');
|
||||
const customTimeValue = ref(dayjs('20:00', 'HH:mm'));
|
||||
const customFrequency = ref('daily');
|
||||
|
||||
// 高级模式
|
||||
const advancedExpression = ref(props.modelValue || '0 20 * * *')
|
||||
const validationMessage = ref('')
|
||||
const validationStatus = ref('')
|
||||
const advancedExpression = ref(props.modelValue || '0 20 * * *');
|
||||
const validationMessage = ref('');
|
||||
const validationStatus = ref('');
|
||||
|
||||
// 通用
|
||||
const nextExecutions = ref([])
|
||||
const nextExecutions = ref([]);
|
||||
|
||||
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
|
||||
let isManualEditing = false
|
||||
let isManualEditing = false;
|
||||
|
||||
// 切换模式 - 防止页面刷新
|
||||
function switchMode(newMode) {
|
||||
mode.value = newMode
|
||||
mode.value = newMode;
|
||||
|
||||
// 切换到快速模式时,自动选择默认值并触发保存
|
||||
if (newMode === 'quick') {
|
||||
selectedQuick.value = '20:00'
|
||||
const cron = buildCrontabFromQuick()
|
||||
advancedExpression.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
if (cron) validateAndPreview(cron)
|
||||
selectedQuick.value = '20:00';
|
||||
const cron = buildCrontabFromQuick();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
}
|
||||
// 切换到自定义模式时,基于当前值构建 cron
|
||||
else if (newMode === 'custom') {
|
||||
const cron = buildCrontabFromCustom()
|
||||
advancedExpression.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
if (cron) validateAndPreview(cron)
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
}
|
||||
// 切换到高级模式时,使用当前的 advancedExpression
|
||||
else if (newMode === 'advanced') {
|
||||
if (advancedExpression.value) {
|
||||
emit('update:modelValue', advancedExpression.value)
|
||||
validateAndPreview(advancedExpression.value)
|
||||
emit('update:modelValue', advancedExpression.value);
|
||||
validateAndPreview(advancedExpression.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,174 +153,185 @@ function switchMode(newMode) {
|
||||
// 处理时间选择器变化
|
||||
function onCustomTimeChange(time) {
|
||||
if (time) {
|
||||
customTime.value = time.format('HH:mm')
|
||||
customTime.value = time.format('HH:mm');
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 - 只在有效值时更新
|
||||
watch(selectedQuick, () => {
|
||||
const cron = buildCrontabFromQuick()
|
||||
advancedExpression.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
if (cron) validateAndPreview(cron)
|
||||
})
|
||||
const cron = buildCrontabFromQuick();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
});
|
||||
|
||||
watch(customFrequency, () => {
|
||||
const cron = buildCrontabFromCustom()
|
||||
advancedExpression.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
if (cron) validateAndPreview(cron)
|
||||
})
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
});
|
||||
|
||||
watch(customTime, () => {
|
||||
const cron = buildCrontabFromCustom()
|
||||
advancedExpression.value = cron
|
||||
emit('update:modelValue', cron)
|
||||
if (cron) validateAndPreview(cron)
|
||||
})
|
||||
const cron = buildCrontabFromCustom();
|
||||
advancedExpression.value = cron;
|
||||
emit('update:modelValue', cron);
|
||||
if (cron) validateAndPreview(cron);
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
function buildCrontabFromQuick() {
|
||||
if (selectedQuick.value === '20:00') {
|
||||
return '0 20 * * *' // 每天 20:00
|
||||
return '0 20 * * *'; // 每天 20:00
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCrontabFromCustom() {
|
||||
const [hour, minute] = customTime.value.split(':')
|
||||
const [hour, minute] = customTime.value.split(':');
|
||||
|
||||
let dow = '*' // 星期
|
||||
let dow = '*'; // 星期
|
||||
if (customFrequency.value === 'weekday') {
|
||||
dow = '1-5' // 周一至周五
|
||||
dow = '1-5'; // 周一至周五
|
||||
} else if (customFrequency.value === 'weekend') {
|
||||
dow = '0,6' // 周六和周日
|
||||
dow = '0,6'; // 周六和周日
|
||||
}
|
||||
|
||||
return `${minute} ${hour} * * ${dow}`
|
||||
return `${minute} ${hour} * * ${dow}`;
|
||||
}
|
||||
|
||||
// 处理高级模式输入 - 使用防抖以避免频繁调用API
|
||||
let debounceTimer = null
|
||||
let debounceTimer = null;
|
||||
function handleAdvancedInput() {
|
||||
// 设置手动编辑标志
|
||||
isManualEditing = true
|
||||
isManualEditing = true;
|
||||
|
||||
// 立即触发 emit,保证值实时同步
|
||||
emit('update:modelValue', advancedExpression.value)
|
||||
emit('update:modelValue', advancedExpression.value);
|
||||
|
||||
// 使用防抖延迟验证
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (!advancedExpression.value.trim()) {
|
||||
validationMessage.value = ''
|
||||
nextExecutions.value = []
|
||||
return
|
||||
validationMessage.value = '';
|
||||
nextExecutions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await validateAndPreview(advancedExpression.value)
|
||||
}, 500) // 500ms 防抖延迟
|
||||
await validateAndPreview(advancedExpression.value);
|
||||
}, 500); // 500ms 防抖延迟
|
||||
}
|
||||
|
||||
async function validateAndPreview(expr) {
|
||||
if (!expr) {
|
||||
validationMessage.value = ''
|
||||
nextExecutions.value = []
|
||||
return
|
||||
validationMessage.value = '';
|
||||
nextExecutions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.post('/api/tasks/validate-cron', {
|
||||
cron_expression: expr
|
||||
})
|
||||
cron_expression: expr,
|
||||
});
|
||||
|
||||
if (response.valid) {
|
||||
validationStatus.value = 'success'
|
||||
validationMessage.value = `有效: ${response.description}`
|
||||
nextExecutions.value = response.next_times
|
||||
validationStatus.value = 'success';
|
||||
validationMessage.value = `有效: ${response.description}`;
|
||||
nextExecutions.value = response.next_times;
|
||||
}
|
||||
} catch (error) {
|
||||
validationStatus.value = 'error'
|
||||
validationMessage.value = error.message || '无效的 crontab 表达式'
|
||||
nextExecutions.value = []
|
||||
validationStatus.value = 'error';
|
||||
validationMessage.value = error.message || '无效的 crontab 表达式';
|
||||
nextExecutions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 cron 表达式并设置对应的模式
|
||||
function parseCronExpression(cron) {
|
||||
if (!cron) return
|
||||
if (!cron) return;
|
||||
|
||||
advancedExpression.value = cron
|
||||
advancedExpression.value = cron;
|
||||
|
||||
// 尝试匹配快速模式: 0 20 * * *
|
||||
if (cron === '0 20 * * *') {
|
||||
mode.value = 'quick'
|
||||
selectedQuick.value = '20:00'
|
||||
validateAndPreview(cron)
|
||||
return
|
||||
mode.value = 'quick';
|
||||
selectedQuick.value = '20:00';
|
||||
validateAndPreview(cron);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试解析为自定义模式
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length === 5) {
|
||||
const [minute, hour, day, month, dow] = parts
|
||||
const [minute, hour, day, month, dow] = parts;
|
||||
|
||||
// 检查是否是简单的每天或工作日/周末模式
|
||||
if (day === '*' && month === '*') {
|
||||
const hourNum = parseInt(hour)
|
||||
const minuteNum = parseInt(minute)
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
|
||||
if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum < 24 && minuteNum >= 0 && minuteNum < 60) {
|
||||
mode.value = 'custom'
|
||||
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
||||
customTimeValue.value = dayjs(customTime.value, 'HH:mm')
|
||||
if (
|
||||
!isNaN(hourNum) &&
|
||||
!isNaN(minuteNum) &&
|
||||
hourNum >= 0 &&
|
||||
hourNum < 24 &&
|
||||
minuteNum >= 0 &&
|
||||
minuteNum < 60
|
||||
) {
|
||||
mode.value = 'custom';
|
||||
customTime.value = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
customTimeValue.value = dayjs(customTime.value, 'HH:mm');
|
||||
|
||||
// 识别频率
|
||||
if (dow === '*') {
|
||||
customFrequency.value = 'daily'
|
||||
customFrequency.value = 'daily';
|
||||
} else if (dow === '1-5') {
|
||||
customFrequency.value = 'weekday'
|
||||
customFrequency.value = 'weekday';
|
||||
} else if (dow === '0,6' || dow === '6,0') {
|
||||
customFrequency.value = 'weekend'
|
||||
customFrequency.value = 'weekend';
|
||||
} else {
|
||||
// 不支持的星期模式,使用高级模式
|
||||
mode.value = 'advanced'
|
||||
mode.value = 'advanced';
|
||||
}
|
||||
|
||||
validateAndPreview(cron)
|
||||
return
|
||||
validateAndPreview(cron);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况使用高级模式
|
||||
mode.value = 'advanced'
|
||||
validateAndPreview(cron)
|
||||
mode.value = 'advanced';
|
||||
validateAndPreview(cron);
|
||||
}
|
||||
|
||||
// 初始化 - 解析传入的 cron 表达式
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
// 如果正在手动编辑高级模式,跳过自动解析
|
||||
if (isManualEditing) {
|
||||
isManualEditing = false // 重置标志
|
||||
return
|
||||
}
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newVal => {
|
||||
// 如果正在手动编辑高级模式,跳过自动解析
|
||||
if (isManualEditing) {
|
||||
isManualEditing = false; // 重置标志
|
||||
return;
|
||||
}
|
||||
|
||||
if (newVal) {
|
||||
parseCronExpression(newVal)
|
||||
}
|
||||
}, { immediate: true })
|
||||
if (newVal) {
|
||||
parseCronExpression(newVal);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 组件卸载时清理防抖定时器,防止内存泄漏
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -391,8 +401,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
.quick-option:hover {
|
||||
border-color: var(--md-sys-color-outline);
|
||||
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.3),
|
||||
0px 1px 3px 1px rgba(0, 0, 0, 0.15);
|
||||
box-shadow:
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
|
||||
0px 1px 3px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<a-form-item label="显示名称" class="mb-0">
|
||||
<a-input
|
||||
:value="modelValue.display_name"
|
||||
@change="e => updateField('display_name', e.target.value)"
|
||||
placeholder="在表单中显示的名称"
|
||||
allow-clear
|
||||
@change="e => updateField('display_name', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">显示名称</span>
|
||||
</a-form-item>
|
||||
@@ -15,9 +15,9 @@
|
||||
<a-form-item label="字段类型" class="mb-0">
|
||||
<a-select
|
||||
:value="modelValue.field_type"
|
||||
@change="handleFieldTypeChange"
|
||||
placeholder="选择输入控件类型"
|
||||
class="w-full"
|
||||
@change="handleFieldTypeChange"
|
||||
>
|
||||
<a-select-option label="📝 单行文本" value="text" />
|
||||
<a-select-option label="📄 多行文本" value="textarea" />
|
||||
@@ -33,9 +33,9 @@
|
||||
<a-form-item label="值类型" class="mb-0">
|
||||
<a-select
|
||||
:value="modelValue.value_type"
|
||||
@change="value => updateField('value_type', value)"
|
||||
placeholder="选择数据类型"
|
||||
class="w-full"
|
||||
@change="value => updateField('value_type', value)"
|
||||
>
|
||||
<a-select-option label="字符串 (string)" value="string">
|
||||
<span class="text-xs text-on-surface-variant">字符串 (string)</span>
|
||||
@@ -60,26 +60,24 @@
|
||||
<a-input
|
||||
v-if="modelValue.value_type !== 'json'"
|
||||
:value="modelValue.default_value"
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
placeholder="字段的默认值"
|
||||
allow-clear
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
/>
|
||||
<a-textarea
|
||||
v-else
|
||||
:value="modelValue.default_value"
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
placeholder="字段的默认值"
|
||||
:rows="3"
|
||||
allow-clear
|
||||
@change="e => updateField('default_value', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">
|
||||
<template v-if="modelValue.value_type === 'json'">
|
||||
<p>输入JSON对象,会自动序列化为字符串</p>
|
||||
<p>如:{"key1":value1,"key2":value2}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
用户未填写时使用此值
|
||||
</template>
|
||||
<template v-else> 用户未填写时使用此值 </template>
|
||||
</span>
|
||||
</a-form-item>
|
||||
</div>
|
||||
@@ -88,15 +86,17 @@
|
||||
<a-form-item label="占位符提示" class="mb-0">
|
||||
<a-input
|
||||
:value="modelValue.placeholder"
|
||||
@change="e => updateField('placeholder', e.target.value)"
|
||||
placeholder="输入框的灰色提示文本"
|
||||
allow-clear
|
||||
@change="e => updateField('placeholder', e.target.value)"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-variant mt-1">占位符</span>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Row 4: Switches -->
|
||||
<div class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant">
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 p-3 bg-surface-container-low rounded-md3 border border-outline-variant"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-on-surface">是否必填</label>
|
||||
@@ -104,8 +104,8 @@
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="modelValue.required"
|
||||
@change="handleRequiredChange"
|
||||
:disabled="modelValue.hidden"
|
||||
@change="handleRequiredChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,20 +114,11 @@
|
||||
<label class="text-sm font-medium text-on-surface">是否隐藏</label>
|
||||
<p class="text-xs text-on-surface-variant">直接使用默认值,不在表单中显示</p>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="modelValue.hidden"
|
||||
@change="handleHiddenChange"
|
||||
/>
|
||||
<a-switch :checked="modelValue.hidden" @change="handleHiddenChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="modelValue.hidden"
|
||||
message="💡 提示"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mt-3"
|
||||
>
|
||||
<a-alert v-if="modelValue.hidden" message="💡 提示" type="info" :closable="false" class="mt-3">
|
||||
<template #description>
|
||||
<p class="text-xs">
|
||||
隐藏字段将自动使用默认值,不会在创建任务表单中显示。请确保设置了合适的默认值。
|
||||
@@ -147,30 +138,31 @@
|
||||
<span class="text-xs text-on-surface-variant w-8">{{ index + 1 }}.</span>
|
||||
<a-input
|
||||
:value="option.label"
|
||||
@change="e => updateOption(index, 'label', e.target.value)"
|
||||
placeholder="显示文本(如:健康)"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
@change="e => updateOption(index, 'label', e.target.value)"
|
||||
/>
|
||||
<a-input
|
||||
:value="option.value"
|
||||
@change="e => updateOption(index, 'value', e.target.value)"
|
||||
placeholder="选项值(如:healthy)"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
@change="e => updateOption(index, 'value', e.target.value)"
|
||||
/>
|
||||
<a-button
|
||||
size="small"
|
||||
danger
|
||||
@click="removeOption(index)"
|
||||
>
|
||||
<a-button size="small" danger @click="removeOption(index)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-button size="small" type="primary" @click="addOption" class="w-full">
|
||||
<a-button size="small" type="primary" class="w-full" @click="addOption">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
添加选项
|
||||
</a-button>
|
||||
@@ -185,99 +177,99 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { DeleteOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
fieldKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
// Update single field
|
||||
const updateField = (field, value) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
[field]: value
|
||||
})
|
||||
}
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle required change
|
||||
const handleRequiredChange = (value) => {
|
||||
updateField('required', value)
|
||||
}
|
||||
const handleRequiredChange = value => {
|
||||
updateField('required', value);
|
||||
};
|
||||
|
||||
// Handle hidden change - 当隐藏时,自动设置 required 为 false
|
||||
const handleHiddenChange = (value) => {
|
||||
const handleHiddenChange = value => {
|
||||
const updated = {
|
||||
...props.modelValue,
|
||||
hidden: value
|
||||
}
|
||||
hidden: value,
|
||||
};
|
||||
|
||||
// 如果设置为隐藏,则取消必填
|
||||
if (value) {
|
||||
updated.required = false
|
||||
updated.required = false;
|
||||
}
|
||||
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
emit('update:modelValue', updated);
|
||||
};
|
||||
|
||||
// Handle field type change
|
||||
const handleFieldTypeChange = (newType) => {
|
||||
const handleFieldTypeChange = newType => {
|
||||
const updated = {
|
||||
...props.modelValue,
|
||||
field_type: newType
|
||||
}
|
||||
field_type: newType,
|
||||
};
|
||||
|
||||
if (newType === 'select' && !updated.options) {
|
||||
updated.options = []
|
||||
updated.options = [];
|
||||
}
|
||||
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
emit('update:modelValue', updated);
|
||||
};
|
||||
|
||||
// Add option
|
||||
const addOption = () => {
|
||||
const options = [...(props.modelValue.options || [])]
|
||||
options.push({ label: '', value: '' })
|
||||
const options = [...(props.modelValue.options || [])];
|
||||
options.push({ label: '', value: '' });
|
||||
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
options
|
||||
})
|
||||
}
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
// Update option
|
||||
const updateOption = (index, field, value) => {
|
||||
const options = [...(props.modelValue.options || [])]
|
||||
const options = [...(props.modelValue.options || [])];
|
||||
options[index] = {
|
||||
...options[index],
|
||||
[field]: value
|
||||
}
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
options
|
||||
})
|
||||
}
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
// Remove option
|
||||
const removeOption = (index) => {
|
||||
const options = [...(props.modelValue.options || [])]
|
||||
options.splice(index, 1)
|
||||
const removeOption = index => {
|
||||
const options = [...(props.modelValue.options || [])];
|
||||
options.splice(index, 1);
|
||||
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
options
|
||||
})
|
||||
}
|
||||
options,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow">
|
||||
<div
|
||||
class="field-tree-node border border-outline-variant rounded-md3 p-4 bg-surface shadow-md3-1 hover:shadow-md3-2 transition-shadow"
|
||||
>
|
||||
<!-- 普通字段 -->
|
||||
<div v-if="isFieldConfig" class="field-config">
|
||||
<div class="flex items-center justify-between mb-3 pb-2 border-b border-outline-variant">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant transition-transform"
|
||||
@@ -16,29 +18,54 @@
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-mono text-base font-bold text-primary">{{ fieldKey }}</span>
|
||||
<a-tag type="primary" size="small">普通字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" @click="handleMove('up')" title="上移">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleMove('down')" title="下移">
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" type="danger" plain @click="handleDelete">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</a-button>
|
||||
@@ -56,8 +83,8 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant transition-transform"
|
||||
@@ -66,35 +93,65 @@
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-mono text-base font-bold text-secondary">{{ fieldKey }}</span>
|
||||
<a-tag type="warning" size="small">数组字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" @click="handleMove('up')" title="上移">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleMove('down')" title="下移">
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" @click="addArrayItem">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
添加元素
|
||||
</a-button>
|
||||
<a-button size="small" type="danger" plain @click="handleDelete">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</a-button>
|
||||
@@ -102,7 +159,10 @@
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed">
|
||||
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
|
||||
<div
|
||||
v-if="localFieldConfig.length === 0"
|
||||
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
|
||||
>
|
||||
<p class="text-sm text-on-surface-variant mb-2">数组为空</p>
|
||||
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
|
||||
</div>
|
||||
@@ -121,8 +181,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 如果数组元素是字段配置对象,直接渲染为字段编辑器 -->
|
||||
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-surface rounded-md3 p-3">
|
||||
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" />
|
||||
<div
|
||||
v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item"
|
||||
class="bg-surface rounded-md3 p-3"
|
||||
>
|
||||
<FieldConfigEditor
|
||||
:model-value="item"
|
||||
:field-key="`元素${index + 1}`"
|
||||
@update:model-value="updateArrayItemField(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 如果数组元素是对象(但不是字段配置),递归渲染其中的字段 -->
|
||||
@@ -138,9 +205,20 @@
|
||||
@move="$emit('move', $event)"
|
||||
/>
|
||||
|
||||
<a-button class="w-full" size="small" type="primary" plain @click="addFieldToArrayItem(index)">
|
||||
<a-button
|
||||
class="w-full"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="addFieldToArrayItem(index)"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
添加字段
|
||||
</a-button>
|
||||
@@ -168,8 +246,8 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
class="hover:bg-surface-container rounded-md3 p-1 transition-colors"
|
||||
@click="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-on-surface-variant transition-transform"
|
||||
@@ -178,35 +256,65 @@
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-mono text-base font-bold text-accent">{{ fieldKey }}</span>
|
||||
<a-tag type="success" size="small">对象字段</a-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" @click="handleMove('up')" title="上移">
|
||||
<a-button size="small" title="上移" @click="handleMove('up')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleMove('down')" title="下移">
|
||||
<a-button size="small" title="下移" @click="handleMove('down')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" @click="addFieldToObject">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
添加子字段
|
||||
</a-button>
|
||||
<a-button size="small" type="danger" plain @click="handleDelete">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</a-button>
|
||||
@@ -214,34 +322,47 @@
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed">
|
||||
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline">
|
||||
<div
|
||||
v-if="Object.keys(localFieldConfig).length === 0"
|
||||
class="text-center py-6 bg-surface-container-low rounded-md3 border border-dashed border-outline"
|
||||
>
|
||||
<p class="text-sm text-on-surface-variant mb-2">对象为空</p>
|
||||
<a-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</a-button>
|
||||
<a-button size="small" type="primary" @click="addFieldToObject"
|
||||
>添加第一个子字段</a-button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-accent">
|
||||
<!-- 递归渲染对象中的字段 -->
|
||||
<FieldTreeNode
|
||||
v-for="(subConfig, subKey) in localFieldConfig"
|
||||
:key="subKey"
|
||||
:field-key="subKey"
|
||||
:field-config="subConfig"
|
||||
:path="[...path, subKey]"
|
||||
@update="$emit('update', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@move="$emit('move', $event)"
|
||||
/>
|
||||
<!-- 递归渲染对象中的字段 -->
|
||||
<FieldTreeNode
|
||||
v-for="(subConfig, subKey) in localFieldConfig"
|
||||
:key="subKey"
|
||||
:field-key="subKey"
|
||||
:field-config="subConfig"
|
||||
:path="[...path, subKey]"
|
||||
@update="$emit('update', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@move="$emit('move', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加字段对话框 -->
|
||||
<a-modal v-model:open="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px">
|
||||
<a-modal
|
||||
v-model:open="addFieldDialogVisible"
|
||||
:title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'"
|
||||
width="400px"
|
||||
>
|
||||
<a-form>
|
||||
<a-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
|
||||
<a-input
|
||||
v-model:value="newFieldName"
|
||||
:placeholder="currentArrayIndex === -1 ? '留空则作为数组元素,填写则作为对象字段' : '例如: FieldId, Values, Texts'"
|
||||
:placeholder="
|
||||
currentArrayIndex === -1
|
||||
? '留空则作为数组元素,填写则作为对象字段'
|
||||
: '例如: FieldId, Values, Texts'
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="元素类型">
|
||||
@@ -262,119 +383,131 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import FieldConfigEditor from './FieldConfigEditor.vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import FieldConfigEditor from './FieldConfigEditor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
fieldKey: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
fieldConfig: {
|
||||
type: [Object, Array],
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
path: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete', 'move'])
|
||||
const emit = defineEmits(['update', 'delete', 'move']);
|
||||
|
||||
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)))
|
||||
const addFieldDialogVisible = ref(false)
|
||||
const newFieldName = ref('')
|
||||
const newFieldType = ref('field')
|
||||
const currentArrayIndex = ref(null)
|
||||
const isAddingToObject = ref(false)
|
||||
const isCollapsed = ref(false)
|
||||
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)));
|
||||
const addFieldDialogVisible = ref(false);
|
||||
const newFieldName = ref('');
|
||||
const newFieldType = ref('field');
|
||||
const currentArrayIndex = ref(null);
|
||||
const isAddingToObject = ref(false);
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
// 标志位,防止循环更新
|
||||
let isUpdatingFromProps = false
|
||||
let isUpdatingFromProps = false;
|
||||
|
||||
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
|
||||
watch(() => props.fieldConfig, (newVal) => {
|
||||
isUpdatingFromProps = true
|
||||
localFieldConfig.value = JSON.parse(JSON.stringify(newVal))
|
||||
// 使用 nextTick 确保在下一个 tick 后重置标志
|
||||
nextTick(() => {
|
||||
isUpdatingFromProps = false
|
||||
})
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => props.fieldConfig,
|
||||
newVal => {
|
||||
isUpdatingFromProps = true;
|
||||
localFieldConfig.value = JSON.parse(JSON.stringify(newVal));
|
||||
// 使用 nextTick 确保在下一个 tick 后重置标志
|
||||
nextTick(() => {
|
||||
isUpdatingFromProps = false;
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 判断字段类型
|
||||
const isFieldConfig = computed(() => {
|
||||
return typeof props.fieldConfig === 'object' &&
|
||||
!Array.isArray(props.fieldConfig) &&
|
||||
'display_name' in props.fieldConfig
|
||||
})
|
||||
return (
|
||||
typeof props.fieldConfig === 'object' &&
|
||||
!Array.isArray(props.fieldConfig) &&
|
||||
'display_name' in props.fieldConfig
|
||||
);
|
||||
});
|
||||
|
||||
const isArray = computed(() => {
|
||||
return Array.isArray(props.fieldConfig)
|
||||
})
|
||||
return Array.isArray(props.fieldConfig);
|
||||
});
|
||||
|
||||
const isObject = computed(() => {
|
||||
return typeof props.fieldConfig === 'object' &&
|
||||
!Array.isArray(props.fieldConfig) &&
|
||||
!('display_name' in props.fieldConfig)
|
||||
})
|
||||
return (
|
||||
typeof props.fieldConfig === 'object' &&
|
||||
!Array.isArray(props.fieldConfig) &&
|
||||
!('display_name' in props.fieldConfig)
|
||||
);
|
||||
});
|
||||
|
||||
// 监听本地配置变化 - 只在非 props 更新时触发
|
||||
watch(localFieldConfig, (newVal) => {
|
||||
if (!isUpdatingFromProps) {
|
||||
emit('update', { path: props.path, value: newVal })
|
||||
}
|
||||
}, { deep: true })
|
||||
watch(
|
||||
localFieldConfig,
|
||||
newVal => {
|
||||
if (!isUpdatingFromProps) {
|
||||
emit('update', { path: props.path, value: newVal });
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 删除字段
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.path)
|
||||
}
|
||||
emit('delete', props.path);
|
||||
};
|
||||
|
||||
// 移动字段
|
||||
const handleMove = (direction) => {
|
||||
emit('move', { path: props.path, direction })
|
||||
}
|
||||
const handleMove = direction => {
|
||||
emit('move', { path: props.path, direction });
|
||||
};
|
||||
|
||||
// 添加数组元素
|
||||
const addArrayItem = () => {
|
||||
// 弹出对话框让用户选择添加元素类型
|
||||
currentArrayIndex.value = -1 // 标记为添加数组元素
|
||||
isAddingToObject.value = false
|
||||
newFieldName.value = '' // 数组元素不需要字段名,但复用对话框
|
||||
newFieldType.value = 'field'
|
||||
addFieldDialogVisible.value = true
|
||||
}
|
||||
currentArrayIndex.value = -1; // 标记为添加数组元素
|
||||
isAddingToObject.value = false;
|
||||
newFieldName.value = ''; // 数组元素不需要字段名,但复用对话框
|
||||
newFieldType.value = 'field';
|
||||
addFieldDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除数组元素
|
||||
const removeArrayItem = (index) => {
|
||||
localFieldConfig.value.splice(index, 1)
|
||||
}
|
||||
const removeArrayItem = index => {
|
||||
localFieldConfig.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// 更新数组元素的字段配置
|
||||
const updateArrayItemField = (index, newValue) => {
|
||||
localFieldConfig.value[index] = newValue
|
||||
}
|
||||
localFieldConfig.value[index] = newValue;
|
||||
};
|
||||
|
||||
// 为数组元素添加字段
|
||||
const addFieldToArrayItem = (index) => {
|
||||
currentArrayIndex.value = index
|
||||
isAddingToObject.value = false
|
||||
newFieldName.value = ''
|
||||
newFieldType.value = 'field'
|
||||
addFieldDialogVisible.value = true
|
||||
}
|
||||
const addFieldToArrayItem = index => {
|
||||
currentArrayIndex.value = index;
|
||||
isAddingToObject.value = false;
|
||||
newFieldName.value = '';
|
||||
newFieldType.value = 'field';
|
||||
addFieldDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 为对象添加字段
|
||||
const addFieldToObject = () => {
|
||||
currentArrayIndex.value = null
|
||||
isAddingToObject.value = true
|
||||
newFieldName.value = ''
|
||||
newFieldType.value = 'field'
|
||||
addFieldDialogVisible.value = true
|
||||
}
|
||||
currentArrayIndex.value = null;
|
||||
isAddingToObject.value = true;
|
||||
newFieldName.value = '';
|
||||
newFieldType.value = 'field';
|
||||
addFieldDialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 确认添加字段
|
||||
const confirmAddField = () => {
|
||||
@@ -391,20 +524,20 @@ const confirmAddField = () => {
|
||||
required: false,
|
||||
hidden: false,
|
||||
value_type: 'string',
|
||||
options: []
|
||||
})
|
||||
options: [],
|
||||
});
|
||||
} else if (newFieldType.value === 'array') {
|
||||
localFieldConfig.value.push([])
|
||||
localFieldConfig.value.push([]);
|
||||
} else if (newFieldType.value === 'object') {
|
||||
localFieldConfig.value.push({})
|
||||
localFieldConfig.value.push({});
|
||||
}
|
||||
|
||||
addFieldDialogVisible.value = false
|
||||
message.success('数组元素添加成功')
|
||||
return
|
||||
addFieldDialogVisible.value = false;
|
||||
message.success('数组元素添加成功');
|
||||
return;
|
||||
} else {
|
||||
// 字段名不为空,添加为包含命名字段的对象
|
||||
const newObject = {}
|
||||
const newObject = {};
|
||||
if (newFieldType.value === 'field') {
|
||||
newObject[newFieldName.value] = {
|
||||
display_name: '',
|
||||
@@ -413,32 +546,32 @@ const confirmAddField = () => {
|
||||
required: false,
|
||||
hidden: false,
|
||||
value_type: 'string',
|
||||
options: []
|
||||
}
|
||||
options: [],
|
||||
};
|
||||
} else if (newFieldType.value === 'array') {
|
||||
newObject[newFieldName.value] = []
|
||||
newObject[newFieldName.value] = [];
|
||||
} else if (newFieldType.value === 'object') {
|
||||
newObject[newFieldName.value] = {}
|
||||
newObject[newFieldName.value] = {};
|
||||
}
|
||||
|
||||
localFieldConfig.value.push(newObject)
|
||||
addFieldDialogVisible.value = false
|
||||
message.success('带命名字段的对象添加成功')
|
||||
return
|
||||
localFieldConfig.value.push(newObject);
|
||||
addFieldDialogVisible.value = false;
|
||||
message.success('带命名字段的对象添加成功');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况需要字段名
|
||||
if (!newFieldName.value) {
|
||||
message.warning('请输入字段名')
|
||||
return
|
||||
message.warning('请输入字段名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAddingToObject.value) {
|
||||
// 添加到对象字段
|
||||
if (localFieldConfig.value[newFieldName.value]) {
|
||||
message.warning('该字段已存在')
|
||||
return
|
||||
message.warning('该字段已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newFieldType.value === 'field') {
|
||||
@@ -449,19 +582,19 @@ const confirmAddField = () => {
|
||||
required: false,
|
||||
hidden: false,
|
||||
value_type: 'string',
|
||||
options: []
|
||||
}
|
||||
options: [],
|
||||
};
|
||||
} else if (newFieldType.value === 'array') {
|
||||
localFieldConfig.value[newFieldName.value] = []
|
||||
localFieldConfig.value[newFieldName.value] = [];
|
||||
} else if (newFieldType.value === 'object') {
|
||||
localFieldConfig.value[newFieldName.value] = {}
|
||||
localFieldConfig.value[newFieldName.value] = {};
|
||||
}
|
||||
} else if (currentArrayIndex.value !== null) {
|
||||
// 添加到数组元素
|
||||
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
|
||||
const arrayItem = localFieldConfig.value[currentArrayIndex.value];
|
||||
if (arrayItem[newFieldName.value]) {
|
||||
message.warning('该字段已存在')
|
||||
return
|
||||
message.warning('该字段已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newFieldType.value === 'field') {
|
||||
@@ -472,18 +605,18 @@ const confirmAddField = () => {
|
||||
required: false,
|
||||
hidden: false,
|
||||
value_type: 'string',
|
||||
options: []
|
||||
}
|
||||
options: [],
|
||||
};
|
||||
} else if (newFieldType.value === 'array') {
|
||||
arrayItem[newFieldName.value] = []
|
||||
arrayItem[newFieldName.value] = [];
|
||||
} else if (newFieldType.value === 'object') {
|
||||
arrayItem[newFieldName.value] = {}
|
||||
arrayItem[newFieldName.value] = {};
|
||||
}
|
||||
}
|
||||
|
||||
addFieldDialogVisible.value = false
|
||||
message.success('字段添加成功')
|
||||
}
|
||||
addFieldDialogVisible.value = false;
|
||||
message.success('字段添加成功');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor'
|
||||
import { onMounted } from 'vue';
|
||||
import Navbar from './Navbar.vue';
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor';
|
||||
|
||||
// 启动全局 Token 监控
|
||||
const { startMonitoring } = useTokenMonitor()
|
||||
const { startMonitoring } = useTokenMonitor();
|
||||
|
||||
onMounted(() => {
|
||||
startMonitoring()
|
||||
})
|
||||
startMonitoring();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -26,7 +26,11 @@ onMounted(() => {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, var(--md-sys-color-surface-container-lowest) 0%, var(--md-sys-color-surface-container-low) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--md-sys-color-surface-container-lowest) 0%,
|
||||
var(--md-sys-color-surface-container-low) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
+127
-136
@@ -5,7 +5,9 @@
|
||||
<!-- Logo and Brand -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<router-link to="/" class="flex items-center space-x-3 group">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<CheckCircleOutlined class="text-white text-xl" />
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
|
||||
@@ -13,19 +15,15 @@
|
||||
|
||||
<!-- Desktop Navigation Links -->
|
||||
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
v-slot="{ isActive }"
|
||||
custom
|
||||
>
|
||||
<router-link v-slot="{ isActive }" to="/dashboard" custom>
|
||||
<a
|
||||
@click="router.push('/dashboard')"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface hover:bg-surface-container'
|
||||
: 'text-on-surface hover:bg-surface-container',
|
||||
]"
|
||||
@click="router.push('/dashboard')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<HomeOutlined />
|
||||
@@ -34,19 +32,15 @@
|
||||
</a>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/tasks"
|
||||
v-slot="{ isActive }"
|
||||
custom
|
||||
>
|
||||
<router-link v-slot="{ isActive }" to="/tasks" custom>
|
||||
<a
|
||||
@click="router.push('/tasks')"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface hover:bg-surface-container'
|
||||
: 'text-on-surface hover:bg-surface-container',
|
||||
]"
|
||||
@click="router.push('/tasks')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FileTextOutlined />
|
||||
@@ -55,19 +49,15 @@
|
||||
</a>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/records"
|
||||
v-slot="{ isActive }"
|
||||
custom
|
||||
>
|
||||
<router-link v-slot="{ isActive }" to="/records" custom>
|
||||
<a
|
||||
@click="router.push('/records')"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
|
||||
isActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
: 'text-on-surface hover:bg-surface-container'
|
||||
: 'text-on-surface hover:bg-surface-container',
|
||||
]"
|
||||
@click="router.push('/records')"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<UnorderedListOutlined />
|
||||
@@ -81,7 +71,9 @@
|
||||
<a
|
||||
:class="[
|
||||
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
|
||||
isAdminPath ? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400' : 'text-on-surface hover:bg-surface-container'
|
||||
isAdminPath
|
||||
? 'bg-secondary-100 dark:bg-secondary-900/30 text-secondary-700 dark:text-secondary-400'
|
||||
: 'text-on-surface hover:bg-surface-container',
|
||||
]"
|
||||
>
|
||||
<SettingOutlined />
|
||||
@@ -155,11 +147,15 @@
|
||||
|
||||
<!-- Desktop User Menu -->
|
||||
<a-dropdown v-if="!isMobile" :trigger="['hover']">
|
||||
<a class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-surface-container transition-all cursor-pointer">
|
||||
<a
|
||||
class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-surface-container transition-all cursor-pointer"
|
||||
>
|
||||
<a-avatar :style="{ backgroundColor: '#f56a00' }">
|
||||
{{ userInitial }}
|
||||
</a-avatar>
|
||||
<span class="hidden md:block font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</span>
|
||||
<span class="hidden md:block font-medium text-on-surface">{{
|
||||
authStore.user?.alias || '用户'
|
||||
}}</span>
|
||||
<DownOutlined class="text-xs text-on-surface-variant" />
|
||||
</a>
|
||||
<template #overlay>
|
||||
@@ -167,7 +163,9 @@
|
||||
<a-menu-item key="info" disabled>
|
||||
<div class="px-2 py-1">
|
||||
<p class="text-sm font-medium text-on-surface">{{ authStore.user?.alias }}</p>
|
||||
<p class="text-xs text-on-surface-variant mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
|
||||
<p class="text-xs text-on-surface-variant mt-1">
|
||||
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
|
||||
</p>
|
||||
</div>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
@@ -175,7 +173,7 @@
|
||||
<SettingOutlined />
|
||||
<span class="ml-2">个人设置</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout" danger>
|
||||
<a-menu-item key="logout" danger @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
<span class="ml-2">退出登录</span>
|
||||
</a-menu-item>
|
||||
@@ -197,12 +195,7 @@
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Drawer -->
|
||||
<a-drawer
|
||||
v-model:open="drawerVisible"
|
||||
placement="left"
|
||||
:width="280"
|
||||
title="菜单"
|
||||
>
|
||||
<a-drawer v-model:open="drawerVisible" placement="left" :width="280" title="菜单">
|
||||
<!-- User Info in Drawer -->
|
||||
<div class="mb-6 pb-4 border-b border-outline-variant">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -211,17 +204,15 @@
|
||||
</a-avatar>
|
||||
<div>
|
||||
<p class="font-medium text-on-surface">{{ authStore.user?.alias || '用户' }}</p>
|
||||
<p class="text-xs text-on-surface-variant">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
|
||||
<p class="text-xs text-on-surface-variant">
|
||||
{{ authStore.isAdmin ? '管理员' : '普通用户' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<a-menu
|
||||
mode="inline"
|
||||
:selected-keys="[currentMenuKey]"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<a-menu mode="inline" :selected-keys="[currentMenuKey]" @click="handleMenuClick">
|
||||
<a-menu-item key="dashboard">
|
||||
<template #icon><HomeOutlined /></template>
|
||||
仪表盘
|
||||
@@ -285,15 +276,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import QRCodeModal from './QRCodeModal.vue'
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useTokenMonitor } from '@/composables/useTokenMonitor';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { useTheme } from '@/composables/useTheme';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import QRCodeModal from './QRCodeModal.vue';
|
||||
import {
|
||||
MenuOutlined,
|
||||
HomeOutlined,
|
||||
@@ -311,123 +302,123 @@ import {
|
||||
BulbOutlined,
|
||||
BulbFilled,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const { isMobile } = useBreakpoint()
|
||||
const { getRemainingMinutes, tokenStatus } = useTokenMonitor()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
const { getRemainingMinutes, tokenStatus } = useTokenMonitor();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const qrcodeModalVisible = ref(false)
|
||||
const drawerVisible = ref(false);
|
||||
const qrcodeModalVisible = ref(false);
|
||||
|
||||
const isAdminPath = computed(() => route.path.startsWith('/admin'))
|
||||
const isAdminPath = computed(() => route.path.startsWith('/admin'));
|
||||
|
||||
const userInitial = computed(() => {
|
||||
const name = authStore.user?.alias || 'U'
|
||||
return name.charAt(0).toUpperCase()
|
||||
})
|
||||
const name = authStore.user?.alias || 'U';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
// Token 状态计算
|
||||
const remainingMinutes = computed(() => {
|
||||
return getRemainingMinutes()
|
||||
})
|
||||
return getRemainingMinutes();
|
||||
});
|
||||
|
||||
const showTokenStatus = computed(() => {
|
||||
if (!authStore.isAuthenticated || !tokenStatus.value) return false
|
||||
if (!authStore.isAuthenticated || !tokenStatus.value) return false;
|
||||
|
||||
const mins = remainingMinutes.value
|
||||
const mins = remainingMinutes.value;
|
||||
// 显示条件:Token 即将过期(60分钟内)或已过期(5分钟内)
|
||||
if (mins === null) return false
|
||||
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5)
|
||||
})
|
||||
if (mins === null) return false;
|
||||
return mins <= 60 || (mins < 0 && Math.abs(mins) <= 5);
|
||||
});
|
||||
|
||||
const tokenBadgeStatus = computed(() => {
|
||||
const mins = remainingMinutes.value
|
||||
if (mins === null) return 'default'
|
||||
if (mins < 0) return 'error' // 已过期
|
||||
if (mins <= 10) return 'error' // 10分钟内过期
|
||||
if (mins <= 30) return 'warning' // 30分钟内过期
|
||||
return 'processing' // 正常但快过期
|
||||
})
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'default';
|
||||
if (mins < 0) return 'error'; // 已过期
|
||||
if (mins <= 10) return 'error'; // 10分钟内过期
|
||||
if (mins <= 30) return 'warning'; // 30分钟内过期
|
||||
return 'processing'; // 正常但快过期
|
||||
});
|
||||
|
||||
const tokenBadgeText = computed(() => {
|
||||
const mins = remainingMinutes.value
|
||||
if (mins === null) return ''
|
||||
if (mins < 0) return 'Token 已过期'
|
||||
if (mins < 60) return `Token 剩余:${mins}分钟`
|
||||
return ''
|
||||
})
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return '';
|
||||
if (mins < 0) return 'Token 已过期';
|
||||
if (mins < 60) return `Token 剩余:${mins}分钟`;
|
||||
return '';
|
||||
});
|
||||
|
||||
const tokenIconClass = computed(() => {
|
||||
const mins = remainingMinutes.value
|
||||
if (mins === null) return 'text-on-surface-variant'
|
||||
if (mins < 0) return 'text-red-500 dark:text-red-400' // 已过期
|
||||
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse' // 10分钟内,闪烁
|
||||
if (mins <= 30) return 'text-orange-500 dark:text-orange-400' // 30分钟内
|
||||
return 'text-blue-500 dark:text-blue-400' // 正常
|
||||
})
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'text-on-surface-variant';
|
||||
if (mins < 0) return 'text-red-500 dark:text-red-400'; // 已过期
|
||||
if (mins <= 10) return 'text-red-500 dark:text-red-400 animate-pulse'; // 10分钟内,闪烁
|
||||
if (mins <= 30) return 'text-orange-500 dark:text-orange-400'; // 30分钟内
|
||||
return 'text-blue-500 dark:text-blue-400'; // 正常
|
||||
});
|
||||
|
||||
const tokenStatusTooltip = computed(() => {
|
||||
const mins = remainingMinutes.value
|
||||
if (mins === null) return 'Token 状态未知'
|
||||
const mins = remainingMinutes.value;
|
||||
if (mins === null) return 'Token 状态未知';
|
||||
if (mins < 0) {
|
||||
const expiredMins = Math.abs(mins)
|
||||
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`
|
||||
const expiredMins = Math.abs(mins);
|
||||
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`;
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`
|
||||
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`;
|
||||
}
|
||||
return 'Token 状态正常'
|
||||
})
|
||||
return 'Token 状态正常';
|
||||
});
|
||||
|
||||
const handleTokenStatusClick = () => {
|
||||
const mins = remainingMinutes.value
|
||||
const mins = remainingMinutes.value;
|
||||
|
||||
// Token 已过期时提醒刷新
|
||||
if (mins !== null && mins < 0) {
|
||||
message.info('Token 已过期,请进行刷新')
|
||||
message.info('Token 已过期,请进行刷新');
|
||||
}
|
||||
// Token 未过期时,点击无效果
|
||||
}
|
||||
};
|
||||
|
||||
const currentMenuKey = computed(() => {
|
||||
const path = route.path
|
||||
if (path.startsWith('/admin/users')) return 'admin-users'
|
||||
if (path.startsWith('/admin/templates')) return 'admin-templates'
|
||||
if (path.startsWith('/admin/records')) return 'admin-records'
|
||||
if (path.startsWith('/admin/stats')) return 'admin-stats'
|
||||
if (path.startsWith('/admin/logs')) return 'admin-logs'
|
||||
if (path.startsWith('/dashboard')) return 'dashboard'
|
||||
if (path.startsWith('/tasks')) return 'tasks'
|
||||
if (path.startsWith('/records')) return 'records'
|
||||
if (path.startsWith('/settings')) return 'settings'
|
||||
return ''
|
||||
})
|
||||
const path = route.path;
|
||||
if (path.startsWith('/admin/users')) return 'admin-users';
|
||||
if (path.startsWith('/admin/templates')) return 'admin-templates';
|
||||
if (path.startsWith('/admin/records')) return 'admin-records';
|
||||
if (path.startsWith('/admin/stats')) return 'admin-stats';
|
||||
if (path.startsWith('/admin/logs')) return 'admin-logs';
|
||||
if (path.startsWith('/dashboard')) return 'dashboard';
|
||||
if (path.startsWith('/tasks')) return 'tasks';
|
||||
if (path.startsWith('/records')) return 'records';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
return '';
|
||||
});
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const routes = {
|
||||
'dashboard': '/dashboard',
|
||||
'tasks': '/tasks',
|
||||
'records': '/records',
|
||||
dashboard: '/dashboard',
|
||||
tasks: '/tasks',
|
||||
records: '/records',
|
||||
'admin-users': '/admin/users',
|
||||
'admin-templates': '/admin/templates',
|
||||
'admin-records': '/admin/records',
|
||||
'admin-stats': '/admin/stats',
|
||||
'admin-logs': '/admin/logs',
|
||||
'settings': '/settings',
|
||||
}
|
||||
settings: '/settings',
|
||||
};
|
||||
|
||||
if (key === 'logout') {
|
||||
handleLogout()
|
||||
handleLogout();
|
||||
} else if (routes[key]) {
|
||||
router.push(routes[key])
|
||||
drawerVisible.value = false
|
||||
router.push(routes[key]);
|
||||
drawerVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
@@ -436,36 +427,36 @@ const handleLogout = () => {
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
drawerVisible.value = false
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
drawerVisible.value = false;
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理 Token 刷新
|
||||
const handleRefreshToken = () => {
|
||||
qrcodeModalVisible.value = true
|
||||
}
|
||||
qrcodeModalVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理 QR 码扫码成功
|
||||
const handleQRCodeSuccess = async () => {
|
||||
message.success('Token 刷新成功')
|
||||
qrcodeModalVisible.value = false
|
||||
message.success('Token 刷新成功');
|
||||
qrcodeModalVisible.value = false;
|
||||
|
||||
// 刷新用户信息和 Token 状态
|
||||
try {
|
||||
await authStore.fetchCurrentUser()
|
||||
await userStore.fetchTokenStatus()
|
||||
await authStore.fetchCurrentUser();
|
||||
await userStore.fetchTokenStatus();
|
||||
} catch (error) {
|
||||
console.error('刷新用户信息失败:', error)
|
||||
console.error('刷新用户信息失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 QR 码扫码失败
|
||||
const handleQRCodeError = (error) => {
|
||||
message.error(error?.message || 'Token 刷新失败')
|
||||
}
|
||||
const handleQRCodeError = error => {
|
||||
message.error(error?.message || 'Token 刷新失败');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
title="QQ 扫码登录"
|
||||
:width="isMobile ? '100%' : 400"
|
||||
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
|
||||
:maskClosable="false"
|
||||
@cancel="handleClose"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="qrcode-container">
|
||||
<!-- 加载中 -->
|
||||
@@ -33,30 +33,26 @@
|
||||
<div v-else-if="status === 'expired'" class="status-container">
|
||||
<WarningFilled class="status-icon warning-icon" />
|
||||
<p class="status-text">二维码已过期</p>
|
||||
<a-button type="primary" @click="refreshQRCode" class="mt-4">刷新二维码</a-button>
|
||||
<a-button type="primary" class="mt-4" @click="refreshQRCode">刷新二维码</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 失败 -->
|
||||
<div v-else-if="status === 'failed'" class="status-container">
|
||||
<CloseCircleFilled class="status-icon error-icon" />
|
||||
<p class="status-text error">{{ errorMessage }}</p>
|
||||
<a-button type="primary" @click="refreshQRCode" class="mt-4">重试</a-button>
|
||||
<a-button type="primary" class="mt-4" @click="refreshQRCode">重试</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { usePollStatus } from '@/composables/usePollStatus'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
WarningFilled,
|
||||
CloseCircleFilled,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint';
|
||||
import { usePollStatus } from '@/composables/usePollStatus';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CheckCircleFilled, WarningFilled, CloseCircleFilled } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -67,161 +63,162 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success', 'error'])
|
||||
const emit = defineEmits(['update:visible', 'success', 'error']);
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isMobile } = useBreakpoint()
|
||||
const authStore = useAuthStore();
|
||||
const { isMobile } = useBreakpoint();
|
||||
|
||||
// 使用轮询 composable
|
||||
const { startPolling: startQRPolling, stopPolling } = usePollStatus({
|
||||
interval: 2000,
|
||||
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
|
||||
backoff: false
|
||||
})
|
||||
maxRetries: 90, // 3分钟 = 180秒 / 2秒间隔 = 90次
|
||||
backoff: false,
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val),
|
||||
})
|
||||
set: val => emit('update:visible', val),
|
||||
});
|
||||
|
||||
const status = ref('loading') // loading, pending, success, expired, failed
|
||||
const qrcodeUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const countdown = ref(180) // 倒计时 3 分钟
|
||||
const progress = ref(100)
|
||||
const status = ref('loading'); // loading, pending, success, expired, failed
|
||||
const qrcodeUrl = ref('');
|
||||
const sessionId = ref('');
|
||||
const errorMessage = ref('');
|
||||
const countdown = ref(180); // 倒计时 3 分钟
|
||||
const progress = ref(100);
|
||||
|
||||
let countdownTimer = null
|
||||
let countdownTimer = null;
|
||||
|
||||
// 获取二维码
|
||||
const fetchQRCode = async () => {
|
||||
status.value = 'loading'
|
||||
status.value = 'loading';
|
||||
try {
|
||||
const result = await authStore.loginWithQRCode(props.alias)
|
||||
sessionId.value = result.session_id
|
||||
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`
|
||||
status.value = 'pending'
|
||||
const result = await authStore.loginWithQRCode(props.alias);
|
||||
sessionId.value = result.session_id;
|
||||
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`;
|
||||
status.value = 'pending';
|
||||
|
||||
// 开始轮询扫码状态(使用 composable)
|
||||
startQRPolling(
|
||||
async () => {
|
||||
const result = await authStore.checkQRCodeStatus(sessionId.value)
|
||||
const result = await authStore.checkQRCodeStatus(sessionId.value);
|
||||
|
||||
// 检查是否完成(成功、过期或失败)
|
||||
const completed = result.status === 'expired' || result.status === 'failed' || result.success
|
||||
const completed =
|
||||
result.status === 'expired' || result.status === 'failed' || result.success;
|
||||
|
||||
return {
|
||||
completed,
|
||||
success: result.success === true,
|
||||
data: result
|
||||
}
|
||||
data: result,
|
||||
};
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
status.value = 'success'
|
||||
stopCountdown()
|
||||
message.success('登录成功!')
|
||||
onSuccess: result => {
|
||||
status.value = 'success';
|
||||
stopCountdown();
|
||||
message.success('登录成功!');
|
||||
|
||||
// 延迟关闭对话框
|
||||
setTimeout(() => {
|
||||
emit('success', result.user)
|
||||
handleClose()
|
||||
}, 1500)
|
||||
emit('success', result.user);
|
||||
handleClose();
|
||||
}, 1500);
|
||||
},
|
||||
onFailure: (result) => {
|
||||
onFailure: result => {
|
||||
if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
status.value = 'expired';
|
||||
} else {
|
||||
status.value = 'failed'
|
||||
errorMessage.value = result.message || '扫码失败'
|
||||
status.value = 'failed';
|
||||
errorMessage.value = result.message || '扫码失败';
|
||||
}
|
||||
stopCountdown()
|
||||
stopCountdown();
|
||||
},
|
||||
onTimeout: () => {
|
||||
status.value = 'expired'
|
||||
stopCountdown()
|
||||
}
|
||||
status.value = 'expired';
|
||||
stopCountdown();
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
startCountdown()
|
||||
startCountdown();
|
||||
} catch (error) {
|
||||
status.value = 'failed'
|
||||
errorMessage.value = error.message || '获取二维码失败'
|
||||
emit('error', error)
|
||||
status.value = 'failed';
|
||||
errorMessage.value = error.message || '获取二维码失败';
|
||||
emit('error', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 180
|
||||
countdown.value = 180;
|
||||
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
clearInterval(countdownTimer);
|
||||
}
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
progress.value = (countdown.value / 180) * 100
|
||||
countdown.value--;
|
||||
progress.value = (countdown.value / 180) * 100;
|
||||
|
||||
if (countdown.value <= 0) {
|
||||
status.value = 'expired'
|
||||
stopPolling() // 停止轮询
|
||||
stopCountdown()
|
||||
status.value = 'expired';
|
||||
stopPolling(); // 停止轮询
|
||||
stopCountdown();
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 停止倒计时
|
||||
const stopCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新二维码
|
||||
const refreshQRCode = () => {
|
||||
fetchQRCode()
|
||||
}
|
||||
fetchQRCode();
|
||||
};
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
stopPolling() // 停止轮询
|
||||
stopCountdown()
|
||||
stopPolling(); // 停止轮询
|
||||
stopCountdown();
|
||||
|
||||
// 如果有未完成的会话,取消它
|
||||
if (sessionId.value && status.value !== 'success') {
|
||||
try {
|
||||
authStore.cancelQRCodeSession(sessionId.value)
|
||||
authStore.cancelQRCodeSession(sessionId.value);
|
||||
} catch (error) {
|
||||
console.error('取消会话失败:', error)
|
||||
console.error('取消会话失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
visible => {
|
||||
if (visible) {
|
||||
fetchQRCode()
|
||||
fetchQRCode();
|
||||
} else {
|
||||
stopPolling()
|
||||
stopCountdown()
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 组件卸载时清理定时器,防止内存泄漏
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
stopCountdown()
|
||||
})
|
||||
stopPolling();
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<a-card class="md3-card text-center" style="padding: 48px 20px;">
|
||||
<a-card class="md3-card text-center" style="padding: 48px 20px">
|
||||
<!-- 图标 -->
|
||||
<div v-if="icon" class="mb-6">
|
||||
<component
|
||||
:is="icon"
|
||||
class="text-8xl mx-auto"
|
||||
:class="iconColorClass"
|
||||
/>
|
||||
<component :is="icon" class="text-8xl mx-auto" :class="iconColorClass" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
@@ -22,12 +18,7 @@
|
||||
<!-- 操作按钮(可选) -->
|
||||
<div v-if="$slots.action || actionText">
|
||||
<slot name="action">
|
||||
<a-button
|
||||
v-if="actionText"
|
||||
type="primary"
|
||||
@click="handleAction"
|
||||
:loading="loading"
|
||||
>
|
||||
<a-button v-if="actionText" type="primary" :loading="loading" @click="handleAction">
|
||||
<template v-if="actionIcon" #icon>
|
||||
<component :is="actionIcon" />
|
||||
</template>
|
||||
@@ -39,7 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
@@ -47,7 +38,7 @@ const props = defineProps({
|
||||
*/
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -55,7 +46,7 @@ const props = defineProps({
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -63,7 +54,7 @@ const props = defineProps({
|
||||
*/
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -71,7 +62,7 @@ const props = defineProps({
|
||||
*/
|
||||
actionText: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -79,7 +70,7 @@ const props = defineProps({
|
||||
*/
|
||||
actionIcon: {
|
||||
type: Object,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -87,7 +78,7 @@ const props = defineProps({
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -96,15 +87,15 @@ const props = defineProps({
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'neutral',
|
||||
validator: (v) => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v)
|
||||
}
|
||||
})
|
||||
validator: v => ['primary', 'neutral', 'success', 'warning', 'error'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const handleAction = () => {
|
||||
emit('action')
|
||||
}
|
||||
emit('action');
|
||||
};
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
const colors = {
|
||||
@@ -112,8 +103,8 @@ const iconColorClass = computed(() => {
|
||||
neutral: 'text-on-surface-variant',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-orange-500',
|
||||
error: 'text-error'
|
||||
}
|
||||
return colors[props.iconColor]
|
||||
})
|
||||
error: 'text-error',
|
||||
};
|
||||
return colors[props.iconColor];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,61 +2,38 @@
|
||||
<div v-if="loading" class="loading-state">
|
||||
<!-- 卡片骨架屏 -->
|
||||
<div v-if="type === 'card'" class="grid grid-cols-1 gap-4">
|
||||
<a-card
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="md3-card"
|
||||
>
|
||||
<a-skeleton
|
||||
:active="true"
|
||||
:paragraph="{ rows: paragraphRows }"
|
||||
:avatar="showAvatar"
|
||||
/>
|
||||
<a-card v-for="i in count" :key="i" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 列表骨架屏 -->
|
||||
<div v-else-if="type === 'list'" class="space-y-4">
|
||||
<a-card
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="md3-card"
|
||||
>
|
||||
<a-skeleton
|
||||
:active="true"
|
||||
:paragraph="{ rows: 1 }"
|
||||
:avatar="showAvatar"
|
||||
/>
|
||||
<a-card v-for="i in count" :key="i" class="md3-card">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 1 }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 表格骨架屏 -->
|
||||
<a-card v-else-if="type === 'table'" class="md3-card">
|
||||
<a-skeleton
|
||||
:active="true"
|
||||
:paragraph="{ rows: count * 2 }"
|
||||
/>
|
||||
<a-skeleton :active="true" :paragraph="{ rows: count * 2 }" />
|
||||
</a-card>
|
||||
|
||||
<!-- 默认骨架屏 -->
|
||||
<a-card v-else class="md3-card">
|
||||
<a-skeleton
|
||||
:active="true"
|
||||
:paragraph="{ rows: paragraphRows }"
|
||||
:avatar="showAvatar"
|
||||
/>
|
||||
<a-skeleton :active="true" :paragraph="{ rows: paragraphRows }" :avatar="showAvatar" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
/**
|
||||
* 是否显示加载状态
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -65,7 +42,7 @@ const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'card',
|
||||
validator: (v) => ['card', 'list', 'table', 'default'].includes(v)
|
||||
validator: v => ['card', 'list', 'table', 'default'].includes(v),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -73,7 +50,7 @@ const props = defineProps({
|
||||
*/
|
||||
count: {
|
||||
type: Number,
|
||||
default: 3
|
||||
default: 3,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -81,7 +58,7 @@ const props = defineProps({
|
||||
*/
|
||||
paragraphRows: {
|
||||
type: Number,
|
||||
default: 4
|
||||
default: 4,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -89,9 +66,9 @@ const props = defineProps({
|
||||
*/
|
||||
showAvatar: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -100,7 +77,11 @@ const props = defineProps({
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,11 +28,7 @@
|
||||
<!-- 趋势指示器(可选) -->
|
||||
<div v-if="trend !== undefined" class="mt-3 pt-3 border-t border-outline-variant">
|
||||
<div class="flex items-center text-sm">
|
||||
<component
|
||||
:is="trendIcon"
|
||||
:class="trendColorClass"
|
||||
class="mr-1"
|
||||
/>
|
||||
<component :is="trendIcon" :class="trendColorClass" class="mr-1" />
|
||||
<span :class="trendColorClass" class="md3-label-small">
|
||||
{{ trendText }}
|
||||
</span>
|
||||
@@ -42,12 +38,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
MinusOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { computed } from 'vue';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
@@ -55,7 +47,7 @@ const props = defineProps({
|
||||
*/
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -63,7 +55,7 @@ const props = defineProps({
|
||||
*/
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -71,7 +63,7 @@ const props = defineProps({
|
||||
*/
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -79,7 +71,7 @@ const props = defineProps({
|
||||
*/
|
||||
icon: {
|
||||
type: Object,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -88,7 +80,7 @@ const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: (v) => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v)
|
||||
validator: v => ['primary', 'success', 'warning', 'error', 'info', 'neutral'].includes(v),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -96,7 +88,7 @@ const props = defineProps({
|
||||
*/
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 0
|
||||
default: 0,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -104,7 +96,7 @@ const props = defineProps({
|
||||
*/
|
||||
formatter: {
|
||||
type: Function,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -112,7 +104,7 @@ const props = defineProps({
|
||||
*/
|
||||
trend: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -120,73 +112,73 @@ const props = defineProps({
|
||||
*/
|
||||
trendText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
// 动画延迟
|
||||
const animationDelay = computed(() => `${props.delay}s`)
|
||||
const animationDelay = computed(() => `${props.delay}s`);
|
||||
|
||||
// 格式化数值
|
||||
const formattedValue = computed(() => {
|
||||
if (props.formatter) {
|
||||
return props.formatter(props.value)
|
||||
return props.formatter(props.value);
|
||||
}
|
||||
return props.value
|
||||
})
|
||||
return props.value;
|
||||
});
|
||||
|
||||
// 颜色映射
|
||||
const colorClasses = {
|
||||
primary: {
|
||||
value: 'text-primary',
|
||||
iconBg: 'bg-primary-100 dark:bg-primary-900/30',
|
||||
icon: 'text-primary'
|
||||
icon: 'text-primary',
|
||||
},
|
||||
success: {
|
||||
value: 'text-green-600 dark:text-green-400',
|
||||
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
||||
icon: 'text-green-600 dark:text-green-400'
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
},
|
||||
warning: {
|
||||
value: 'text-orange-600 dark:text-orange-400',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
icon: 'text-orange-600 dark:text-orange-400'
|
||||
icon: 'text-orange-600 dark:text-orange-400',
|
||||
},
|
||||
error: {
|
||||
value: 'text-error',
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
icon: 'text-error'
|
||||
icon: 'text-error',
|
||||
},
|
||||
info: {
|
||||
value: 'text-secondary',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
icon: 'text-secondary'
|
||||
icon: 'text-secondary',
|
||||
},
|
||||
neutral: {
|
||||
value: 'text-on-surface',
|
||||
iconBg: 'bg-surface-container',
|
||||
icon: 'text-on-surface-variant'
|
||||
}
|
||||
}
|
||||
icon: 'text-on-surface-variant',
|
||||
},
|
||||
};
|
||||
|
||||
const valueColorClass = computed(() => colorClasses[props.color].value)
|
||||
const iconBgClass = computed(() => colorClasses[props.color].iconBg)
|
||||
const iconColorClass = computed(() => colorClasses[props.color].icon)
|
||||
const valueColorClass = computed(() => colorClasses[props.color].value);
|
||||
const iconBgClass = computed(() => colorClasses[props.color].iconBg);
|
||||
const iconColorClass = computed(() => colorClasses[props.color].icon);
|
||||
|
||||
// 趋势图标和颜色
|
||||
const trendIcon = computed(() => {
|
||||
if (props.trend === undefined) return null
|
||||
if (props.trend > 0) return ArrowUpOutlined
|
||||
if (props.trend < 0) return ArrowDownOutlined
|
||||
return MinusOutlined
|
||||
})
|
||||
if (props.trend === undefined) return null;
|
||||
if (props.trend > 0) return ArrowUpOutlined;
|
||||
if (props.trend < 0) return ArrowDownOutlined;
|
||||
return MinusOutlined;
|
||||
});
|
||||
|
||||
const trendColorClass = computed(() => {
|
||||
if (props.trend === undefined) return ''
|
||||
if (props.trend > 0) return 'text-green-600 dark:text-green-400'
|
||||
if (props.trend < 0) return 'text-red-600 dark:text-red-400'
|
||||
return 'text-on-surface-variant'
|
||||
})
|
||||
if (props.trend === undefined) return '';
|
||||
if (props.trend > 0) return 'text-green-600 dark:text-green-400';
|
||||
if (props.trend < 0) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-on-surface-variant';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user