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:
2026-01-03 19:01:15 +08:00
parent 523da50123
commit 5cdc8b2144
57 changed files with 4623 additions and 2754 deletions
+129 -118
View File
@@ -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 {
+65 -73
View File
@@ -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>
+283 -150
View File
@@ -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>
+11 -7
View File
@@ -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
View File
@@ -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>
+85 -88
View File
@@ -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>
+20 -29
View File
@@ -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>
+20 -39
View File
@@ -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>
+39 -47
View File
@@ -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>