refactor: v2

backend & frontend
This commit is contained in:
2026-01-01 18:38:21 +08:00
parent 3d201bc497
commit fdc725b893
109 changed files with 22918 additions and 1135 deletions
+316
View File
@@ -0,0 +1,316 @@
<template>
<div class="crontab-editor">
<!-- 模式选择 Tab -->
<div class="mode-tabs">
<button
v-for="m in modes"
:key="m"
:class="{ active: mode === m }"
@click.prevent="switchMode(m)"
class="mode-tab"
type="button"
>
{{ modeLabels[m] }}
</button>
</div>
<!-- 快速模式仅日期 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>
</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"
format="HH:mm"
placeholder="选择时间"
/>
</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>
</div>
<!-- 高级模式原始 Crontab 表达式 -->
<div v-if="mode === 'advanced'" class="mode-content">
<div class="expression-input">
<el-input
v-model="advancedExpression"
type="textarea"
placeholder="输入 crontab 表达式(例如:0 20 * * *"
:rows="2"
@input="validateExpression"
/>
<div class="help-text">
格式: 分钟 小时 日期 月份 星期
<a href="https://crontab.guru" target="_blank">了解更多</a>
</div>
</div>
</div>
<!-- 预览部分 -->
<div v-if="nextExecutions.length" class="preview-section">
<h4>下一个执行时间</h4>
<ul class="execution-list">
<li v-for="(time, idx) in nextExecutions" :key="idx">{{ time }}</li>
</ul>
</div>
<!-- 验证消息 -->
<div v-if="validationMessage" :class="['validation-message', validationStatus]">
{{ validationMessage }}
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import client from '@/api/client'
const props = defineProps({
modelValue: String, // 当前 cron 表达式
})
const emit = defineEmits(['update:modelValue'])
const mode = ref('quick')
const modeLabels = {
quick: '快速',
custom: '自定义',
advanced: '高级'
}
const modes = ['quick', 'custom', 'advanced']
// 快速模式
const selectedQuick = ref('20:00')
// 自定义模式
const customTime = ref('20:00')
const customFrequency = ref('daily')
// 高级模式
const advancedExpression = ref(props.modelValue || '0 20 * * *')
const validationMessage = ref('')
const validationStatus = ref('')
// 通用
const nextExecutions = ref([])
// 切换模式 - 防止页面刷新
function switchMode(newMode) {
mode.value = newMode
}
// 监听 - 只在有效值时更新
watch(selectedQuick, () => {
const cron = buildCrontabFromQuick()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
watch(customFrequency, () => {
const cron = buildCrontabFromCustom()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
watch(customTime, () => {
const cron = buildCrontabFromCustom()
emit('update:modelValue', cron)
if (cron) validateAndPreview(cron)
})
// 工具函数
function buildCrontabFromQuick() {
if (selectedQuick.value === '20:00') {
return '0 20 * * *' // 每天 20:00
}
return null
}
function buildCrontabFromCustom() {
const [hour, minute] = customTime.value.split(':')
let dow = '*' // 星期
if (customFrequency.value === 'weekday') {
dow = '1-5' // 周一至周五
} else if (customFrequency.value === 'weekend') {
dow = '0,6' // 周六和周日
}
return `${minute} ${hour} * * ${dow}`
}
async function validateExpression() {
if (!advancedExpression.value.trim()) {
validationMessage.value = ''
nextExecutions.value = []
return
}
await validateAndPreview(advancedExpression.value)
}
async function validateAndPreview(expr) {
if (!expr) {
validationMessage.value = ''
nextExecutions.value = []
return
}
try {
const response = await client.post('/api/tasks/validate-cron', {
cron_expression: expr
})
if (response.valid) {
validationStatus.value = 'success'
validationMessage.value = `有效: ${response.description}`
nextExecutions.value = response.next_times
}
} catch (error) {
validationStatus.value = 'error'
validationMessage.value = error.message || '无效的 crontab 表达式'
nextExecutions.value = []
}
}
// 初始化
watch(() => props.modelValue, (newVal) => {
if (newVal) {
advancedExpression.value = newVal
}
}, { immediate: true })
</script>
<style scoped>
.crontab-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 16px;
background: #f5f7fa;
}
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 2px solid #ebeef5;
}
.mode-tab {
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: #909399;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.3s;
}
.mode-tab.active {
color: #409eff;
border-bottom-color: #409eff;
}
.mode-content {
margin: 16px 0;
}
.quick-option {
padding: 12px;
background: white;
border-radius: 4px;
}
.option-label {
font-weight: 600;
}
.option-desc {
margin-left: 12px;
color: #909399;
font-size: 12px;
}
.expression-input {
margin: 12px 0;
}
.help-text {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.help-text a {
color: #409eff;
text-decoration: none;
}
.help-text a:hover {
text-decoration: underline;
}
.preview-section {
margin: 16px 0;
padding: 12px;
background: white;
border-radius: 4px;
}
.preview-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
}
.execution-list {
margin: 0;
padding-left: 20px;
font-size: 12px;
color: #606266;
}
.validation-message {
padding: 8px 12px;
border-radius: 4px;
margin-top: 12px;
font-size: 12px;
}
.validation-message.success {
background: #f0f9ff;
color: #67c23a;
border: 1px solid #c6e2ff;
}
.validation-message.error {
background: #fef0f0;
color: #f56c6c;
border: 1px solid #fde7e7;
}
.validation-message.info {
background: #f4f4f5;
color: #909399;
border: 1px solid #ebeef5;
}
</style>
@@ -0,0 +1,294 @@
<template>
<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)"
placeholder="在表单中显示的名称"
clearable
/>
<span class="text-xs text-gray-500 mt-1">显示名称</span>
</el-form-item>
<el-form-item label="字段类型" class="mb-0">
<el-select
:model-value="modelValue.field_type"
@update:model-value="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>
<span class="text-xs text-gray-500 mt-1">用户填写时使用的输入控件</span>
</el-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)"
placeholder="选择数据类型"
class="w-full"
>
<el-option label="字符串 (string)" value="string">
<span class="text-xs text-gray-500">字符串 (string)</span>
</el-option>
<el-option label="整数 (int)" value="int">
<span class="text-xs text-gray-500">整数 (int)</span>
</el-option>
<el-option label="浮点数 (double)" value="double">
<span class="text-xs text-gray-500">浮点数 (double)</span>
</el-option>
<el-option label="布尔值 (bool)" value="bool">
<span class="text-xs text-gray-500">布尔值 (bool)</span>
</el-option>
<el-option label="JSON对象 (json)" value="json">
<span class="text-xs text-gray-500">JSON对象 (json) - 用于Values字段</span>
</el-option>
</el-select>
<span class="text-xs text-gray-500 mt-1">数据存储时的类型</span>
</el-form-item>
<el-form-item label="默认值" class="mb-0">
<el-input
v-if="modelValue.value_type !== 'json'"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
placeholder="字段的默认值"
clearable
/>
<el-input
v-else
type="textarea"
:model-value="modelValue.default_value"
@update:model-value="updateField('default_value', $event)"
placeholder="字段的默认值"
:rows="3"
clearable
/>
<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>
</template>
<template v-else>
用户未填写时使用此值
</template>
</span>
</el-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)"
placeholder="输入框的灰色提示文本"
clearable
/>
<span class="text-xs text-gray-500 mt-1">占位符</span>
</el-form-item>
<!-- Row 4: Switches -->
<div class="grid grid-cols-2 gap-4 p-3 bg-blue-50 rounded-lg">
<div class="flex items-center justify-between">
<div>
<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"
:disabled="modelValue.hidden"
/>
</div>
<div class="flex items-center justify-between">
<div>
<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"
/>
</div>
</div>
<el-alert
v-if="modelValue.hidden"
title="💡 提示"
type="info"
:closable="false"
class="mt-3"
>
<p class="text-xs">
隐藏字段将自动使用默认值不会在创建任务表单中显示请确保设置了合适的默认值
</p>
</el-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">
<div class="space-y-2">
<div
v-for="(option, index) in modelValue.options || []"
:key="index"
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)"
placeholder="显示文本(如:健康)"
size="small"
class="flex-1"
/>
<el-input
:model-value="option.value"
@update:model-value="updateOption(index, 'value', $event)"
placeholder="选项值(如:healthy"
size="small"
class="flex-1"
/>
<el-button
size="small"
type="danger"
:icon="Delete"
@click="removeOption(index)"
circle
/>
</div>
<el-button size="small" type="primary" plain @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>
<p class="text-xs text-gray-500 mt-2">
💡 提示显示文本是用户看到的内容选项值是实际保存的数据
</p>
</div>
</el-form-item>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { Delete } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: {
type: Object,
required: true
},
fieldKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
// Update single field
const updateField = (field, value) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
})
}
// Handle required change
const handleRequiredChange = (value) => {
updateField('required', value)
}
// Handle hidden change - 当隐藏时,自动设置 required 为 false
const handleHiddenChange = (value) => {
const updated = {
...props.modelValue,
hidden: value
}
// 如果设置为隐藏,则取消必填
if (value) {
updated.required = false
}
emit('update:modelValue', updated)
}
// Handle field type change
const handleFieldTypeChange = (newType) => {
const updated = {
...props.modelValue,
field_type: newType
}
if (newType === 'select' && !updated.options) {
updated.options = []
}
emit('update:modelValue', updated)
}
// Add option
const addOption = () => {
const options = [...(props.modelValue.options || [])]
options.push({ label: '', value: '' })
emit('update:modelValue', {
...props.modelValue,
options
})
}
// Update option
const updateOption = (index, field, value) => {
const options = [...(props.modelValue.options || [])]
options[index] = {
...options[index],
[field]: value
}
emit('update:modelValue', {
...props.modelValue,
options
})
}
// Remove option
const removeOption = (index) => {
const options = [...(props.modelValue.options || [])]
options.splice(index, 1)
emit('update:modelValue', {
...props.modelValue,
options
})
}
</script>
<style scoped>
.field-config-editor {
background-color: #fafafa;
padding: 20px;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #374151;
}
</style>
+497
View File
@@ -0,0 +1,497 @@
<template>
<div class="field-tree-node border-2 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
<!-- 普通字段 -->
<div v-if="isFieldConfig" class="field-config">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-gray-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
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>
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span class="font-mono text-base font-bold text-blue-700">{{ fieldKey }}</span>
<el-tag type="primary" size="small">普通字段</el-tag>
</div>
<div class="flex gap-2">
<el-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="下移">
<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">
<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>
</div>
</div>
<div v-show="!isCollapsed" class="bg-gray-50 rounded-lg p-3">
<FieldConfigEditor v-model="localFieldConfig" :field-key="fieldKey" />
</div>
</div>
<!-- 数组字段 -->
<div v-else-if="isArray" class="array-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-purple-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
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>
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="font-mono text-base font-bold text-purple-700">{{ fieldKey }}</span>
<el-tag type="warning" size="small">数组字段</el-tag>
</div>
<div class="flex gap-2">
<el-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="下移">
<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">
<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">
<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>
</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>
</div>
<div v-else class="space-y-3 mt-3">
<div
v-for="(item, index) in localFieldConfig"
:key="index"
class="border-2 border-purple-200 rounded-lg p-3 bg-purple-50"
>
<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)">
删除元素
</el-button>
</div>
<!-- 如果数组元素是字段配置对象直接渲染为字段编辑器 -->
<div v-if="typeof item === 'object' && !Array.isArray(item) && 'display_name' in item" class="bg-white rounded-lg p-3">
<FieldConfigEditor :model-value="item" @update:model-value="updateArrayItemField(index, $event)" :field-key="`元素${index + 1}`" />
</div>
<!-- 如果数组元素是对象但不是字段配置递归渲染其中的字段 -->
<div v-else-if="typeof item === 'object' && !Array.isArray(item)" class="space-y-2">
<FieldTreeNode
v-for="(subConfig, subKey) in item"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, index, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
<el-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>
</div>
<!-- 如果数组元素是数组递归渲染 -->
<div v-else-if="Array.isArray(item)">
<FieldTreeNode
:field-key="`元素${index + 1}`"
:field-config="item"
:path="[...path, index]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 对象字段 -->
<div v-else-if="isObject" class="object-field">
<div class="flex items-center justify-between mb-3 pb-2 border-b border-green-200">
<div class="flex items-center gap-3">
<button
type="button"
@click="isCollapsed = !isCollapsed"
class="hover:bg-gray-100 rounded p-1 transition-colors"
>
<svg
class="w-4 h-4 text-gray-600 transition-transform"
:class="{ 'rotate-180': !isCollapsed }"
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>
<svg class="w-5 h-5 text-green-600" 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 class="font-mono text-base font-bold text-green-700">{{ fieldKey }}</span>
<el-tag type="success" size="small">对象字段</el-tag>
</div>
<div class="flex gap-2">
<el-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="下移">
<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">
<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">
<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>
</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>
</div>
<div v-else class="space-y-3 mt-3 pl-4 border-l-4 border-green-300">
<!-- 递归渲染对象中的字段 -->
<FieldTreeNode
v-for="(subConfig, subKey) in localFieldConfig"
:key="subKey"
:field-key="subKey"
:field-config="subConfig"
:path="[...path, subKey]"
@update="$emit('update', $event)"
@delete="$emit('delete', $event)"
@move="$emit('move', $event)"
/>
</div>
</div>
</div>
<!-- 添加字段对话框 -->
<el-dialog v-model="addFieldDialogVisible" :title="currentArrayIndex === -1 ? '添加数组元素' : '添加字段'" width="400px">
<el-form>
<el-form-item :label="currentArrayIndex === -1 ? '字段名(可选)' : '字段名'">
<el-input
v-model="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>
<template #footer>
<el-button @click="addFieldDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddField">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import FieldConfigEditor from './FieldConfigEditor.vue'
const props = defineProps({
fieldKey: {
type: String,
required: true
},
fieldConfig: {
type: [Object, Array],
required: true
},
path: {
type: Array,
required: true
}
})
const emit = defineEmits(['update', 'delete', 'move'])
const localFieldConfig = ref(JSON.parse(JSON.stringify(props.fieldConfig)))
const addFieldDialogVisible = ref(false)
const newFieldName = ref('')
const newFieldType = ref('field')
const currentArrayIndex = ref(null)
const isAddingToObject = ref(false)
const isCollapsed = ref(false)
// 标志位,防止循环更新
let isUpdatingFromProps = false
// 监听 props.fieldConfig 的变化,同步更新 localFieldConfig
watch(() => props.fieldConfig, (newVal) => {
isUpdatingFromProps = true
localFieldConfig.value = JSON.parse(JSON.stringify(newVal))
// 使用 nextTick 确保在下一个 tick 后重置标志
nextTick(() => {
isUpdatingFromProps = false
})
}, { deep: true })
// 判断字段类型
const isFieldConfig = computed(() => {
return typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
'display_name' in props.fieldConfig
})
const isArray = computed(() => {
return Array.isArray(props.fieldConfig)
})
const isObject = computed(() => {
return typeof props.fieldConfig === 'object' &&
!Array.isArray(props.fieldConfig) &&
!('display_name' in props.fieldConfig)
})
// 监听本地配置变化 - 只在非 props 更新时触发
watch(localFieldConfig, (newVal) => {
if (!isUpdatingFromProps) {
emit('update', { path: props.path, value: newVal })
}
}, { deep: true })
// 删除字段
const handleDelete = () => {
emit('delete', props.path)
}
// 移动字段
const handleMove = (direction) => {
emit('move', { path: props.path, direction })
}
// 添加数组元素
const addArrayItem = () => {
// 弹出对话框让用户选择添加元素类型
currentArrayIndex.value = -1 // 标记为添加数组元素
isAddingToObject.value = false
newFieldName.value = '' // 数组元素不需要字段名,但复用对话框
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 删除数组元素
const removeArrayItem = (index) => {
localFieldConfig.value.splice(index, 1)
}
// 更新数组元素的字段配置
const updateArrayItemField = (index, newValue) => {
localFieldConfig.value[index] = newValue
}
// 为数组元素添加字段
const addFieldToArrayItem = (index) => {
currentArrayIndex.value = index
isAddingToObject.value = false
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 为对象添加字段
const addFieldToObject = () => {
currentArrayIndex.value = null
isAddingToObject.value = true
newFieldName.value = ''
newFieldType.value = 'field'
addFieldDialogVisible.value = true
}
// 确认添加字段
const confirmAddField = () => {
// 如果是添加数组元素(currentArrayIndex === -1
if (currentArrayIndex.value === -1) {
// 检查是否输入了字段名
if (!newFieldName.value || newFieldName.value.trim() === '') {
// 字段名为空,直接添加为数组元素
if (newFieldType.value === 'field') {
localFieldConfig.value.push({
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
})
} else if (newFieldType.value === 'array') {
localFieldConfig.value.push([])
} else if (newFieldType.value === 'object') {
localFieldConfig.value.push({})
}
addFieldDialogVisible.value = false
ElMessage.success('数组元素添加成功')
return
} else {
// 字段名不为空,添加为包含命名字段的对象
const newObject = {}
if (newFieldType.value === 'field') {
newObject[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
newObject[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
newObject[newFieldName.value] = {}
}
localFieldConfig.value.push(newObject)
addFieldDialogVisible.value = false
ElMessage.success('带命名字段的对象添加成功')
return
}
}
// 其他情况需要字段名
if (!newFieldName.value) {
ElMessage.warning('请输入字段名')
return
}
if (isAddingToObject.value) {
// 添加到对象字段
if (localFieldConfig.value[newFieldName.value]) {
ElMessage.warning('该字段已存在')
return
}
if (newFieldType.value === 'field') {
localFieldConfig.value[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
localFieldConfig.value[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
localFieldConfig.value[newFieldName.value] = {}
}
} else if (currentArrayIndex.value !== null) {
// 添加到数组元素
const arrayItem = localFieldConfig.value[currentArrayIndex.value]
if (arrayItem[newFieldName.value]) {
ElMessage.warning('该字段已存在')
return
}
if (newFieldType.value === 'field') {
arrayItem[newFieldName.value] = {
display_name: '',
field_type: 'text',
default_value: '',
required: false,
hidden: false,
value_type: 'string',
options: []
}
} else if (newFieldType.value === 'array') {
arrayItem[newFieldName.value] = []
} else if (newFieldType.value === 'object') {
arrayItem[newFieldName.value] = {}
}
}
addFieldDialogVisible.value = false
ElMessage.success('字段添加成功')
}
</script>
<style scoped>
.field-tree-node {
position: relative;
}
.rotate-180 {
transform: rotate(180deg);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<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>
+28
View File
@@ -0,0 +1,28 @@
<template>
<div class="layout-container">
<Navbar />
<div class="main-content">
<slot />
</div>
</div>
</template>
<script setup>
import Navbar from './Navbar.vue'
</script>
<style scoped>
.layout-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
overflow-y: auto;
background-color: #f5f5f5;
padding: 20px;
}
</style>
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="sticky top-0 z-50 glass-effect border-b border-gray-200/50 shadow-md3-2">
<nav class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo and Brand -->
<div class="flex items-center space-x-8">
<router-link to="/" class="flex items-center space-x-3 group">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-md3 flex items-center justify-center transform group-hover:scale-110 transition-transform">
<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>
</div>
<span class="text-xl font-bold text-gradient">接龙自动打卡</span>
</router-link>
<!-- Navigation Links -->
<div class="hidden md:flex items-center space-x-2">
<router-link
to="/dashboard"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/dashboard')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<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>
<span>仪表盘</span>
</div>
</a>
</router-link>
<router-link
to="/tasks"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/tasks')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<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>
<span>任务管理</span>
</div>
</a>
</router-link>
<router-link
to="/records"
v-slot="{ isActive }"
custom
>
<a
@click="router.push('/records')"
:class="[
'px-4 py-2 rounded-full font-medium transition-all cursor-pointer',
isActive
? 'bg-primary-100 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
]"
>
<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>
<span>打卡记录</span>
</div>
</a>
</router-link>
<!-- Admin Menu -->
<div v-if="authStore.isAdmin" class="relative" @mouseenter="showAdminMenu = true" @mouseleave="showAdminMenu = false">
<button
:class="[
'px-4 py-2 rounded-full font-medium transition-all flex items-center space-x-2',
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>
<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>
</div>
</div>
<!-- User Menu -->
<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"
>
<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>
</div>
</div>
</nav>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const showAdminMenu = ref(false)
const showUserMenu = ref(false)
const isAdminPath = computed(() => route.path.startsWith('/admin'))
const userInitial = computed(() => {
const name = authStore.user?.alias || 'U'
return name.charAt(0).toUpperCase()
})
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
authStore.logout()
router.push('/login')
})
.catch(() => {
// 取消操作
})
}
</script>
<style scoped>
/* Additional component-specific styles if needed */
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
>
<div class="flex-grow">
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span class="logo-text">接龙自动打卡系统</span>
</el-menu-item>
<el-menu-item index="/dashboard">
<el-icon><User /></el-icon>
<span>我的仪表盘</span>
</el-menu-item>
<el-menu-item index="/records">
<el-icon><List /></el-icon>
<span>打卡记录</span>
</el-menu-item>
<!-- 管理员菜单 -->
<el-sub-menu v-if="authStore.isAdmin" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>管理后台</span>
</template>
<el-menu-item index="/admin/users">用户管理</el-menu-item>
<el-menu-item index="/admin/records">所有打卡记录</el-menu-item>
<el-menu-item index="/admin/stats">统计信息</el-menu-item>
<el-menu-item index="/admin/logs">系统日志</el-menu-item>
</el-sub-menu>
</div>
<div class="flex-grow" />
<el-sub-menu index="user">
<template #title>
<el-icon><Avatar /></el-icon>
<span>{{ authStore.userSignature || '用户' }}</span>
</template>
<el-menu-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const activeMenu = computed(() => route.path)
const handleSelect = (index) => {
if (index !== route.path) {
router.push(index)
}
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
authStore.logout()
router.push('/login')
})
.catch(() => {
// 取消操作
})
}
</script>
<style scoped>
.flex-grow {
flex-grow: 1;
display: flex;
}
.logo-text {
font-weight: bold;
font-size: 18px;
margin-left: 8px;
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="dialogVisible"
title="QQ 扫码登录"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="qrcode-container">
<!-- 加载中 -->
<div v-if="status === 'loading'" class="status-container">
<el-icon class="is-loading" :size="60">
<Loading />
</el-icon>
<p class="status-text">正在获取二维码...</p>
</div>
<!-- 显示二维码 -->
<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" />
<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>
<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>
<p class="status-text">二维码已过期</p>
<el-button type="primary" @click="refreshQRCode">刷新二维码</el-button>
</div>
<!-- 失败 -->
<div v-else-if="status === 'failed'" class="status-container">
<el-icon :size="60" color="#f56c6c">
<CircleCloseFilled />
</el-icon>
<p class="status-text error">{{ errorMessage }}</p>
<el-button type="primary" @click="refreshQRCode">重试</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const props = defineProps({
visible: {
type: Boolean,
required: true,
},
alias: {
type: String,
required: true,
},
})
const emit = defineEmits(['update:visible', 'success', 'error'])
const authStore = useAuthStore()
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
})
const status = ref('loading') // loading, pending, success, expired, failed
const qrcodeUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
const countdown = ref(180) // 倒计时 3 分钟
const progress = ref(100)
let pollingTimer = null
let countdownTimer = null
// 获取二维码
const fetchQRCode = async () => {
status.value = 'loading'
try {
const result = await authStore.loginWithQRCode(props.alias)
sessionId.value = result.session_id
qrcodeUrl.value = `data:image/png;base64,${result.qrcode_base64}`
status.value = 'pending'
// 开始轮询扫码状态
startPolling()
startCountdown()
} catch (error) {
status.value = 'failed'
errorMessage.value = error.message || '获取二维码失败'
emit('error', error)
}
}
// 开始轮询扫码状态
const startPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
}
pollingTimer = setInterval(async () => {
try {
const result = await authStore.checkQRCodeStatus(sessionId.value)
if (result.success) {
// 扫码成功
status.value = 'success'
stopPolling()
stopCountdown()
ElMessage.success('登录成功!')
// 延迟关闭对话框
setTimeout(() => {
emit('success', result.user)
handleClose()
}, 1500)
} else if (result.status === 'expired') {
// 二维码过期
status.value = 'expired'
stopPolling()
stopCountdown()
} else if (result.status === 'failed') {
// 扫码失败
status.value = 'failed'
errorMessage.value = result.message || '扫码失败'
stopPolling()
stopCountdown()
}
// 否则继续轮询(pending 状态)
} catch (error) {
console.error('轮询扫码状态失败:', error)
// 继续轮询,不中断
}
}, 2000) // 每 2 秒轮询一次
}
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 开始倒计时
const startCountdown = () => {
countdown.value = 180
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
countdown.value--
progress.value = (countdown.value / 180) * 100
if (countdown.value <= 0) {
status.value = 'expired'
stopPolling()
stopCountdown()
}
}, 1000)
}
// 停止倒计时
const stopCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
// 刷新二维码
const refreshQRCode = () => {
fetchQRCode()
}
// 关闭对话框
const handleClose = () => {
stopPolling()
stopCountdown()
dialogVisible.value = false
}
// 监听对话框显示状态
watch(
() => props.visible,
(visible) => {
if (visible) {
fetchQRCode()
} else {
stopPolling()
stopCountdown()
}
}
)
</script>
<style scoped>
.qrcode-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
min-height: 300px;
}
.status-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
}
.status-text {
margin-top: 20px;
font-size: 16px;
color: #606266;
}
.status-text.success {
color: #67c23a;
font-weight: bold;
}
.status-text.error {
color: #f56c6c;
}
.qrcode-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.qrcode-image {
width: 240px;
height: 240px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
background-color: #fff;
}
.hint-text {
margin-top: 20px;
font-size: 14px;
color: #909399;
}
.countdown-text {
margin-top: 10px;
font-size: 12px;
color: #909399;
}
.el-progress {
width: 100%;
margin-top: 10px;
}
</style>