mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
refactor: v2
backend & frontend
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user