mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user