优化: 重构项目结构并修复字段配置保存问题

- 删除旧的独立子项目(admin、display),统一使用单应用架构
- 将 AdminLayout.vue 重命名为 Admin.vue
- 在管理后台添加大屏预览快捷按钮
- 修复字段配置修改后刷新页面丢失的问题
- 新增 updateFields 方法确保字段配置持久化到 IndexedDB
- 更新 IFLOW.md 和 README.md 文档
- 清理未使用的文件和测试数据
This commit is contained in:
yuantao
2026-01-16 14:07:51 +08:00
parent f19b1427b5
commit 466b8408ab
45 changed files with 695 additions and 9338 deletions

View File

@@ -9,7 +9,7 @@ const router = createRouter({
},
{
path: '/admin',
component: () => import('@/views/AdminLayout.vue'),
component: () => import('@/views/Admin.vue'),
children: [
{
path: 'participants',

View File

@@ -112,6 +112,11 @@ export const useLotteryStore = defineStore('lottery', () => {
}
}
const updateFields = async (newFields) => {
fields.value = newFields
await saveData('lottery_fields', fields.value)
}
// ============ 参与者管理 ============
const addParticipant = async (participant) => {
participants.value.push(participant)
@@ -454,6 +459,7 @@ export const useLotteryStore = defineStore('lottery', () => {
addField,
updateField,
removeField,
updateFields,
// 参与者管理
addParticipant,

View File

@@ -1 +0,0 @@
# Views directory

File diff suppressed because it is too large Load Diff

View File

@@ -1,412 +0,0 @@
<template>
<div class="admin-layout">
<el-container>
<el-header>
<div class="header-content">
<h1>抽奖管理系统</h1>
<div style="display: flex; gap: 10px">
<el-button type="info" @click="openDisplaySettings">
<el-icon><Setting /></el-icon>
显示设置
</el-button>
<el-button type="info" @click="showBackgroundDialog = true">
<el-icon><Picture /></el-icon>
背景设置
</el-button>
<el-button type="info" @click="showShortcutGuide = true">
<el-icon><QuestionFilled /></el-icon>
快捷键指南
</el-button>
</div>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:default-active="activeMenu"
router
class="admin-menu"
>
<el-menu-item index="/admin/participants">
<el-icon><User /></el-icon>
<span>名单管理</span>
</el-menu-item>
<el-menu-item index="/admin/prizes">
<el-icon><Present /></el-icon>
<span>奖品管理</span>
</el-menu-item>
<el-menu-item index="/admin/rounds">
<el-icon><List /></el-icon>
<span>轮次管理</span>
</el-menu-item>
<el-menu-item index="/admin/winners">
<el-icon><Trophy /></el-icon>
<span>中奖记录</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view></router-view>
</el-main>
</el-container>
</el-container>
<!-- 快捷键指南对话框 -->
<el-dialog v-model="showShortcutGuide" title="大屏端快捷键指南" width="500px">
<div class="shortcut-guide">
<div class="shortcut-item">
<span class="shortcut-key"> </span>
<span class="shortcut-desc">切换轮次左右方向键</span>
</div>
<div class="shortcut-item">
<span class="shortcut-key">Space</span>
<span class="shortcut-desc">开始/停止抽奖</span>
</div>
</div>
<template #footer>
<el-button type="primary" @click="showShortcutGuide = false">知道了</el-button>
</template>
</el-dialog>
<!-- 背景图片配置对话框 -->
<el-dialog v-model="showBackgroundDialog" title="大屏端背景图片" width="500px">
<div class="background-config">
<div v-if="store.backgroundImage" class="current-background">
<div class="background-label">当前背景图片</div>
<img :src="store.backgroundImage" class="background-preview" />
<el-button type="danger" @click="clearBackgroundImage" style="margin-top: 10px">
清除背景
</el-button>
</div>
<div class="upload-section">
<div class="background-label">上传新背景</div>
<el-upload
:auto-upload="false"
:on-change="handleBackgroundChange"
:limit="1"
accept="image/*"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽图片到此处或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持 JPGPNGGIF 等图片格式最大支持10MB
</div>
</template>
</el-upload>
<div v-if="backgroundImagePreview" class="preview-section">
<div class="background-label">预览</div>
<img :src="backgroundImagePreview" class="background-preview" />
</div>
</div>
</div>
<template #footer>
<el-button @click="showBackgroundDialog = false">取消</el-button>
<el-button type="primary" @click="saveBackgroundImage" :disabled="!backgroundImagePreview">保存</el-button>
</template>
</el-dialog>
<!-- 显示设置对话框 -->
<el-dialog v-model="showDisplaySettingsDialog" title="大屏端显示设置" width="400px">
<el-form label-width="120px">
<el-form-item label="每行显示人数">
<el-input-number
v-model="tempColumnsPerRow"
:min="1"
:max="10"
:step="1"
/>
<div style="margin-top: 8px; color: var(--color-text-light); font-size: 12px;">
设置大屏端名单每行显示的人数建议值2-5
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDisplaySettingsDialog = false">取消</el-button>
<el-button type="primary" @click="saveDisplaySettings">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useLotteryStore } from '../store'
import { User, Present, List, Trophy, Picture, QuestionFilled, UploadFilled, Setting } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
const store = useLotteryStore()
const activeMenu = computed(() => route.path)
// 初始化store
onMounted(async () => {
await store.initialize()
})
// 快捷键指南
const showShortcutGuide = ref(false)
// 显示设置
const showDisplaySettingsDialog = ref(false)
const tempColumnsPerRow = ref(3)
const openDisplaySettings = () => {
tempColumnsPerRow.value = store.columnsPerRow
showDisplaySettingsDialog.value = true
}
const saveDisplaySettings = async () => {
try {
await store.setColumnsPerRow(tempColumnsPerRow.value)
showDisplaySettingsDialog.value = false
ElMessage.success('显示设置已保存')
} catch (error) {
ElMessage.error('保存显示设置失败:' + error.message)
}
}
// 背景图片配置
const showBackgroundDialog = ref(false)
const backgroundImageFile = ref(null)
const backgroundImagePreview = ref('')
const handleBackgroundChange = (file) => {
const maxSize = 10 * 1024 * 1024
if (file.raw.size > maxSize) {
ElMessage.error('图片大小不能超过10MB请使用较小的图片')
backgroundImageFile.value = null
backgroundImagePreview.value = ''
return
}
backgroundImageFile.value = file.raw
const reader = new FileReader()
reader.onload = (e) => {
backgroundImagePreview.value = e.target.result
}
reader.readAsDataURL(file.raw)
}
const saveBackgroundImage = async () => {
if (backgroundImagePreview.value) {
try {
await store.setBackgroundImage(backgroundImagePreview.value)
showBackgroundDialog.value = false
backgroundImagePreview.value = ''
backgroundImageFile.value = null
ElMessage.success('背景图片已设置')
} catch (error) {
ElMessage.error('保存背景图片失败:' + error.message)
}
} else {
ElMessage.warning('请选择图片')
}
}
const clearBackgroundImage = async () => {
try {
await store.clearBackgroundImage()
ElMessage.success('背景图片已清除')
} catch (error) {
ElMessage.error('清除背景图片失败:' + error.message)
}
}
</script>
<style scoped>
.admin-layout {
min-height: 100vh;
background: var(--color-background);
font-family: var(--font-family-primary);
}
.el-header {
background: var(--color-background);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-2xl);
box-shadow: var(--shadow-small);
position: relative;
z-index: 10;
height: var(--header-height);
}
.el-header::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--color-secondary);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.el-header h1 {
margin: 0;
font-family: var(--font-family-secondary);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.el-header .el-button {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
transition: var(--transition-color), var(--transition-background);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius-md);
padding: var(--spacing-md) var(--spacing-xl);
}
.el-header .el-button:hover {
background: rgba(var(--color-primary-rgb), 0.1);
border-color: var(--color-primary);
color: var(--color-primary);
transform: none;
}
.el-aside {
background: var(--color-background);
border-right: 1px solid var(--color-border);
padding: var(--spacing-lg) 0;
}
.admin-menu {
border: none;
background: transparent;
}
.admin-menu .el-menu-item {
margin: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--border-radius-md);
color: var(--color-text-primary);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
.admin-menu .el-menu-item:hover {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
}
.admin-menu .el-menu-item.is-active {
background: var(--color-primary);
color: var(--color-text-white);
}
.admin-menu .el-menu-item .el-icon {
margin-right: var(--spacing-sm);
}
.el-main {
padding: var(--spacing-3xl);
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
/* 快捷键指南样式 */
.shortcut-guide {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: var(--spacing-3xl);
padding: var(--spacing-2xl) 0;
}
.shortcut-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-xl);
min-width: 140px;
}
.shortcut-key {
background: var(--color-secondary);
color: var(--color-text-white);
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-3xl);
margin-bottom: var(--spacing-md);
box-shadow: 0 4px 12px rgba(var(--color-secondary-rgb), 0.3);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--border-radius-lg);
border: 2px solid rgba(255, 255, 255, 0.2);
min-width: 80px;
text-align: center;
}
.shortcut-desc {
color: var(--color-text-light);
font-size: var(--font-size-base);
text-align: center;
line-height: var(--line-height-relaxed);
font-family: var(--font-family-primary);
}
/* 背景图片配置样式 */
.background-config {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.current-background {
padding: var(--spacing-lg);
background: var(--color-border-light);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
}
.upload-section {
padding: var(--spacing-lg);
background: var(--color-border-light);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
}
.background-label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: var(--spacing-md);
}
.background-preview {
width: 100%;
max-width: 400px;
max-height: 250px;
object-fit: cover;
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
}
.preview-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--color-border);
}
</style>

