feat: migrate from Element Plus to Ant Design Vue and update Vite configuration for better dependency management

- Updated Vite configuration to manually chunk Ant Design Vue for improved dependency management.
- Added a comprehensive migration testing checklist for transitioning from Element Plus 2.13.0 to Ant Design Vue 4.x, covering various components and functionalities.
This commit is contained in:
2026-01-03 01:38:38 +08:00
parent 42a1046750
commit 827c9198ae
57 changed files with 5517 additions and 2982 deletions
+159 -34
View File
@@ -17,45 +17,51 @@
<!-- 快速模式仅日期 20:00 -->
<div v-if="mode === 'quick'" class="mode-content">
<div class="quick-option">
<el-radio v-model="selectedQuick" label="20:00">
<span class="option-label">每天 20:00默认</span>
<span class="option-desc">推荐的默认时间</span>
</el-radio>
<a-radio-group v-model:value="selectedQuick">
<a-radio value="20:00">
<span class="option-label">每天 20:00默认</span>
<span class="option-desc">推荐的默认时间</span>
</a-radio>
</a-radio-group>
</div>
</div>
<!-- 自定义模式可视化构建器 -->
<!-- 自定义模式:可视化构建器 -->
<div v-if="mode === 'custom'" class="mode-content">
<el-form label-width="120px">
<el-form-item label="时间">
<el-time-select
v-model="customTime"
:start="'00:00'"
:end="'23:30'"
step="00:30"
<a-form layout="vertical">
<a-form-item label="时间" name="customTime">
<a-time-picker
id="cron-custom-time"
v-model:value="customTimeValue"
format="HH:mm"
placeholder="选择时间"
:minute-step="30"
@change="onCustomTimeChange"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="频率">
<el-select v-model="customFrequency">
<el-option label="每天" value="daily" />
<el-option label="工作日(周一-周五)" value="weekday" />
<el-option label="周末(周六-周日)" value="weekend" />
</el-select>
</el-form-item>
</el-form>
</a-form-item>
<a-form-item label="频率" name="customFrequency">
<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>
</a-select>
</a-form-item>
</a-form>
</div>
<!-- 高级模式原始 Crontab 表达式 -->
<div v-if="mode === 'advanced'" class="mode-content">
<div class="expression-input">
<el-input
v-model="advancedExpression"
type="textarea"
<a-textarea
v-model:value="advancedExpression"
placeholder="输入 crontab 表达式(例如:0 20 * * *"
:rows="2"
@input="validateExpression"
@input="handleAdvancedInput"
/>
<div class="help-text">
格式: 分钟 小时 日期 月份 星期
@@ -80,7 +86,8 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, watch, onBeforeUnmount } from 'vue'
import dayjs from 'dayjs'
import client from '@/api/client'
const props = defineProps({
@@ -102,6 +109,7 @@ const selectedQuick = ref('20:00')
// 自定义模式
const customTime = ref('20:00')
const customTimeValue = ref(dayjs('20:00', 'HH:mm'))
const customFrequency = ref('daily')
// 高级模式
@@ -112,26 +120,62 @@ const validationStatus = ref('')
// 通用
const nextExecutions = ref([])
// 标志:是否正在手动编辑高级模式(防止自动解析导致模式切换)
let isManualEditing = false
// 切换模式 - 防止页面刷新
function switchMode(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)
}
// 切换到自定义模式时,基于当前值构建 cron
else if (newMode === 'custom') {
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)
}
}
}
// 处理时间选择器变化
function onCustomTimeChange(time) {
if (time) {
customTime.value = time.format('HH:mm')
}
}
// 监听 - 只在有效值时更新
watch(selectedQuick, () => {
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)
})
watch(customTime, () => {
const cron = buildCrontabFromCustom()
advancedExpression.value = cron
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
@@ -157,14 +201,29 @@ function buildCrontabFromCustom() {
return `${minute} ${hour} * * ${dow}`
}
async function validateExpression() {
if (!advancedExpression.value.trim()) {
validationMessage.value = ''
nextExecutions.value = []
return
// 处理高级模式输入 - 使用防抖以避免频繁调用API
let debounceTimer = null
function handleAdvancedInput() {
// 设置手动编辑标志
isManualEditing = true
// 立即触发 emit,保证值实时同步
emit('update:modelValue', advancedExpression.value)
// 使用防抖延迟验证
if (debounceTimer) {
clearTimeout(debounceTimer)
}
await validateAndPreview(advancedExpression.value)
debounceTimer = setTimeout(async () => {
if (!advancedExpression.value.trim()) {
validationMessage.value = ''
nextExecutions.value = []
return
}
await validateAndPreview(advancedExpression.value)
}, 500) // 500ms 防抖延迟
}
async function validateAndPreview(expr) {
@@ -191,12 +250,78 @@ async function validateAndPreview(expr) {
}
}
// 初始化
// 解析 cron 表达式并设置对应的模式
function parseCronExpression(cron) {
if (!cron) return
advancedExpression.value = cron
// 尝试匹配快速模式: 0 20 * * *
if (cron === '0 20 * * *') {
mode.value = 'quick'
selectedQuick.value = '20:00'
validateAndPreview(cron)
return
}
// 尝试解析为自定义模式
const parts = cron.trim().split(/\s+/)
if (parts.length === 5) {
const [minute, hour, day, month, dow] = parts
// 检查是否是简单的每天或工作日/周末模式
if (day === '*' && month === '*') {
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 (dow === '*') {
customFrequency.value = 'daily'
} else if (dow === '1-5') {
customFrequency.value = 'weekday'
} else if (dow === '0,6' || dow === '6,0') {
customFrequency.value = 'weekend'
} else {
// 不支持的星期模式,使用高级模式
mode.value = 'advanced'
}
validateAndPreview(cron)
return
}
}
}
// 其他情况使用高级模式
mode.value = 'advanced'
validateAndPreview(cron)
}
// 初始化 - 解析传入的 cron 表达式
watch(() => props.modelValue, (newVal) => {
// 如果正在手动编辑高级模式,跳过自动解析
if (isManualEditing) {
isManualEditing = false // 重置标志
return
}
if (newVal) {
advancedExpression.value = newVal
parseCronExpression(newVal)
}
}, { immediate: true })
// 组件卸载时清理防抖定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
+81 -80
View File
@@ -2,99 +2,98 @@
<div class="field-config-editor space-y-4">
<!-- Row 1: Display Name and Field Type -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="显示名称" class="mb-0">
<el-input
:model-value="modelValue.display_name"
@update:model-value="updateField('display_name', $event)"
<a-form-item label="显示名称" class="mb-0">
<a-input
:value="modelValue.display_name"
@change="e => updateField('display_name', e.target.value)"
placeholder="在表单中显示的名称"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">显示名称</span>
</el-form-item>
</a-form-item>
<el-form-item label="字段类型" class="mb-0">
<el-select
:model-value="modelValue.field_type"
@update:model-value="handleFieldTypeChange"
<a-form-item label="字段类型" class="mb-0">
<a-select
:value="modelValue.field_type"
@change="handleFieldTypeChange"
placeholder="选择输入控件类型"
class="w-full"
>
<el-option label="📝 单行文本" value="text" />
<el-option label="📄 多行文本" value="textarea" />
<el-option label="🔢 数字输入" value="number" />
<el-option label="📋 下拉选择" value="select" />
</el-select>
<a-select-option label="📝 单行文本" value="text" />
<a-select-option label="📄 多行文本" value="textarea" />
<a-select-option label="🔢 数字输入" value="number" />
<a-select-option label="📋 下拉选择" value="select" />
</a-select>
<span class="text-xs text-gray-500 mt-1">用户填写时使用的输入控件</span>
</el-form-item>
</a-form-item>
</div>
<!-- Row 2: Value Type and Default Value -->
<div class="grid grid-cols-2 gap-4">
<el-form-item label="值类型" class="mb-0">
<el-select
:model-value="modelValue.value_type"
@update:model-value="updateField('value_type', $event)"
<a-form-item label="值类型" class="mb-0">
<a-select
:value="modelValue.value_type"
@change="value => updateField('value_type', value)"
placeholder="选择数据类型"
class="w-full"
>
<el-option label="字符串 (string)" value="string">
<a-select-option label="字符串 (string)" value="string">
<span class="text-xs text-gray-500">字符串 (string)</span>
</el-option>
<el-option label="整数 (int)" value="int">
</a-select-option>
<a-select-option label="整数 (int)" value="int">
<span class="text-xs text-gray-500">整数 (int)</span>
</el-option>
<el-option label="浮点数 (double)" value="double">
</a-select-option>
<a-select-option label="浮点数 (double)" value="double">
<span class="text-xs text-gray-500">浮点数 (double)</span>
</el-option>
<el-option label="布尔值 (bool)" value="bool">
</a-select-option>
<a-select-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-gray-500">布尔值 (bool)</span>
</el-option>
<el-option label="JSON对象 (json)" value="json">
</a-select-option>
<a-select-option label="JSON对象 (json)" value="json">
<span class="text-xs text-gray-500">JSON对象 (json) - 用于Values字段</span>
</el-option>
</el-select>
</a-select-option>
</a-select>
<span class="text-xs text-gray-500 mt-1">数据存储时的类型</span>
</el-form-item>
</a-form-item>
<el-form-item label="默认值" class="mb-0">
<el-input
<a-form-item label="默认值" class="mb-0">
<a-input
v-if="modelValue.value_type !== 'json'"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
clearable
allow-clear
/>
<el-input
<a-textarea
v-else
type="textarea"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
:value="modelValue.default_value"
@change="e => updateField('default_value', e.target.value)"
placeholder="字段的默认值"
:rows="3"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">
<template v-if="modelValue.value_type === 'json'">
<p>输入JSON对象会自动序列化为字符串</p>
<p>{"key1":value1,"key2":value2}</p>
<p>输入JSON对象,会自动序列化为字符串</p>
<p>:{"key1":value1,"key2":value2}</p>
</template>
<template v-else>
用户未填写时使用此值
</template>
</span>
</el-form-item>
</a-form-item>
</div>
<!-- Row 3: Placeholder -->
<el-form-item label="占位符提示" class="mb-0">
<el-input
:model-value="modelValue.placeholder"
@update:model-value="updateField('placeholder', $event)"
<a-form-item label="占位符提示" class="mb-0">
<a-input
:value="modelValue.placeholder"
@change="e => updateField('placeholder', e.target.value)"
placeholder="输入框的灰色提示文本"
clearable
allow-clear
/>
<span class="text-xs text-gray-500 mt-1">占位符</span>
</el-form-item>
</a-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-blue-50 rounded-lg">
@@ -103,9 +102,9 @@
<label class="text-sm font-medium text-gray-700">是否必填</label>
<p class="text-xs text-gray-500">用户必须填写此字段</p>
</div>
<el-switch
:model-value="modelValue.required"
@update:model-value="handleRequiredChange"
<a-switch
:checked="modelValue.required"
@change="handleRequiredChange"
:disabled="modelValue.hidden"
/>
</div>
@@ -115,28 +114,30 @@
<label class="text-sm font-medium text-gray-700">是否隐藏</label>
<p class="text-xs text-gray-500">直接使用默认值不在表单中显示</p>
</div>
<el-switch
:model-value="modelValue.hidden"
@update:model-value="handleHiddenChange"
<a-switch
:checked="modelValue.hidden"
@change="handleHiddenChange"
/>
</div>
</div>
<el-alert
<a-alert
v-if="modelValue.hidden"
title="💡 提示"
message="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</el-alert>
<template #description>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</template>
</a-alert>
<!-- Options for select type -->
<div v-if="modelValue.field_type === 'select'" class="border-t pt-4 mt-4">
<el-form-item label="选项列表" class="mb-0">
<a-form-item label="选项列表" class="mb-0">
<div class="space-y-2">
<div
v-for="(option, index) in modelValue.options || []"
@@ -144,48 +145,48 @@
class="flex items-center gap-2 p-2 bg-gray-50 rounded"
>
<span class="text-xs text-gray-500 w-8">{{ index + 1 }}.</span>
<el-input
:model-value="option.label"
@update:model-value="updateOption(index, 'label', $event)"
<a-input
:value="option.label"
@change="e => updateOption(index, 'label', e.target.value)"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
/>
<el-input
:model-value="option.value"
@update:model-value="updateOption(index, 'value', $event)"
<a-input
:value="option.value"
@change="e => updateOption(index, 'value', e.target.value)"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
/>
<el-button
<a-button
size="small"
type="danger"
:icon="Delete"
danger
@click="removeOption(index)"
circle
/>
>
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<el-button size="small" type="primary" plain @click="addOption" class="w-full">
<a-button size="small" type="primary" @click="addOption" class="w-full">
<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" />
</svg>
添加选项
</el-button>
</a-button>
<p class="text-xs text-gray-500 mt-2">
💡 提示显示文本是用户看到的内容选项值是实际保存的数据
</p>
</div>
</el-form-item>
</a-form-item>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { Delete } from '@element-plus/icons-vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
const props = defineProps({
modelValue: {
@@ -287,7 +288,7 @@ const removeOption = (index) => {
border: 1px solid #e5e7eb;
}
:deep(.el-form-item__label) {
:deep(.ant-form-item-label) {
font-weight: 500;
color: #374151;
}
+55 -55
View File
@@ -23,25 +23,25 @@
<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-blue-700">{{ fieldKey }}</span>
<el-tag type="primary" size="small">普通字段</el-tag>
<a-tag type="primary" size="small">普通字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<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" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<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" />
</svg>
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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" />
</svg>
删除
</el-button>
</a-button>
</div>
</div>
@@ -73,38 +73,38 @@
<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-purple-700">{{ fieldKey }}</span>
<el-tag type="warning" size="small">数组字段</el-tag>
<a-tag type="warning" size="small">数组字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<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" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<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" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addArrayItem">
</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" />
</svg>
添加元素
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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" />
</svg>
删除
</el-button>
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="localFieldConfig.length === 0" class="text-center py-6 bg-purple-50 rounded-lg border border-dashed border-purple-300">
<p class="text-sm text-gray-500 mb-2">数组为空</p>
<el-button size="small" type="primary" @click="addArrayItem">添加第一个元素</el-button>
<a-button size="small" type="primary" @click="addArrayItem">添加第一个元素</a-button>
</div>
<div v-else class="space-y-3 mt-3">
@@ -115,9 +115,9 @@
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-purple-700">元素 #{{ index + 1 }}</span>
<el-button size="small" type="danger" plain @click="removeArrayItem(index)">
<a-button size="small" type="danger" plain @click="removeArrayItem(index)">
删除元素
</el-button>
</a-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
@@ -138,12 +138,12 @@
@move="$emit('move', $event)"
/>
<el-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" />
</svg>
添加字段
</el-button>
</a-button>
</div>
<!-- 如果数组元素是数组递归渲染 -->
@@ -185,38 +185,38 @@
<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-green-700">{{ fieldKey }}</span>
<el-tag type="success" size="small">对象字段</el-tag>
<a-tag type="success" size="small">对象字段</a-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="handleMove('up')" title="上移">
<a-button size="small" @click="handleMove('up')" title="上移">
<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" />
</svg>
</el-button>
<el-button size="small" @click="handleMove('down')" title="下移">
</a-button>
<a-button size="small" @click="handleMove('down')" title="下移">
<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" />
</svg>
</el-button>
<el-button size="small" type="primary" @click="addFieldToObject">
</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" />
</svg>
添加子字段
</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">
</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" />
</svg>
删除
</el-button>
</a-button>
</div>
</div>
<div v-show="!isCollapsed">
<div v-if="Object.keys(localFieldConfig).length === 0" class="text-center py-6 bg-green-50 rounded-lg border border-dashed border-green-300">
<p class="text-sm text-gray-500 mb-2">对象为空</p>
<el-button size="small" type="primary" @click="addFieldToObject">添加第一个子字段</el-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-green-300">
@@ -236,34 +236,34 @@
</div>
<!-- 添加字段对话框 -->
<el-dialog v-model="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px">
<el-form>
<el-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<el-input
v-model="newFieldName"
<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'"
/>
</el-form-item>
<el-form-item label="元素类型">
<el-radio-group v-model="newFieldType">
<el-radio label="field">普通字段</el-radio>
<el-radio label="array">数组字段</el-radio>
<el-radio label="object">对象字段</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</a-form-item>
<a-form-item label="元素类型">
<a-radio-group v-model:value="newFieldType">
<a-radio value="field">普通字段</a-radio>
<a-radio value="array">数组字段</a-radio>
<a-radio value="object">对象字段</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
<template #footer>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
<a-button @click="addFieldDialogVisible = false">取消</a-button>
<a-button type="primary" @click="confirmAddField">确定</a-button>
</template>
</el-dialog>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { message } from 'ant-design-vue'
import FieldConfigEditor from './FieldConfigEditor.vue'
const props = defineProps({
@@ -400,7 +400,7 @@ const confirmAddField = () => {
}
addFieldDialogVisible.value = false
ElMessage.success('数组元素添加成功')
message.success('数组元素添加成功')
return
} else {
// 字段名不为空,添加为包含命名字段的对象
@@ -423,21 +423,21 @@ const confirmAddField = () => {
localFieldConfig.value.push(newObject)
addFieldDialogVisible.value = false
ElMessage.success('带命名字段的对象添加成功')
message.success('带命名字段的对象添加成功')
return
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
message.warning('请输入字段名')
return
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
ElMessage.warning('该字段已存在')
message.warning('该字段已存在')
return
}
@@ -460,7 +460,7 @@ const confirmAddField = () => {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
if (arrayItem[newFieldName.value]) {
ElMessage.warning('该字段已存在')
message.warning('该字段已存在')
return
}
@@ -482,7 +482,7 @@ const confirmAddField = () => {
}
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
message.success('字段添加成功')
}
</script>
-43
View File
@@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+11 -2
View File
@@ -8,21 +8,30 @@
</template>
<script setup>
import { onMounted } from 'vue'
import Navbar from './Navbar.vue'
import { useTokenMonitor } from '@/composables/useTokenMonitor'
// 启动全局 Token 监控
const { startMonitoring } = useTokenMonitor()
onMounted(() => {
startMonitoring()
})
</script>
<style scoped>
.layout-container {
width: 100%;
height: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eef5 100%);
}
.main-content {
flex: 1;
overflow-y: auto;
background-color: #f5f5f5;
padding: 20px;
}
</style>
+340 -156
View File
@@ -6,15 +6,13 @@
<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">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CheckCircleOutlined class="text-white text-xl" />
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-2">
<!-- Desktop Navigation Links -->
<div v-if="!isMobile" class="hidden md:flex items-center space-x-2">
<router-link
to="/dashboard"
v-slot="{ isActive }"
@@ -30,9 +28,7 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<HomeOutlined />
<span>仪表盘</span>
</div>
</a>
@@ -53,9 +49,7 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<FileTextOutlined />
<span>任务管理</span>
</div>
</a>
@@ -76,156 +70,203 @@
]"
>
<div class="flex items-center space-x-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<UnorderedListOutlined />
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Menu -->
<div v-if="authStore.isAdmin" class="relative" @mouseenter="showAdminMenu = true" @mouseleave="showAdminMenu = false">
<button
<!-- Admin Dropdown Menu -->
<a-dropdown v-if="authStore.isAdmin" :trigger="['hover']">
<a
:class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2',
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2 cursor-pointer',
isAdminPath ? 'bg-secondary-100 text-secondary-700' : 'text-gray-700 hover:bg-gray-100'
]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<SettingOutlined />
<span>管理后台</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showAdminMenu }" 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" />
</svg>
</button>
<!-- Admin Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="showAdminMenu" class="absolute top-full left-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<router-link
to="/admin/users"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span>用户管理</span>
</div>
</router-link>
<router-link
to="/admin/templates"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<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="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>模板管理</span>
</div>
</router-link>
<router-link
to="/admin/records"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span>打卡记录</span>
</div>
</router-link>
<router-link
to="/admin/stats"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>统计信息</span>
</div>
</router-link>
<router-link
to="/admin/logs"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center space-x-2">
<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="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>系统日志</span>
</div>
</router-link>
</div>
</transition>
</div>
<DownOutlined class="text-xs" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="users" @click="router.push('/admin/users')">
<UserOutlined />
<span class="ml-2">用户管理</span>
</a-menu-item>
<a-menu-item key="templates" @click="router.push('/admin/templates')">
<FileOutlined />
<span class="ml-2">模板管理</span>
</a-menu-item>
<a-menu-item key="records" @click="router.push('/admin/records')">
<CheckSquareOutlined />
<span class="ml-2">打卡记录</span>
</a-menu-item>
<a-menu-item key="stats" @click="router.push('/admin/stats')">
<BarChartOutlined />
<span class="ml-2">统计信息</span>
</a-menu-item>
<a-menu-item key="logs" @click="router.push('/admin/logs')">
<FileTextOutlined />
<span class="ml-2">系统日志</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<!-- User Menu -->
<!-- User Menu & Mobile Hamburger -->
<div class="flex items-center space-x-4">
<!-- User Avatar and Menu -->
<div class="relative" @mouseenter="showUserMenu = true" @mouseleave="showUserMenu = false">
<button class="flex items-center space-x-3 px-4 py-2 rounded-full hover:bg-gray-100 transition-all">
<div class="w-8 h-8 bg-gradient-to-br from-accent-400 to-accent-600 rounded-full flex items-center justify-center text-white font-semibold">
{{ userInitial }}
</div>
<span class="hidden md:block font-medium text-gray-700">{{ authStore.user?.alias || '用户' }}</span>
<svg class="w-4 h-4 text-gray-500 transition-transform" :class="{ 'rotate-180': showUserMenu }" 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" />
</svg>
</button>
<!-- User Dropdown -->
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
<!-- Token Status Indicator (Desktop) -->
<a-tooltip v-if="!isMobile && showTokenStatus" :title="tokenStatusTooltip">
<div
class="px-3 py-1.5 rounded-full cursor-pointer transition-all hover:bg-gray-100 flex items-center space-x-2"
@click="handleTokenStatusClick"
>
<div v-show="showUserMenu" class="absolute top-full right-0 mt-2 w-48 glass-effect rounded-md3 shadow-md3-3 py-2 border border-gray-200/50">
<div class="px-4 py-2 border-b border-gray-200/50">
<p class="text-sm font-medium text-gray-900">{{ authStore.user?.alias }}</p>
<p class="text-xs text-gray-500 mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
<button
@click="router.push('/settings')"
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors flex items-center space-x-2"
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>个人设置</span>
</button>
<button
@click="handleLogout"
class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center space-x-2"
>
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>退出登录</span>
</button>
</div>
</transition>
</div>
<a-badge :status="tokenBadgeStatus" />
<ClockCircleOutlined :class="tokenIconClass" />
<span class="text-sm">{{ tokenBadgeText }}</span>
<!-- 过期时显示刷新按钮 -->
<a-button
v-if="remainingMinutes !== null && remainingMinutes < 0"
type="primary"
size="small"
@click.stop="handleRefreshToken"
>
刷新
</a-button>
</div>
</a-tooltip>
<!-- 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-gray-100 transition-all cursor-pointer">
<a-avatar :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<span class="hidden md:block font-medium text-gray-700">{{ authStore.user?.alias || '用户' }}</span>
<DownOutlined class="text-xs text-gray-500" />
</a>
<template #overlay>
<a-menu>
<a-menu-item key="info" disabled>
<div class="px-2 py-1">
<p class="text-sm font-medium text-gray-900">{{ authStore.user?.alias }}</p>
<p class="text-xs text-gray-500 mt-1">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="settings" @click="router.push('/settings')">
<SettingOutlined />
<span class="ml-2">个人设置</span>
</a-menu-item>
<a-menu-item key="logout" @click="handleLogout" danger>
<LogoutOutlined />
<span class="ml-2">退出登录</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- Mobile Hamburger Button -->
<a-button
v-if="isMobile"
type="text"
@click="drawerVisible = true"
class="!p-2"
>
<MenuOutlined class="text-xl" />
</a-button>
</div>
</div>
</nav>
<!-- Mobile Drawer -->
<a-drawer
v-model:open="drawerVisible"
placement="left"
:width="280"
title="菜单"
>
<!-- User Info in Drawer -->
<div class="mb-6 pb-4 border-b border-gray-200">
<div class="flex items-center space-x-3">
<a-avatar :size="48" :style="{ backgroundColor: '#f56a00' }">
{{ userInitial }}
</a-avatar>
<div>
<p class="font-medium text-gray-900">{{ authStore.user?.alias || '用户' }}</p>
<p class="text-xs text-gray-500">{{ authStore.isAdmin ? '管理员' : '普通用户' }}</p>
</div>
</div>
</div>
<!-- Mobile Navigation Menu -->
<a-menu
mode="inline"
:selected-keys="[currentMenuKey]"
@click="handleMenuClick"
>
<a-menu-item key="dashboard">
<template #icon><HomeOutlined /></template>
仪表盘
</a-menu-item>
<a-menu-item key="tasks">
<template #icon><FileTextOutlined /></template>
任务管理
</a-menu-item>
<a-menu-item key="records">
<template #icon><UnorderedListOutlined /></template>
打卡记录
</a-menu-item>
<!-- Admin Menu Group -->
<a-sub-menu v-if="authStore.isAdmin" key="admin">
<template #icon><SettingOutlined /></template>
<template #title>管理后台</template>
<a-menu-item key="admin-users">
<template #icon><UserOutlined /></template>
用户管理
</a-menu-item>
<a-menu-item key="admin-templates">
<template #icon><FileOutlined /></template>
模板管理
</a-menu-item>
<a-menu-item key="admin-records">
<template #icon><CheckSquareOutlined /></template>
打卡记录
</a-menu-item>
<a-menu-item key="admin-stats">
<template #icon><BarChartOutlined /></template>
统计信息
</a-menu-item>
<a-menu-item key="admin-logs">
<template #icon><FileTextOutlined /></template>
系统日志
</a-menu-item>
</a-sub-menu>
<a-menu-divider />
<a-menu-item key="settings">
<template #icon><SettingOutlined /></template>
个人设置
</a-menu-item>
<a-menu-item key="logout" danger>
<template #icon><LogoutOutlined /></template>
退出登录
</a-menu-item>
</a-menu>
</a-drawer>
<!-- Token 刷新 QR 码模态框 -->
<QRCodeModal
v-model:visible="qrcodeModalVisible"
:alias="authStore.user?.alias || ''"
@success="handleQRCodeSuccess"
@error="handleQRCodeError"
/>
</div>
</template>
@@ -233,14 +274,36 @@
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { useTokenMonitor } from '@/composables/useTokenMonitor'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { Modal, message } from 'ant-design-vue'
import QRCodeModal from './QRCodeModal.vue'
import {
MenuOutlined,
HomeOutlined,
FileTextOutlined,
UnorderedListOutlined,
SettingOutlined,
UserOutlined,
FileOutlined,
CheckSquareOutlined,
BarChartOutlined,
LogoutOutlined,
DownOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
} 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 showAdminMenu = ref(false)
const showUserMenu = ref(false)
const drawerVisible = ref(false)
const qrcodeModalVisible = ref(false)
const isAdminPath = computed(() => route.path.startsWith('/admin'))
@@ -249,19 +312,140 @@ const userInitial = computed(() => {
return name.charAt(0).toUpperCase()
})
// Token 状态计算
const remainingMinutes = computed(() => {
return getRemainingMinutes()
})
const showTokenStatus = computed(() => {
if (!authStore.isAuthenticated || !tokenStatus.value) return false
const mins = remainingMinutes.value
// 显示条件:Token 即将过期(60分钟内)或已过期(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 tokenBadgeText = computed(() => {
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-gray-500'
if (mins < 0) return 'text-red-500' // 已过期
if (mins <= 10) return 'text-red-500 animate-pulse' // 10分钟内,闪烁
if (mins <= 30) return 'text-orange-500' // 30分钟内
return 'text-blue-500' // 正常
})
const tokenStatusTooltip = computed(() => {
const mins = remainingMinutes.value
if (mins === null) return 'Token 状态未知'
if (mins < 0) {
const expiredMins = Math.abs(mins)
return `登录凭证已过期 ${expiredMins} 分钟,点击右侧按钮刷新`
}
if (mins < 60) {
return `Token 剩余时间:${mins} 分钟,过期后可刷新)`
}
return 'Token 状态正常'
})
const handleTokenStatusClick = () => {
const mins = remainingMinutes.value
// Token 已过期时提醒刷新
if (mins !== null && mins < 0) {
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 handleMenuClick = ({ key }) => {
const routes = {
'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',
}
if (key === 'logout') {
handleLogout()
} else if (routes[key]) {
router.push(routes[key])
drawerVisible.value = false
}
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
Modal.confirm({
title: '提示',
content: '确定要退出登录吗?',
okText: '确定',
cancelText: '取消',
onOk() {
authStore.logout()
router.push('/login')
})
.catch(() => {
// 取消操作
})
drawerVisible.value = false
},
})
}
// 处理 Token 刷新
const handleRefreshToken = () => {
qrcodeModalVisible.value = true
}
// 处理 QR 码扫码成功
const handleQRCodeSuccess = async () => {
message.success('Token 刷新成功')
qrcodeModalVisible.value = false
// 刷新用户信息和 Token 状态
try {
await authStore.fetchCurrentUser()
await userStore.fetchTokenStatus()
} catch (error) {
console.error('刷新用户信息失败:', error)
}
}
// 处理 QR 码扫码失败
const handleQRCodeError = (error) => {
message.error(error?.message || 'Token 刷新失败')
}
</script>
+65 -33
View File
@@ -1,17 +1,17 @@
<template>
<el-dialog
v-model="dialogVisible"
<a-modal
v-model:open="dialogVisible"
title="QQ 扫码登录"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
:width="isMobile ? '100%' : 400"
:style="isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : {}"
:maskClosable="false"
@cancel="handleClose"
:footer="null"
>
<div class="qrcode-container">
<!-- 加载中 -->
<div v-if="status === 'loading'" class="status-container">
<el-icon class="is-loading" :size="60">
<Loading />
</el-icon>
<a-spin size="large" />
<p class="status-text">正在获取二维码...</p>
</div>
@@ -19,43 +19,43 @@
<div v-else-if="status === 'pending'" class="qrcode-wrapper">
<img :src="qrcodeUrl" alt="QR Code" class="qrcode-image" />
<p class="hint-text">请使用手机 QQ 扫描二维码登录</p>
<el-progress :percentage="progress" :show-text="false" />
<a-progress :percent="progress" :show-info="false" />
<p class="countdown-text">{{ countdown }}s</p>
</div>
<!-- 扫码成功 -->
<div v-else-if="status === 'success'" class="status-container">
<el-icon :size="60" color="#67c23a">
<SuccessFilled />
</el-icon>
<CheckCircleFilled class="status-icon success-icon" />
<p class="status-text success">登录成功</p>
</div>
<!-- 二维码过期 -->
<div v-else-if="status === 'expired'" class="status-container">
<el-icon :size="60" color="#e6a23c">
<WarningFilled />
</el-icon>
<WarningFilled class="status-icon warning-icon" />
<p class="status-text">二维码已过期</p>
<el-button type="primary" @click="refreshQRCode">刷新二维码</el-button>
<a-button type="primary" @click="refreshQRCode" class="mt-4">刷新二维码</a-button>
</div>
<!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container">
<el-icon :size="60" color="#f56c6c">
<CircleCloseFilled />
</el-icon>
<CloseCircleFilled class="status-icon error-icon" />
<p class="status-text error">{{ errorMessage }}</p>
<el-button type="primary" @click="refreshQRCode">重试</el-button>
<a-button type="primary" @click="refreshQRCode" class="mt-4">重试</a-button>
</div>
</div>
</el-dialog>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { message } from 'ant-design-vue'
import {
CheckCircleFilled,
WarningFilled,
CloseCircleFilled,
} from '@ant-design/icons-vue'
const props = defineProps({
visible: {
@@ -71,6 +71,7 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'success', 'error'])
const authStore = useAuthStore()
const { isMobile } = useBreakpoint()
const dialogVisible = computed({
get: () => props.visible,
@@ -122,7 +123,7 @@ const startPolling = () => {
stopPolling()
stopCountdown()
ElMessage.success('登录成功!')
message.success('登录成功!')
// 延迟关闭对话框
setTimeout(() => {
@@ -194,6 +195,16 @@ const refreshQRCode = () => {
const handleClose = () => {
stopPolling()
stopCountdown()
// 如果有未完成的会话,取消它
if (sessionId.value && status.value !== 'success') {
try {
authStore.cancelQRCodeSession(sessionId.value)
} catch (error) {
console.error('取消会话失败:', error)
}
}
dialogVisible.value = false
}
@@ -209,6 +220,12 @@ watch(
}
}
)
// 组件卸载时清理定时器,防止内存泄漏
onBeforeUnmount(() => {
stopPolling()
stopCountdown()
})
</script>
<style scoped>
@@ -228,6 +245,22 @@ watch(
min-height: 300px;
}
.status-icon {
font-size: 60px;
}
.success-icon {
color: #52c41a;
}
.warning-icon {
color: #faad14;
}
.error-icon {
color: #ff4d4f;
}
.status-text {
margin-top: 20px;
font-size: 16px;
@@ -235,12 +268,12 @@ watch(
}
.status-text.success {
color: #67c23a;
color: #52c41a;
font-weight: bold;
}
.status-text.error {
color: #f56c6c;
color: #ff4d4f;
}
.qrcode-wrapper {
@@ -253,8 +286,8 @@ watch(
.qrcode-image {
width: 240px;
height: 240px;
border: 1px solid #dcdfe6;
border-radius: 4px;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 10px;
background-color: #fff;
}
@@ -262,17 +295,16 @@ watch(
.hint-text {
margin-top: 20px;
font-size: 14px;
color: #909399;
color: #8c8c8c;
}
.countdown-text {
margin-top: 10px;
font-size: 12px;
color: #909399;
color: #8c8c8c;
}
.el-progress {
width: 100%;
margin-top: 10px;
.mt-4 {
margin-top: 16px;
}
</style>