View File

@@ -4,82 +4,42 @@
<div class="card-header">
<span>名单管理</span>
<div>
<el-button type="info" size="small" @click="showFieldConfig">
字段配置
</el-button>
<el-button type="primary" size="small" @click="showImportDialog = true">
导入
</el-button>
<el-button type="success" size="small" @click="exportParticipants">
导出
</el-button>
<el-button type="danger" size="small" @click="clearParticipants">
清空
</el-button>
<el-button type="info" size="small" @click="showFieldConfig"> 字段配置 </el-button>
<el-button type="primary" size="small" @click="showImportDialog = true"> 导入 </el-button>
<el-button type="success" size="small" @click="exportParticipants"> 导出 </el-button>
<el-button type="danger" size="small" @click="clearParticipants"> 清空 </el-button>
</div>
</div>
</template>
<!-- 单个添加表单 -->
<div class="single-add-form">
<el-form :model="newParticipantData" label-width="80px" size="small">
<el-form-item
v-for="field in store.fields"
:key="field.id"
:label="field.label"
:required="field.required"
>
<el-input
v-model="newParticipantData[field.key]"
:placeholder="`输入${field.label}`"
/>
<el-form-item v-for="field in store.fields" :key="field.id" :label="field.label" :required="field.required">
<el-input v-model="newParticipantData[field.key]" :placeholder="`输入${field.label}`" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addParticipant" style="width: 100%">
添加
</el-button>
<el-button type="primary" @click="addParticipant" style="width: 100%"> 添加 </el-button>
</el-form-item>
</el-form>
</div>
<!-- 参与者表格 -->
<div class="participant-table">
<el-table :data="store.participants" style="width: 100%" stripe max-height="400">
<el-table-column
v-for="field in store.fields"
:key="field.id"
:prop="field.key"
:label="field.label"
:min-width="120"
show-overflow-tooltip
/>
<el-table-column v-for="field in store.fields" :key="field.id" :prop="field.key" :label="field.label" :min-width="120" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="150" align="center">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="showParticipantDetail(row)"
v-if="store.fields.length > 1"
>
详情
</el-button>
<el-button
type="danger"
size="small"
@click="removeParticipant(row.id)"
>
删除
</el-button>
<el-button type="primary" size="small" @click="showParticipantDetail(row)" v-if="store.fields.length > 1"> 详情 </el-button>
<el-button type="danger" size="small" @click="removeParticipant(row.id)"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="participant-count">
{{ store.participants.length }}
</div>
<div class="participant-count"> {{ store.participants.length }} </div>
</el-card>
<!-- 字段配置对话框 -->
<el-dialog v-model="showFieldDialog" title="字段配置" width="600px">
<div class="field-config">
@@ -98,25 +58,14 @@
<el-button type="primary" @click="saveFields">保存</el-button>
</template>
</el-dialog>
<!-- 导入对话框 -->
<el-dialog v-model="showImportDialog" title="导入名单" width="500px">
<el-upload
ref="uploadRef"
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
drag
>
<el-upload ref="uploadRef" :auto-upload="false" :on-change="handleFileChange" :limit="1" accept=".csv" drag>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<div class="el-upload__text"> 拖拽文件到此处或 <em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip">
支持 .csv 格式文件第一行为字段名后续行为数据
</div>
<div class="el-upload__tip"> 支持 .csv 格式文件第一行为字段名后续行为数据 </div>
</template>
</el-upload>
<template #footer>
@@ -153,27 +102,31 @@ const addField = () => {
id: Date.now(),
key: '',
label: '',
required: false
required: false,
})
}
const removeField = (id) => {
const removeField = id => {
const index = tempFields.value.findIndex(f => f.id === id)
if (index > -1) {
tempFields.value.splice(index, 1)
}
}
const saveFields = () => {
const saveFields = async () => {
const validFields = tempFields.value.filter(f => f.key && f.label)
if (validFields.length === 0) {
ElMessage.error('至少需要一个有效字段')
return
}
store.fields.splice(0, store.fields.length, ...validFields)
showFieldDialog.value = false
ElMessage.success('字段配置已保存')
try {
await store.updateFields(validFields)
showFieldDialog.value = false
ElMessage.success('字段配置已保存')
} catch (error) {
ElMessage.error('保存字段配置失败:' + error.message)
}
}
// 名单管理
@@ -189,9 +142,13 @@ const initNewParticipantData = () => {
})
}
watch(() => store.fields, () => {
initNewParticipantData()
}, { deep: true })
watch(
() => store.fields,
() => {
initNewParticipantData()
},
{ deep: true }
)
initNewParticipantData()
@@ -201,33 +158,33 @@ const addParticipant = () => {
ElMessage.error(`请填写必填字段:${missingFields.map(f => f.label).join('、')}`)
return
}
const hasValue = Object.values(newParticipantData.value).some(v => v && v.trim())
if (!hasValue) {
ElMessage.error('请至少填写一个字段')
return
}
store.addParticipant({
id: Date.now(),
...newParticipantData.value
...newParticipantData.value,
})
initNewParticipantData()
ElMessage.success('添加成功')
}
const showParticipantDetail = (person) => {
const showParticipantDetail = person => {
const details = []
store.fields.forEach(field => {
details.push(`${field.label}: ${person[field.key] || '-'}`)
})
const allKeys = Object.keys(person).filter(key => key !== 'id')
const configuredKeys = store.fields.map(f => f.key)
const unconfiguredKeys = allKeys.filter(key => !configuredKeys.includes(key))
if (unconfiguredKeys.length > 0) {
details.push('')
details.push('未配置字段:')
@@ -235,21 +192,21 @@ const showParticipantDetail = (person) => {
details.push(`${key}: ${person[key]}`)
})
}
ElMessage({
message: details.join('\n'),
type: 'info',
duration: 0,
showClose: true
showClose: true,
})
}
const removeParticipant = (id) => {
const removeParticipant = id => {
store.removeParticipant(id)
ElMessage.success('删除成功')
}
const handleFileChange = (file) => {
const handleFileChange = file => {
selectedFile.value = file.raw
}
@@ -279,21 +236,19 @@ const clearParticipants = () => {
ElMessage.warning('名单已经是空的')
return
}
ElMessageBox.confirm(
'确定要清空所有名单吗?此操作不可恢复。',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
store.clearParticipants()
ElMessage.success('已清空名单')
}).catch(() => {
// 用户取消操作
ElMessageBox.confirm('确定要清空所有名单吗?此操作不可恢复。', '确认清空', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
store.clearParticipants()
ElMessage.success('已清空名单')
})
.catch(() => {
// 用户取消操作
})
}
</script>
@@ -449,4 +404,4 @@ const clearParticipants = () => {
box-shadow: var(--shadow-small);
transform: none;
}
</style>
</style>