初始化提交

This commit is contained in:
yuantao
2026-01-15 10:38:00 +08:00
commit 96ccf6430a
58 changed files with 13856 additions and 0 deletions

193
.gitignore vendored Normal file
View File

@@ -0,0 +1,193 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

0
IFLOW.md Normal file
View File

24
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1740
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
admin/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"typescript": "~5.9.3",
"vite": "^7.2.4"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.13.1",
"pinia": "^3.0.4"
}
}

1
admin/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1286
admin/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

9
admin/src/counter.ts Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

23
admin/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
// 引入全局样式
import './styles/variables.css'
import './styles/global.css'
import './styles/components.css'
import './styles/element-plus.css'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

24
admin/src/main.ts Normal file
View File

@@ -0,0 +1,24 @@
import './style.css'
import typescriptLogo from './typescript.svg'
import viteLogo from '/vite.svg'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div>
<a href="https://vite.dev" target="_blank">
<img src="${viteLogo}" class="logo" alt="Vite logo" />
</a>
<a href="https://www.typescriptlang.org/" target="_blank">
<img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
</a>
<h1>Vite + TypeScript</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
<p class="read-the-docs">
Click on the Vite and TypeScript logos to learn more
</p>
</div>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)

491
admin/src/store/index.js Normal file
View File

@@ -0,0 +1,491 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { indexedDB } from '@utils/indexedDB'
export const useLotteryStore = defineStore('lottery', () => {
// 状态
const fields = ref([])
const participants = ref([])
const prizes = ref([])
const rounds = ref([])
const winners = ref([])
const isRolling = ref(false)
const currentRound = ref(null)
const displayMode = ref('scroll')
const backgroundImage = ref('')
const isInitialized = ref(false)
// 初始化 - 从IndexedDB加载数据
const initialize = async () => {
if (isInitialized.value) return
try {
const data = await indexedDB.getAll()
fields.value = data.lottery_fields || []
participants.value = data.lottery_participants || []
prizes.value = data.lottery_prizes || []
rounds.value = data.lottery_rounds || []
winners.value = data.lottery_winners || []
isRolling.value = data.lottery_isRolling === 'true'
currentRound.value = data.lottery_currentRound || null
displayMode.value = data.lottery_displayMode || 'scroll'
backgroundImage.value = data.lottery_backgroundImage || ''
isInitialized.value = true
} catch (error) {
console.error('Failed to initialize from IndexedDB:', error)
// 使用默认值
fields.value = [{key: 'name', label: '姓名', required: true}]
isInitialized.value = true
}
}
// 保存单个数据到IndexedDB
const saveData = async (key, value) => {
try {
console.log(`Saving ${key} to IndexedDB...`)
// 深拷贝数据以避免Vue响应式包装器
const clonedValue = JSON.parse(JSON.stringify(value))
await indexedDB.set(key, clonedValue)
// 触发localStorage事件通知其他标签页
localStorage.setItem('lottery_data_changed', Date.now().toString())
console.log(`Successfully saved ${key}`)
} catch (error) {
console.error(`Failed to save ${key} to IndexedDB:`, error)
throw error
}
}
// ============ 字段管理 ============
const addField = async (field) => {
const newField = {
id: Date.now(),
key: field.key,
label: field.label,
required: field.required || false
}
fields.value.push(newField)
await saveData('lottery_fields', fields.value)
}
const updateField = async (field) => {
const index = fields.value.findIndex(f => f.id === field.id)
if (index > -1) {
fields.value[index] = field
await saveData('lottery_fields', fields.value)
}
}
const removeField = async (id) => {
const index = fields.value.findIndex(f => f.id === id)
if (index > -1) {
fields.value.splice(index, 1)
await saveData('lottery_fields', fields.value)
}
}
// ============ 参与者管理 ============
const addParticipant = async (data) => {
const participant = { id: Date.now(), ...data }
participants.value.push(participant)
await saveData('lottery_participants', participants.value)
}
const updateParticipant = async (id, data) => {
const index = participants.value.findIndex(p => p.id === id)
if (index > -1) {
participants.value[index] = { ...participants.value[index], ...data }
await saveData('lottery_participants', participants.value)
}
}
const removeParticipant = async (id) => {
const index = participants.value.findIndex(p => p.id === id)
if (index > -1) {
participants.value.splice(index, 1)
await saveData('lottery_participants', participants.value)
}
}
const clearParticipants = async () => {
participants.value = []
await saveData('lottery_participants', participants.value)
}
const importParticipantsFromFile = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = async (e) => {
try {
const content = e.target.result
const lines = content.split('\n').map(l => l.trim()).filter(l => l)
// 检测是否是CSV格式包含逗号
const isCSV = lines.some(line => line.includes(','))
if (isCSV) {
// CSV格式导入
const headers = lines[0].split(',').map(h => h.trim())
const data = []
// 自动添加缺失的字段到配置
headers.forEach(header => {
if (!fields.value.some(f => f.key === header)) {
fields.value.push({
id: Date.now() + Math.random(),
key: header,
label: header,
required: false
})
}
})
// 保存更新后的字段配置
await saveData('lottery_fields', fields.value)
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim())
const participant = { id: Date.now() + i }
headers.forEach((header, index) => {
participant[header] = values[index] || ''
})
data.push(participant)
}
participants.value.push(...data)
await saveData('lottery_participants', participants.value)
resolve(data.length)
} else {
// 简单文本格式导入(每行一个姓名)
const data = lines.map(line => ({
id: Date.now() + Math.random(),
name: line
}))
participants.value.push(...data)
await saveData('lottery_participants', participants.value)
resolve(data.length)
}
} catch (error) {
reject(error)
}
}
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsText(file)
})
}
// ============ 奖品管理 ============
const addPrize = async (prize) => {
prizes.value.push({
id: Date.now(),
name: prize.name,
stock: parseInt(prize.stock) || 0,
used: 0
})
await saveData('lottery_prizes', prizes.value)
}
const updatePrize = async (prize) => {
const index = prizes.value.findIndex(p => p.id === prize.id)
if (index > -1) {
prizes.value[index] = prize
await saveData('lottery_prizes', prizes.value)
}
}
const removePrize = async (id) => {
const index = prizes.value.findIndex(p => p.id === id)
if (index > -1) {
prizes.value.splice(index, 1)
await saveData('lottery_prizes', prizes.value)
}
}
const getPrizeAvailable = (prizeId) => {
const prize = prizes.value.find(p => p.id === prizeId)
return prize ? prize.stock - prize.used : 0
}
// ============ 轮次管理 ============
const addRound = async (round) => {
rounds.value.push({
id: Date.now(),
name: round.name,
prizeId: round.prizeId,
count: parseInt(round.count) || 1,
completed: false
})
await saveData('lottery_rounds', rounds.value)
}
const updateRound = async (round) => {
const index = rounds.value.findIndex(r => r.id === round.id)
if (index > -1) {
rounds.value[index] = round
await saveData('lottery_rounds', rounds.value)
}
}
const removeRound = async (id) => {
const index = rounds.value.findIndex(r => r.id === id)
if (index > -1) {
rounds.value.splice(index, 1)
await saveData('lottery_rounds', rounds.value)
}
}
const getPrizeForRound = (roundId) => {
const round = rounds.value.find(r => r.id === roundId)
if (!round) return null
return prizes.value.find(p => p.id === round.prizeId)
}
// ============ 抽奖控制 ============
const startLottery = async (round) => {
const prize = getPrizeForRound(round.id)
if (!prize) {
throw new Error('奖品不存在')
}
if (prize.used >= prize.stock) {
throw new Error('奖品库存不足')
}
if (round.completed) {
throw new Error('该轮次已完成')
}
if (participants.value.length === 0) {
throw new Error('参与者名单为空')
}
currentRound.value = round
isRolling.value = true
displayMode.value = 'scroll'
await saveData('lottery_currentRound', currentRound.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_displayMode', displayMode.value)
}
const stopLottery = async () => {
if (!currentRound.value) return
const round = rounds.value.find(r => r.id === currentRound.value.id)
const prize = prizes.value.find(p => p.id === round.prizeId)
// 获取可用的参与者(未中奖)
const availableParticipants = participants.value.filter(
p => !winners.value.some(w => w.id === p.id)
)
// 计算本次抽奖人数
const maxAvailable = prize.stock - prize.used
const roundCount = Math.min(round.count, maxAvailable, availableParticipants.length)
if (roundCount === 0) {
isRolling.value = false
displayMode.value = 'result'
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_displayMode', displayMode.value)
return
}
// 随机抽取
const shuffled = [...availableParticipants].sort(() => Math.random() - 0.5)
const newWinners = shuffled.slice(0, roundCount)
// 记录中奖者
newWinners.forEach(participant => {
winners.value.push({
id: participant.id,
...participant,
prizeId: prize.id,
prizeName: prize.name,
roundId: round.id,
roundName: round.name,
time: new Date().toISOString()
})
})
// 更新奖品使用量
prize.used += roundCount
// 更新轮次状态
round.completed = roundCount >= round.count || prize.used >= prize.stock
isRolling.value = false
displayMode.value = 'result'
await saveData('lottery_winners', winners.value)
await saveData('lottery_prizes', prizes.value)
await saveData('lottery_rounds', rounds.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_displayMode', displayMode.value)
}
const resetLottery = async () => {
winners.value = []
isRolling.value = false
currentRound.value = null
displayMode.value = 'scroll'
// 重置奖品使用量
prizes.value.forEach(p => {
p.used = 0
})
// 重置轮次状态
rounds.value.forEach(r => {
r.completed = false
})
await saveData('lottery_winners', winners.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_currentRound', currentRound.value)
await saveData('lottery_displayMode', displayMode.value)
await saveData('lottery_prizes', prizes.value)
await saveData('lottery_rounds', rounds.value)
}
// ============ 导出功能 ============
const exportWinners = () => {
if (winners.value.length === 0) return
// 获取所有字段
const participantFields = Object.keys(winners.value[0]).filter(
key => !['id', 'prizeId', 'prizeName', 'roundId', 'roundName', 'time'].includes(key)
)
const headers = [...participantFields, '奖品', '轮次', '时间']
const rows = winners.value.map(w => {
const row = {}
participantFields.forEach(field => {
row[field] = w[field] || ''
})
row['奖品'] = w.prizeName
row['轮次'] = w.roundName
row['时间'] = new Date(w.time).toLocaleString()
return row
})
// 转换为CSV格式
const csv = [headers.join(','), ...rows.map(row => headers.map(h => row[h]).join(','))].join('\n')
// 创建下载
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `中奖名单_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(url)
}
const exportParticipants = () => {
if (participants.value.length === 0) return
// 获取所有字段
const allKeys = new Set()
participants.value.forEach(p => {
Object.keys(p).forEach(key => {
if (key !== 'id') allKeys.add(key)
})
})
const headers = Array.from(allKeys)
const rows = participants.value.map(p => headers.map(h => p[h] || '').join(','))
const csv = [headers.join(','), ...rows].join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `抽奖名单_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(url)
}
// ============ 显示控制 ============
const switchDisplayMode = async (mode) => {
displayMode.value = mode
await saveData('lottery_displayMode', displayMode.value)
}
const setBackgroundImage = async (imageData) => {
try {
console.log('setBackgroundImage called with image data length:', imageData ? imageData.length : 0)
await saveData('lottery_backgroundImage', imageData)
backgroundImage.value = imageData
console.log('setBackgroundImage completed successfully')
} catch (error) {
console.error('Failed to save background image:', error)
throw new Error('保存背景图片失败')
}
}
const clearBackgroundImage = async () => {
try {
await indexedDB.delete('lottery_backgroundImage')
backgroundImage.value = ''
} catch (error) {
console.error('Failed to clear background image:', error)
throw new Error('清除背景图片失败')
}
}
return {
// 状态
fields,
participants,
prizes,
rounds,
winners,
isRolling,
currentRound,
displayMode,
backgroundImage,
isInitialized,
// 初始化
initialize,
// 字段管理
addField,
updateField,
removeField,
// 参与者管理
addParticipant,
updateParticipant,
removeParticipant,
clearParticipants,
importParticipantsFromFile,
// 奖品管理
addPrize,
updatePrize,
removePrize,
getPrizeAvailable,
// 轮次管理
addRound,
updateRound,
removeRound,
getPrizeForRound,
// 抽奖控制
startLottery,
stopLottery,
resetLottery,
// 导出功能
exportWinners,
exportParticipants,
// 显示控制
switchDisplayMode,
setBackgroundImage,
clearBackgroundImage
}
})

97
admin/src/style.css Normal file
View File

@@ -0,0 +1,97 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
padding: 0;
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,249 @@
/* 组件样式 */
/* ===== 按钮组件 ===== */
.el-button {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius-md);
transition: var(--transition-normal);
box-shadow: var(--shadow-small);
}
.el-button--primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.el-button--primary:hover {
background-color: var(--color-secondary);
border-color: var(--color-secondary);
box-shadow: var(--shadow-button);
}
.el-button--success {
background-color: var(--color-success);
border-color: var(--color-success);
}
.el-button--warning {
background-color: var(--color-warning);
border-color: var(--color-warning);
}
.el-button--danger {
background-color: var(--color-danger);
border-color: var(--color-danger);
}
/* ===== 卡片组件 ===== */
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-medium);
border: 1px solid var(--color-border);
}
.el-card__header {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
padding: var(--spacing-xl);
border-bottom: 1px solid var(--color-border);
}
.el-card__body {
padding: var(--spacing-xl);
}
/* ===== 表单组件 ===== */
.el-form-item__label {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.el-input__wrapper {
border-radius: var(--border-radius-md);
transition: var(--transition-normal);
}
.el-input__wrapper:hover {
box-shadow: var(--shadow-small);
}
.el-input__wrapper.is-focus {
box-shadow: var(--shadow-medium);
}
/* ===== 表格组件 ===== */
.el-table {
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: var(--color-primary);
color: var(--color-text-white);
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
}
.el-table tr {
transition: var(--transition-background);
}
.el-table--enable-row-hover .el-table__body tr:hover > td {
background-color: rgba(var(--color-secondary-rgb), 0.1);
}
/* ===== 对话框组件 ===== */
.el-dialog {
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-large);
}
.el-dialog__header {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-xl);
color: var(--color-text-primary);
padding: var(--spacing-2xl);
border-bottom: 1px solid var(--color-border);
}
.el-dialog__body {
padding: var(--spacing-2xl);
}
.el-dialog__footer {
padding: var(--spacing-xl) var(--spacing-2xl);
border-top: 1px solid var(--color-border);
}
/* ===== 标签组件 ===== */
.el-tag {
border-radius: var(--border-radius-md);
font-weight: var(--font-weight-medium);
}
.el-tag--success {
background-color: rgba(var(--color-success), 0.1);
color: var(--color-success);
border-color: var(--color-success);
}
.el-tag--warning {
background-color: rgba(var(--color-warning), 0.1);
color: var(--color-warning);
border-color: var(--color-warning);
}
.el-tag--danger {
background-color: rgba(var(--color-danger), 0.1);
color: var(--color-danger);
border-color: var(--color-danger);
}
/* ===== 消息提示组件 ===== */
.el-message {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
font-family: var(--font-family-primary);
}
.el-message--success {
background-color: var(--color-success);
border-color: var(--color-success);
}
.el-message--warning {
background-color: var(--color-warning);
border-color: var(--color-warning);
}
.el-message--error {
background-color: var(--color-danger);
border-color: var(--color-danger);
}
/* ===== 自定义卡片样式 ===== */
.custom-card {
background: var(--color-background);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-medium);
padding: var(--spacing-2xl);
transition: var(--transition-normal);
}
.custom-card:hover {
box-shadow: var(--shadow-large);
transform: translateY(-2px);
}
/* ===== 标题样式 ===== */
.page-title {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-3xl);
color: var(--color-primary);
margin-bottom: var(--spacing-xl);
}
.section-title {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-xl);
color: var(--color-text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-sm);
border-bottom: 2px solid var(--color-secondary);
}
/* ===== 统计卡片 ===== */
.stat-card {
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
color: var(--color-text-white);
border-radius: var(--border-radius-xl);
padding: var(--spacing-2xl);
box-shadow: var(--shadow-large);
}
.stat-card .stat-value {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-sm);
}
.stat-card .stat-label {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-md);
opacity: 0.9;
}
/* ===== 操作按钮组 ===== */
.action-buttons {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
/* ===== 表单区域 ===== */
.form-section {
background: var(--color-background);
border-radius: var(--border-radius-lg);
padding: var(--spacing-2xl);
box-shadow: var(--shadow-medium);
margin-bottom: var(--spacing-xl);
}
.form-section-title {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
}

View File

@@ -0,0 +1,567 @@
/* Element Plus 组件样式覆盖 - 基于参考项目设计 */
/* ===== 按钮组件 ===== */
.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
border: 1px solid transparent;
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-primary);
text-decoration: none;
cursor: pointer;
transition: var(--transition-color), var(--transition-background), var(--transition-border), var(--transition-box-shadow), var(--transition-transform);
background: none;
color: var(--color-text-primary);
padding: var(--spacing-lg) var(--spacing-xl);
}
.el-button:focus {
outline: 2px solid var(--color-secondary);
outline-offset: 2px;
}
.el-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.el-button--primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-white);
box-shadow: var(--shadow-button);
}
.el-button--primary:hover {
background: var(--color-primary);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-primary-rgb), 0.4);
}
.el-button--primary:active {
transform: translateY(0);
box-shadow: var(--shadow-button);
}
.el-button--success {
background: var(--color-success);
border-color: var(--color-success);
color: var(--color-text-white);
box-shadow: 0 4px 16px rgba(var(--color-success-rgb), 0.3);
}
.el-button--success:hover {
background: var(--color-success);
border-color: var(--color-success);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-success-rgb), 0.4);
}
.el-button--success:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(var(--color-success-rgb), 0.3);
}
.el-button--danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: var(--color-text-white);
box-shadow: 0 4px 16px rgba(var(--color-danger-rgb), 0.3);
}
.el-button--danger:hover {
background: var(--color-danger);
border-color: var(--color-danger);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-danger-rgb), 0.4);
}
.el-button--danger:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(var(--color-danger-rgb), 0.3);
}
.el-button--warning {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-white);
box-shadow: 0 4px 16px rgba(var(--color-warning-rgb), 0.3);
}
.el-button--warning:hover {
background: var(--color-warning);
border-color: var(--color-warning);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-warning-rgb), 0.4);
}
.el-button--warning:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(var(--color-warning-rgb), 0.3);
}
.el-button--info {
background: var(--color-info);
border-color: var(--color-info);
color: var(--color-text-white);
box-shadow: 0 4px 16px rgba(var(--color-info-rgb), 0.3);
}
.el-button--info:hover {
background: var(--color-info);
border-color: var(--color-info);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--color-info-rgb), 0.4);
}
.el-button--info:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(var(--color-info-rgb), 0.3);
}
.el-button--default {
background: var(--color-background);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.el-button--default:hover {
background: rgba(var(--color-primary-rgb), 0.1);
border-color: var(--color-primary);
color: var(--color-primary);
}
.el-button--default:active {
transform: translateY(0);
}
.el-button--small {
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
height: var(--button-height-sm);
}
.el-button--small.el-button--primary:hover,
.el-button--small.el-button--success:hover,
.el-button--small.el-button--info:hover,
.el-button--small.el-button--warning:hover,
.el-button--small.el-button--danger:hover {
transform: translateY(-1px);
}
.el-button--small.el-button--primary:active,
.el-button--small.el-button--success:active,
.el-button--small.el-button--info:active,
.el-button--small.el-button--warning:active,
.el-button--small.el-button--danger:active {
transform: translateY(0);
}
.el-button.is-circle {
border-radius: var(--border-radius-full);
}
.el-button.is-link {
border: none;
background: transparent;
box-shadow: none;
padding: var(--spacing-sm) var(--spacing-md);
}
.el-button.is-link:hover {
background: transparent;
transform: none;
box-shadow: none;
}
.el-button--text {
border: none;
background: transparent;
box-shadow: none;
color: var(--color-text-primary);
}
.el-button--text:hover {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
transform: none;
}
/* ===== 卡片组件 ===== */
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
border: 1px solid var(--color-border);
}
.el-card__header {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
padding: var(--spacing-xl);
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
}
.el-card__body {
padding: var(--spacing-xl);
background: var(--color-background);
}
/* ===== 表格组件 ===== */
.el-table {
border-radius: var(--border-radius-lg);
overflow: hidden;
box-shadow: var(--shadow-small);
font-family: var(--font-family-primary);
}
.el-table .el-table__header-wrapper {
background: var(--color-background);
}
.el-table th {
background: var(--color-background);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-secondary);
border-bottom: 1px solid var(--color-border);
}
.el-table td {
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-base);
}
.el-table .el-table__row:hover > td {
background: rgba(var(--color-primary-rgb), 0.05);
}
.el-table .el-table__row {
transition: var(--transition-background);
}
/* ===== 对话框组件 ===== */
.el-dialog {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
overflow: hidden;
}
.el-dialog__header {
background: var(--color-primary);
color: var(--color-text-white);
padding: var(--spacing-2xl);
border-bottom: none;
}
.el-dialog__title {
color: var(--color-text-white);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-secondary);
}
.el-dialog__headerbtn {
position: absolute;
top: 50%;
right: var(--spacing-lg);
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-full);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
transition: var(--transition-background), var(--transition-transform);
}
.el-dialog__headerbtn .el-dialog__close {
color: var(--color-text-white);
font-size: 16px;
font-weight: var(--font-weight-bold);
}
.el-dialog__headerbtn:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-50%) scale(1.1);
}
.el-dialog__body {
padding: var(--spacing-2xl);
}
.el-dialog__footer {
padding: var(--spacing-lg) var(--spacing-2xl) var(--spacing-2xl);
border-top: 1px solid var(--color-border);
text-align: right;
}
/* ===== 表单组件 ===== */
.el-form-item__label {
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
}
.el-input__wrapper {
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
background: var(--color-background);
transition: var(--transition-border), var(--transition-box-shadow);
box-shadow: none;
}
.el-input__wrapper:hover {
border-color: var(--color-primary);
box-shadow: none;
}
.el-input__wrapper.is-focus {
border-color: var(--color-primary);
box-shadow: var(--shadow-small);
}
.el-input__inner::placeholder {
color: var(--color-text-light);
}
.el-textarea__inner {
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
background: var(--color-background);
transition: var(--transition-border), var(--transition-box-shadow);
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
color: var(--color-text-primary);
line-height: var(--line-height-relaxed);
box-shadow: none;
}
.el-textarea__inner:hover {
border-color: var(--color-primary);
box-shadow: none;
}
.el-textarea__inner:focus {
border-color: var(--color-primary);
box-shadow: var(--shadow-small);
}
.el-textarea__inner::placeholder {
color: var(--color-text-light);
}
/* ===== 选择器组件 ===== */
.el-select .el-input__wrapper {
border-radius: var(--border-radius-md);
box-shadow: none;
}
.el-select-dropdown {
border-radius: var(--border-radius-lg);
border: none;
box-shadow: var(--shadow-large);
padding: var(--spacing-md);
}
.el-select-dropdown__item {
border-radius: var(--border-radius-md);
padding: var(--spacing-lg) var(--spacing-xl);
transition: var(--transition-color), var(--transition-background);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.el-select-dropdown__item:hover {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
}
.el-select-dropdown__item.is-selected {
background: var(--color-primary);
color: var(--color-text-white);
}
/* ===== 数字输入框 ===== */
.el-input-number {
width: 100px;
}
.el-input-number .el-input__wrapper {
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
background: var(--color-background);
transition: var(--transition-border), var(--transition-box-shadow);
box-shadow: none;
}
.el-input-number .el-input__wrapper:hover {
border-color: var(--color-primary);
box-shadow: none;
}
.el-input-number .el-input__wrapper.is-focus {
border-color: var(--color-primary);
box-shadow: var(--shadow-small);
}
/* ===== 上传组件 ===== */
.el-upload-dragger {
border-radius: var(--border-radius-lg);
border: 2px dashed var(--color-border);
transition: var(--transition-border), var(--transition-background);
}
.el-upload-dragger:hover {
border-color: var(--color-primary);
background: rgba(var(--color-primary-rgb), 0.05);
}
/* ===== 标签组件 ===== */
.el-tag {
border-radius: var(--border-radius-full);
font-weight: var(--font-weight-medium);
font-family: var(--font-family-primary);
}
.el-tag--success {
background: var(--color-success);
border-color: var(--color-success);
color: var(--color-text-white);
}
.el-tag--info {
background: var(--color-info);
border-color: var(--color-info);
color: var(--color-text-white);
}
.el-tag--warning {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-white);
}
.el-tag--danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: var(--color-text-white);
}
/* ===== 消息提示 ===== */
.el-message {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
font-family: var(--font-family-primary);
}
.el-message--success {
background: var(--color-success);
border-color: var(--color-success);
}
.el-message--warning {
background: var(--color-warning);
border-color: var(--color-warning);
}
.el-message--error {
background: var(--color-danger);
border-color: var(--color-danger);
}
.el-message--info {
background: var(--color-info);
border-color: var(--color-info);
}
/* ===== 复选框 ===== */
.el-checkbox {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
.el-checkbox__input.is-checked .el-checkbox__inner {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.el-checkbox__inner:hover {
border-color: var(--color-primary);
}
/* ===== 分页 ===== */
.el-pagination {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
}
.el-pagination .el-pager li {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
border-radius: var(--border-radius-sm);
margin: 0 2px;
transition: var(--transition-color), var(--transition-background);
}
.el-pagination .el-pager li.active {
background: var(--color-primary);
color: var(--color-text-white);
}
.el-pagination .el-pager li:hover {
color: var(--color-text-primary);
}
.el-pagination button {
border-radius: var(--border-radius-sm);
}
/* ===== 开关 ===== */
.el-switch {
--el-switch-on-color: var(--color-success);
}
.el-switch__core {
border-radius: var(--border-radius-full);
}
/* ===== 下拉菜单 ===== */
.el-dropdown-menu {
border-radius: var(--border-radius-lg);
border: none;
box-shadow: var(--shadow-large);
padding: var(--spacing-md);
background: var(--color-background);
overflow: hidden;
font-family: var(--font-family-primary);
}
.el-dropdown-menu__item {
border-radius: var(--border-radius-md);
padding: var(--spacing-lg) var(--spacing-xl);
transition: var(--transition-color), var(--transition-background);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.el-dropdown-menu__item:hover {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
}
.el-dropdown-menu__item.is-divided {
border-top: 1px solid rgba(0, 0, 0, 0.06);
margin-top: 8px;
padding-top: 16px;
}

322
admin/src/styles/global.css Normal file
View File

@@ -0,0 +1,322 @@
/* 全局基础样式 */
/* ===== 重置样式 ===== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
background-color: var(--color-background);
overflow-x: hidden;
}
/* ===== 通用样式 ===== */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-3xl);
}
h3 {
font-size: var(--font-size-2xl);
}
h4 {
font-size: var(--font-size-xl);
}
h5 {
font-size: var(--font-size-lg);
}
h6 {
font-size: var(--font-size-md);
}
p {
margin-bottom: var(--spacing-md);
color: var(--color-text-secondary);
}
a {
color: var(--color-secondary);
text-decoration: none;
transition: var(--transition-color);
}
a:hover {
color: var(--color-primary);
}
ul, ol {
margin-bottom: var(--spacing-md);
padding-left: var(--spacing-2xl);
}
li {
margin-bottom: var(--spacing-sm);
color: var(--color-text-secondary);
}
img {
max-width: 100%;
height: auto;
display: block;
}
/* ===== 工具类 ===== */
.container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-muted {
color: var(--color-text-light);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
.font-semibold {
font-weight: var(--font-weight-semibold);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
/* ===== 间距工具类 ===== */
.m-0 { margin: 0; }
.mt-0 { margin-top: 0; }
.mr-0 { margin-right: 0; }
.mb-0 { margin-bottom: 0; }
.ml-0 { margin-left: 0; }
.mx-0 { margin-left: 0; margin-right: 0; }
.my-0 { margin-top: 0; margin-bottom: 0; }
.m-1 { margin: var(--spacing-xs); }
.mt-1 { margin-top: var(--spacing-xs); }
.mr-1 { margin-right: var(--spacing-xs); }
.mb-1 { margin-bottom: var(--spacing-xs); }
.ml-1 { margin-left: var(--spacing-xs); }
.mx-1 { margin-left: var(--spacing-xs); margin-right: var(--spacing-xs); }
.my-1 { margin-top: var(--spacing-xs); margin-bottom: var(--spacing-xs); }
.m-2 { margin: var(--spacing-sm); }
.mt-2 { margin-top: var(--spacing-sm); }
.mr-2 { margin-right: var(--spacing-sm); }
.mb-2 { margin-bottom: var(--spacing-sm); }
.ml-2 { margin-left: var(--spacing-sm); }
.mx-2 { margin-left: var(--spacing-sm); margin-right: var(--spacing-sm); }
.my-2 { margin-top: var(--spacing-sm); margin-bottom: var(--spacing-sm); }
.m-3 { margin: var(--spacing-md); }
.mt-3 { margin-top: var(--spacing-md); }
.mr-3 { margin-right: var(--spacing-md); }
.mb-3 { margin-bottom: var(--spacing-md); }
.ml-3 { margin-left: var(--spacing-md); }
.mx-3 { margin-left: var(--spacing-md); margin-right: var(--spacing-md); }
.my-3 { margin-top: var(--spacing-md); margin-bottom: var(--spacing-md); }
.m-4 { margin: var(--spacing-lg); }
.mt-4 { margin-top: var(--spacing-lg); }
.mr-4 { margin-right: var(--spacing-lg); }
.mb-4 { margin-bottom: var(--spacing-lg); }
.ml-4 { margin-left: var(--spacing-lg); }
.mx-4 { margin-left: var(--spacing-lg); margin-right: var(--spacing-lg); }
.my-4 { margin-top: var(--spacing-lg); margin-bottom: var(--spacing-lg); }
.p-0 { padding: 0; }
.pt-0 { padding-top: 0; }
.pr-0 { padding-right: 0; }
.pb-0 { padding-bottom: 0; }
.pl-0 { padding-left: 0; }
.px-0 { padding-left: 0; padding-right: 0; }
.py-0 { padding-top: 0; padding-bottom: 0; }
.p-1 { padding: var(--spacing-xs); }
.pt-1 { padding-top: var(--spacing-xs); }
.pr-1 { padding-right: var(--spacing-xs); }
.pb-1 { padding-bottom: var(--spacing-xs); }
.pl-1 { padding-left: var(--spacing-xs); }
.px-1 { padding-left: var(--spacing-xs); padding-right: var(--spacing-xs); }
.py-1 { padding-top: var(--spacing-xs); padding-bottom: var(--spacing-xs); }
.p-2 { padding: var(--spacing-sm); }
.pt-2 { padding-top: var(--spacing-sm); }
.pr-2 { padding-right: var(--spacing-sm); }
.pb-2 { padding-bottom: var(--spacing-sm); }
.pl-2 { padding-left: var(--spacing-sm); }
.px-2 { padding-left: var(--spacing-sm); padding-right: var(--spacing-sm); }
.py-2 { padding-top: var(--spacing-sm); padding-bottom: var(--spacing-sm); }
.p-3 { padding: var(--spacing-md); }
.pt-3 { padding-top: var(--spacing-md); }
.pr-3 { padding-right: var(--spacing-md); }
.pb-3 { padding-bottom: var(--spacing-md); }
.pl-3 { padding-left: var(--spacing-md); }
.px-3 { padding-left: var(--spacing-md); padding-right: var(--spacing-md); }
.py-3 { padding-top: var(--spacing-md); padding-bottom: var(--spacing-md); }
.p-4 { padding: var(--spacing-lg); }
.pt-4 { padding-top: var(--spacing-lg); }
.pr-4 { padding-right: var(--spacing-lg); }
.pb-4 { padding-bottom: var(--spacing-lg); }
.pl-4 { padding-left: var(--spacing-lg); }
.px-4 { padding-left: var(--spacing-lg); padding-right: var(--spacing-lg); }
.py-4 { padding-top: var(--spacing-lg); padding-bottom: var(--spacing-lg); }
/* ===== Flexbox 工具类 ===== */
.d-flex {
display: flex;
}
.flex-row {
flex-direction: row;
}
.flex-column {
flex-direction: column;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.align-start {
align-items: flex-start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: flex-end;
}
.flex-wrap {
flex-wrap: wrap;
}
.flex-1 {
flex: 1;
}
/* ===== 滚动条样式 ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-border);
border-radius: var(--border-radius-sm);
}
::-webkit-scrollbar-thumb {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
transition: var(--transition-background);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
/* ===== Vue 应用样式 ===== */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--transition-normal);
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all var(--transition-normal) ease-out;
}
.slide-fade-leave-active {
transition: all var(--transition-fast) ease-in;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(10px);
opacity: 0;
}

View File

@@ -0,0 +1,163 @@
/* 全局样式变量 - 基于琳盛网站设计系统 */
/* ===== 色彩系统 ===== */
:root {
/* 主色调 - 深棕红色 */
--color-primary: rgb(29, 17, 17);
--color-primary-rgb: 29, 17, 17;
/* 次要色 - 青绿色 */
--color-secondary: rgb(0, 165, 141);
--color-secondary-rgb: 0, 165, 141;
/* 强调色 - 深蓝色 */
--color-accent: rgb(34, 37, 121);
--color-accent-rgb: 34, 37, 121;
/* 背景色 */
--color-background: rgb(255, 255, 255);
--color-background-rgb: 255, 255, 255;
/* 文字颜色 */
--color-text-primary: rgb(29, 17, 17);
--color-text-secondary: rgb(64, 64, 64);
--color-text-light: rgb(120, 120, 120);
--color-text-white: rgb(255, 255, 255);
/* 边框颜色 */
--color-border: rgb(225, 225, 225);
--color-border-light: rgba(0, 0, 0, 0.1);
/* 状态颜色 */
--color-success: rgb(0, 165, 141);
--color-warning: rgb(243, 156, 18);
--color-danger: rgb(220, 53, 69);
--color-info: rgb(23, 162, 184);
/* 阴影 */
--shadow-small: 0 2px 8px rgba(0, 0, 0, 0.05);
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-large: 0 8px 24px rgba(0, 0, 0, 0.15);
--shadow-button: 0 4px 16px rgba(var(--color-primary-rgb), 0.3);
}
/* ===== 字体系统 ===== */
:root {
/* 主字体 - Poppins */
--font-family-primary: 'Poppins', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 辅助字体 - Montserrat */
--font-family-secondary: 'Montserrat', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 等宽字体 */
--font-family-mono: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
/* 字体大小 */
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 28px;
--font-size-4xl: 32px;
/* 字体粗细 */
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 行高 */
--line-height-tight: 1.2;
--line-height-normal: 1.4;
--line-height-relaxed: 1.6;
--line-height-loose: 1.8;
}
/* ===== 间距系统 ===== */
:root {
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--spacing-3xl: 32px;
--spacing-4xl: 40px;
--spacing-5xl: 48px;
}
/* ===== 圆角系统 ===== */
:root {
--border-radius-none: 0px;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
--border-radius-2xl: 20px;
--border-radius-full: 50%;
}
/* ===== 过渡动画 ===== */
:root {
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* 简化的过渡效果 */
--transition-color: color var(--transition-normal);
--transition-background: background-color var(--transition-normal);
--transition-border: border-color var(--transition-normal);
--transition-transform: transform var(--transition-normal);
--transition-box-shadow: box-shadow var(--transition-normal);
}
/* ===== 断点系统 ===== */
:root {
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-2xl: 1400px;
}
/* ===== Z-index 层级 ===== */
:root {
--z-index-dropdown: 1000;
--z-index-sticky: 1020;
--z-index-fixed: 1030;
--z-index-modal-backdrop: 1040;
--z-index-modal: 1050;
--z-index-popover: 1060;
--z-index-tooltip: 1070;
}
/* ===== 组件特定变量 ===== */
:root {
/* 按钮高度 */
--button-height-sm: 32px;
--button-height-md: 40px;
--button-height-lg: 48px;
--button-height-xl: 56px;
/* 输入框高度 */
--input-height-sm: 32px;
--input-height-md: 40px;
--input-height-lg: 48px;
/* 侧边栏 */
--sidebar-width-collapsed: 80px;
--sidebar-width-expanded: 260px;
--sidebar-height: 80px;
/* 头部导航 */
--header-height: 108px;
/* 卡片 */
--card-padding: var(--spacing-2xl);
--card-border-radius: var(--border-radius-lg);
--card-shadow: var(--shadow-medium);
}

1
admin/src/typescript.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

26
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
admin/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@utils': path.resolve(__dirname, '../utils')
}
}
})

24
display/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
display/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>display</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1739
display/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
display/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "display",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"typescript": "~5.9.3",
"vite": "^7.2.4"
},
"dependencies": {
"element-plus": "^2.13.1",
"pinia": "^3.0.4"
}
}

1
display/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

451
display/src/App.vue Normal file
View File

@@ -0,0 +1,451 @@
<template>
<div class="display-container" @keydown="handleKeydown" tabindex="0">
<!-- 快捷键提示 -->
<div class="shortcut-hints" v-if="!store.isRolling">
<div class="hint-item">
<span class="key"> </span>
<span class="desc">切换轮次</span>
</div>
<div class="hint-item">
<span class="key">Space</span>
<span class="desc">开始/停止</span>
</div>
</div>
<!-- 当前轮次信息 -->
<div class="current-round-info" v-if="selectedRound">
<div class="round-name">{{ selectedRound.name }}</div>
<div class="prize-name">{{ getPrizeName(selectedRound.prizeId) }}</div>
</div>
<!-- 滚动模式 -->
<div v-if="store.displayMode === 'scroll'" class="scroll-mode">
<div class="scroll-list" :style="{ transform: `translateY(${scrollOffset}px)` }">
<div
v-for="(person, index) in displayList"
:key="person.id || index"
class="scroll-item"
:class="{ 'highlight': index === highlightIndex }"
>
{{ getParticipantName(person) }}
</div>
</div>
</div>
<!-- 抽奖结果模式 -->
<div v-else class="result-mode">
<div class="winners-list">
<div
v-for="(winner, index) in currentWinners"
:key="winner.id || index"
class="winner-item"
:style="{ animationDelay: `${index * 0.2}s` }"
>
<div class="winner-name">{{ getParticipantName(winner) }}</div>
<div class="winner-info" v-if="store.fields.length > 1">
{{ getParticipantDetails(winner) }}
</div>
</div>
</div>
</div>
<!-- 状态指示器 -->
<div class="status-indicator" :class="{ 'active': store.isRolling }">
{{ store.isRolling ? '抽奖中...' : '等待抽奖' }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useLotteryStore } from './store'
const store = useLotteryStore()
const selectedRound = ref(null)
const scrollOffset = ref(0)
const highlightIndex = ref(0)
let scrollInterval = null
let highlightInterval = null
const displayList = computed(() => {
if (store.participants.length === 0) return ['暂无名单']
return store.participants
})
const getPrizeName = (prizeId) => {
const prize = store.prizes.find(p => p.id === prizeId)
return prize ? prize.name : ''
}
const getParticipantName = (person) => {
if (typeof person === 'string') return person
// 首先尝试从配置的字段中查找 name 字段
const nameField = store.fields.find(f => f.key === 'name')
if (nameField && person[nameField.key]) {
return person[nameField.key]
}
// 如果没有 name 字段,尝试从配置的字段中找第一个有值的
const firstFieldWithValue = store.fields.find(f => person[f.key])
if (firstFieldWithValue) {
return person[firstFieldWithValue.key]
}
// 如果配置的字段都没有值,尝试从参与者对象的所有字段中找
const allKeys = Object.keys(person).filter(key => key !== 'id')
if (allKeys.length > 0) {
// 优先找包含 "name" 的字段
const nameKey = allKeys.find(key => key.toLowerCase().includes('name'))
if (nameKey) {
return person[nameKey]
}
// 否则返回第一个字段的值
return person[allKeys[0]]
}
return JSON.stringify(person)
}
const getParticipantDetails = (person) => {
const details = []
// 首先显示配置的字段
store.fields.forEach(field => {
if (field.key !== 'name' && person[field.key]) {
details.push(`${field.label}: ${person[field.key]}`)
}
})
// 如果配置的字段没有显示任何内容,尝试显示所有字段
if (details.length === 0) {
const allKeys = Object.keys(person).filter(key => key !== 'id')
allKeys.forEach(key => {
if (key.toLowerCase() !== 'name' && person[key]) {
details.push(`${key}: ${person[key]}`)
}
})
}
return details.join(' | ')
}
const currentWinners = computed(() => {
if (!store.currentRound) return []
return store.winners
.filter(w => w.roundId === store.currentRound.id)
.sort((a, b) => new Date(a.time) - new Date(b.time))
})
const handleKeydown = (e) => {
// 空格键:开始/停止抽奖
if (e.code === 'Space') {
e.preventDefault()
if (store.isRolling) {
handleStopLottery()
} else if (selectedRound.value && !selectedRound.value.completed) {
handleStartLottery()
}
}
// 左右方向键:切换轮次
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
e.preventDefault()
if (store.rounds.length === 0) return
let currentIndex = -1
if (selectedRound.value) {
currentIndex = store.rounds.findIndex(r => r.id === selectedRound.value.id)
}
if (e.code === 'ArrowLeft') {
// 向左切换
currentIndex = currentIndex <= 0 ? store.rounds.length - 1 : currentIndex - 1
} else {
// 向右切换
currentIndex = currentIndex >= store.rounds.length - 1 ? 0 : currentIndex + 1
}
selectRound(store.rounds[currentIndex])
}
}
const selectRound = (round) => {
selectedRound.value = round
}
const handleStartLottery = () => {
if (!selectedRound.value) return
try {
store.startLottery(selectedRound.value)
startScroll()
} catch (error) {
alert(error.message)
}
}
const handleStopLottery = () => {
store.stopLottery()
stopScroll()
}
const startScroll = () => {
if (scrollInterval) clearInterval(scrollInterval)
if (highlightInterval) clearInterval(highlightInterval)
scrollInterval = setInterval(() => {
scrollOffset.value -= 2
if (Math.abs(scrollOffset.value) >= 60) {
scrollOffset.value = 0
}
}, 50)
highlightInterval = setInterval(() => {
highlightIndex.value = (highlightIndex.value + 1) % displayList.value.length
}, 300)
}
const stopScroll = () => {
if (scrollInterval) {
clearInterval(scrollInterval)
scrollInterval = null
}
if (highlightInterval) {
clearInterval(highlightInterval)
highlightInterval = null
}
}
onMounted(() => {
// 自动聚焦以便接收键盘事件
document.querySelector('.display-container')?.focus()
if (store.displayMode === 'scroll' && store.isRolling) {
startScroll()
}
})
onUnmounted(() => {
stopScroll()
store.stopSync()
})
</script>
<style scoped>
.display-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
outline: none;
font-family: var(--font-family-primary);
}
.display-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: v-bind('store.backgroundImage ? `url(${store.backgroundImage})` : "none"');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
}
.display-container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1;
}
.display-container > * {
position: relative;
z-index: 2;
}
/* 快捷键提示 */
.shortcut-hints {
position: fixed;
top: var(--spacing-xl);
left: var(--spacing-xl);
display: flex;
gap: var(--spacing-lg);
z-index: 100;
}
.hint-item {
background: rgba(255, 255, 255, 0.15);
border-radius: var(--border-radius-md);
padding: var(--spacing-md) var(--spacing-lg);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: var(--spacing-md);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.key {
background: var(--color-secondary);
color: var(--color-text-white);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
min-width: 30px;
text-align: center;
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.3);
}
.desc {
color: var(--color-text-white);
font-size: var(--font-size-sm);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
/* 当前轮次信息 */
.current-round-info {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.round-name {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
margin-bottom: var(--spacing-lg);
text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
font-family: var(--font-family-secondary);
}
.prize-name {
font-size: 64px;
font-weight: var(--font-weight-bold);
color: var(--color-secondary);
text-shadow: 0 0 30px rgba(var(--color-secondary-rgb), 0.8);
font-family: var(--font-family-secondary);
}
/* 滚动模式 */
.scroll-mode {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.scroll-list {
display: flex;
flex-direction: column;
transition: transform 0.1s linear;
}
.scroll-item {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
color: rgba(255, 255, 255, 0.6);
padding: var(--spacing-md) var(--spacing-3xl);
text-align: center;
white-space: nowrap;
transition: var(--transition-normal);
font-family: var(--font-family-secondary);
}
.scroll-item.highlight {
color: var(--color-text-white);
font-size: 72px;
text-shadow: 0 0 30px rgba(255, 255, 255, 0.8);
transform: scale(1.2);
}
/* 结果模式 */
.result-mode {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.winners-list {
display: flex;
flex-wrap: wrap;
gap: 30px;
justify-content: center;
max-width: 1200px;
}
.winner-item {
font-size: 56px;
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
padding: var(--spacing-xl) var(--spacing-4xl);
background: rgba(255, 255, 255, 0.15);
border-radius: var(--border-radius-xl);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: fadeInUp 0.6s ease forwards;
opacity: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
font-family: var(--font-family-secondary);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 状态指示器 */
.status-indicator {
position: fixed;
bottom: var(--spacing-4xl);
right: var(--spacing-4xl);
padding: var(--spacing-lg) var(--spacing-3xl);
background: rgba(255, 255, 255, 0.15);
border-radius: 50px;
color: var(--color-text-white);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
backdrop-filter: blur(10px);
transition: var(--transition-normal);
border: 1px solid rgba(255, 255, 255, 0.2);
font-family: var(--font-family-secondary);
}
.status-indicator.active {
background: rgba(var(--color-secondary-rgb), 0.3);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
</style>

9
display/src/counter.ts Normal file
View File

@@ -0,0 +1,9 @@
export function setupCounter(element: HTMLButtonElement) {
let counter = 0
const setCounter = (count: number) => {
counter = count
element.innerHTML = `count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

9
display/src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './styles/variables.css'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

24
display/src/main.ts Normal file
View File

@@ -0,0 +1,24 @@
import './style.css'
import typescriptLogo from './typescript.svg'
import viteLogo from '/vite.svg'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div>
<a href="https://vite.dev" target="_blank">
<img src="${viteLogo}" class="logo" alt="Vite logo" />
</a>
<a href="https://www.typescriptlang.org/" target="_blank">
<img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
</a>
<h1>Vite + TypeScript</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
<p class="read-the-docs">
Click on the Vite and TypeScript logos to learn more
</p>
</div>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)

153
display/src/store/index.js Normal file
View File

@@ -0,0 +1,153 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { indexedDB } from '@utils/indexedDB'
export const useLotteryStore = defineStore('lottery', () => {
// 初始化数据
const fields = ref([])
const participants = ref([])
const prizes = ref([])
const rounds = ref([])
const winners = ref([])
const isRolling = ref(false)
const currentRound = ref(null)
const displayMode = ref('scroll')
const backgroundImage = ref('')
const isInitialized = ref(false)
// 从IndexedDB初始化数据
const initFromIndexedDB = async () => {
if (isInitialized.value) return
try {
console.log('Display: Initializing from IndexedDB...')
const data = await indexedDB.getAll()
console.log('Display: Retrieved data keys:', Object.keys(data))
fields.value = data.lottery_fields || []
participants.value = data.lottery_participants || []
prizes.value = data.lottery_prizes || []
rounds.value = data.lottery_rounds || []
winners.value = data.lottery_winners || []
isRolling.value = data.lottery_isRolling === 'true'
currentRound.value = data.lottery_currentRound || null
displayMode.value = data.lottery_displayMode || 'scroll'
backgroundImage.value = data.lottery_backgroundImage || ''
console.log('Display: Initialized background image:', backgroundImage.value ? 'Yes' : 'No')
console.log('Display: Initialization completed')
isInitialized.value = true
} catch (error) {
console.error('Display: Failed to initialize from IndexedDB:', error)
// 使用默认值
fields.value = [{key: 'name', label: '姓名', required: true}]
isInitialized.value = true
}
}
// 初始化
initFromIndexedDB()
// 监听localStorage变化来触发数据同步
const handleStorageChange = (e) => {
if (e.key === 'lottery_data_changed') {
console.log('Display: Storage change detected, reloading data...')
initFromIndexedDB()
}
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageChange)
}
// 轮询同步IndexedDB数据作为备用
let syncInterval = null
const startSync = () => {
if (syncInterval) return
console.log('Display: Starting sync interval...')
syncInterval = setInterval(async () => {
try {
const data = await indexedDB.getAll()
// 只更新变化的值,避免不必要的重渲染
if (JSON.stringify(data.lottery_fields) !== JSON.stringify(fields.value)) {
fields.value = data.lottery_fields || []
}
if (JSON.stringify(data.lottery_participants) !== JSON.stringify(participants.value)) {
participants.value = data.lottery_participants || []
}
if (JSON.stringify(data.lottery_prizes) !== JSON.stringify(prizes.value)) {
prizes.value = data.lottery_prizes || []
}
if (JSON.stringify(data.lottery_rounds) !== JSON.stringify(rounds.value)) {
rounds.value = data.lottery_rounds || []
}
if (JSON.stringify(data.lottery_winners) !== JSON.stringify(winners.value)) {
winners.value = data.lottery_winners || []
}
if (data.lottery_isRolling !== isRolling.value) {
isRolling.value = data.lottery_isRolling === 'true'
}
if (JSON.stringify(data.lottery_currentRound) !== JSON.stringify(currentRound.value)) {
currentRound.value = data.lottery_currentRound || null
}
if (data.lottery_displayMode !== displayMode.value) {
displayMode.value = data.lottery_displayMode || 'scroll'
}
if (data.lottery_backgroundImage !== backgroundImage.value) {
console.log('Display: Background image changed from', backgroundImage.value ? 'Yes' : 'No', 'to', data.lottery_backgroundImage ? 'Yes' : 'No')
backgroundImage.value = data.lottery_backgroundImage || ''
}
} catch (error) {
console.error('Display: Failed to sync data from IndexedDB:', error)
}
}, 1000) // 每秒同步一次
}
const stopSync = () => {
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
if (typeof window !== 'undefined') {
window.removeEventListener('storage', handleStorageChange)
}
}
// 启动同步
startSync()
// ============ 只读方法 ============
const getPrizeForRound = (roundId) => {
const round = rounds.value.find(r => r.id === roundId)
if (!round) return null
return prizes.value.find(p => p.id === round.prizeId)
}
const switchDisplayMode = (mode) => {
displayMode.value = mode
}
return {
// 状态
fields,
participants,
prizes,
rounds,
winners,
isRolling,
currentRound,
displayMode,
backgroundImage,
isInitialized,
// 只读方法
getPrizeForRound,
switchDisplayMode,
startSync,
stopSync
}
})

30
display/src/style.css Normal file
View File

@@ -0,0 +1,30 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
padding: 0;
margin: 0;
width: 100vw;
height: 100vh;
}

View File

@@ -0,0 +1,93 @@
:root {
/* 颜色系统 - 参考项目配色 */
--color-primary: rgb(29, 17, 17);
--color-primary-rgb: 29, 17, 17;
--color-secondary: rgb(0, 165, 141);
--color-secondary-rgb: 0, 165, 141;
--color-accent: rgb(34, 37, 121);
--color-accent-rgb: 34, 37, 121;
--color-success: rgb(0, 165, 141);
--color-warning: rgb(255, 193, 7);
--color-danger: rgb(220, 53, 69);
--color-info: rgb(23, 162, 184);
--color-text-primary: rgb(51, 51, 51);
--color-text-secondary: rgb(108, 117, 125);
--color-text-light: rgb(173, 181, 189);
--color-text-white: rgb(255, 255, 255);
--color-background: rgb(248, 249, 250);
--color-border: rgb(222, 226, 230);
--color-border-light: rgb(233, 236, 239);
/* 字体系统 */
--font-family-primary: 'Poppins', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-family-secondary: 'Montserrat', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 32px;
--font-size-4xl: 48px;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 间距系统 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 24px;
--spacing-3xl: 32px;
--spacing-4xl: 40px;
/* 圆角系统 */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
--border-radius-full: 50%;
/* 阴影系统 */
--shadow-small: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-medium: 0 4px 16px rgba(0, 0, 0, 0.12);
--shadow-large: 0 8px 32px rgba(0, 0, 0, 0.16);
--shadow-button: 0 4px 12px rgba(0, 0, 0, 0.15);
/* 过渡动画 */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
--transition-color: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
--transition-background: background-color 0.3s ease;
--transition-border: border-color 0.3s ease;
--transition-box-shadow: box-shadow 0.3s ease;
--transition-transform: transform 0.3s ease;
/* 行高 */
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* 布局 */
--header-height: 64px;
--sidebar-height: 64px;
--button-height-sm: 32px;
--button-height-md: 40px;
--button-height-lg: 48px;
/* 卡片 */
--card-border-radius: 12px;
--card-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

26
display/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
display/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@utils': path.resolve(__dirname, '../utils')
}
}
})

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>抽奖系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1463
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

1
package.json Normal file
View File

@@ -0,0 +1 @@
{"name": "rolling-draw", "version": "1.0.0", "type": "module", "scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"}, "dependencies": {"vue": "^3.4.0", "vue-router": "^4.2.5", "pinia": "^2.1.7", "element-plus": "^2.5.0", "@element-plus/icons-vue": "^2.3.1"}, "devDependencies": {"@vitejs/plugin-vue": "^5.0.0", "vite": "^5.0.0"}}

20
src/App.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<router-view />
</template>
<script setup>
// 主应用组件,只负责路由渲染
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
}
</style>

24
src/main.js Normal file
View File

@@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from './router'
import App from './App.vue'
import './styles/variables.css'
import './styles/global.css'
import './styles/element-plus.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

44
src/router/index.js Normal file
View File

@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: '/admin/participants'
},
{
path: '/admin',
component: () => import('@/views/AdminLayout.vue'),
children: [
{
path: 'participants',
name: 'Participants',
component: () => import('@/views/admin/Participants.vue')
},
{
path: 'prizes',
name: 'Prizes',
component: () => import('@/views/admin/Prizes.vue')
},
{
path: 'rounds',
name: 'Rounds',
component: () => import('@/views/admin/Rounds.vue')
},
{
path: 'winners',
name: 'Winners',
component: () => import('@/views/admin/Winners.vue')
}
]
},
{
path: '/display',
name: 'Display',
component: () => import('@/views/Display.vue')
}
]
})
export default router

490
src/store/index.js Normal file
View File

@@ -0,0 +1,490 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { indexedDB } from '@utils/indexedDB'
export const useLotteryStore = defineStore('lottery', () => {
// 初始化数据
const fields = ref([])
const participants = ref([])
const prizes = ref([])
const rounds = ref([])
const winners = ref([])
const isRolling = ref(false)
const currentRound = ref(null)
const displayMode = ref('scroll')
const backgroundImage = ref('')
const columnsPerRow = ref(3) // 每行显示的人数
const isInitialized = ref(false)
// 从IndexedDB初始化数据
const initialize = async () => {
if (isInitialized.value) return
try {
console.log('Initializing from IndexedDB...')
const data = await indexedDB.getAll()
console.log('Retrieved data keys:', Object.keys(data))
fields.value = data.lottery_fields || []
participants.value = data.lottery_participants || []
prizes.value = data.lottery_prizes || []
rounds.value = data.lottery_rounds || []
winners.value = data.lottery_winners || []
isRolling.value = data.lottery_isRolling === 'true'
currentRound.value = data.lottery_currentRound || null
displayMode.value = data.lottery_displayMode || 'scroll'
backgroundImage.value = data.lottery_backgroundImage || ''
columnsPerRow.value = data.lottery_columnsPerRow || 3
console.log('Initialized background image:', backgroundImage.value ? 'Yes' : 'No')
console.log('Initialization completed')
// 如果当前不是滚动状态,重置显示模式
if (!isRolling.value && displayMode.value === 'result') {
displayMode.value = 'scroll'
await saveData('lottery_displayMode', displayMode.value)
}
isInitialized.value = true
} catch (error) {
console.error('Failed to initialize from IndexedDB:', error)
// 使用默认值
fields.value = [{key: 'name', label: '姓名', required: true}]
isInitialized.value = true
}
}
// 保存单个数据到IndexedDB
const saveData = async (key, value) => {
try {
console.log(`Saving ${key} to IndexedDB...`)
// 深拷贝数据以避免Vue响应式包装器
const clonedValue = JSON.parse(JSON.stringify(value))
await indexedDB.set(key, clonedValue)
// 触发localStorage事件通知其他标签页
localStorage.setItem('lottery_data_changed', Date.now().toString())
console.log(`Successfully saved ${key}`)
} catch (error) {
console.error(`Failed to save ${key} to IndexedDB:`, error)
throw error
}
}
// 监听localStorage变化
const handleStorageChange = (e) => {
if (e.key === 'lottery_data_changed') {
console.log('Storage change detected, reloading data...')
initialize()
}
}
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageChange)
}
// ============ 字段管理 ============
const addField = async (field) => {
const newField = {
id: Date.now(),
key: field.key,
label: field.label,
required: field.required || false
}
fields.value.push(newField)
await saveData('lottery_fields', fields.value)
}
const updateField = async (field) => {
const index = fields.value.findIndex(f => f.id === field.id)
if (index > -1) {
fields.value[index] = field
await saveData('lottery_fields', fields.value)
}
}
const removeField = async (id) => {
const index = fields.value.findIndex(f => f.id === id)
if (index > -1) {
fields.value.splice(index, 1)
await saveData('lottery_fields', fields.value)
}
}
// ============ 参与者管理 ============
const addParticipant = async (participant) => {
participants.value.push(participant)
await saveData('lottery_participants', participants.value)
}
const updateParticipant = async (participant) => {
const index = participants.value.findIndex(p => p.id === participant.id)
if (index > -1) {
participants.value[index] = participant
await saveData('lottery_participants', participants.value)
}
}
const removeParticipant = async (id) => {
const index = participants.value.findIndex(p => p.id === id)
if (index > -1) {
participants.value.splice(index, 1)
await saveData('lottery_participants', participants.value)
}
}
const clearParticipants = async () => {
participants.value = []
await saveData('lottery_participants', participants.value)
}
const importParticipantsFromFile = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = async (e) => {
try {
const content = e.target.result
const lines = content.split('\n').map(l => l.trim()).filter(l => l)
// 检测是否是CSV格式包含逗号
const isCSV = lines.some(line => line.includes(','))
if (isCSV) {
// CSV格式导入
const headers = lines[0].split(',').map(h => h.trim())
const data = []
// 自动添加缺失的字段到配置
headers.forEach(header => {
if (!fields.value.some(f => f.key === header)) {
fields.value.push({
id: Date.now() + Math.random(),
key: header,
label: header,
required: false
})
}
})
// 保存更新后的字段配置
await saveData('lottery_fields', fields.value)
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim())
const participant = { id: Date.now() + i }
headers.forEach((header, index) => {
participant[header] = values[index] || ''
})
data.push(participant)
}
participants.value.push(...data)
await saveData('lottery_participants', participants.value)
resolve(data.length)
} else {
// 简单文本格式导入(每行一个姓名)
const data = lines.map(line => ({
id: Date.now() + Math.random(),
name: line
}))
participants.value.push(...data)
await saveData('lottery_participants', participants.value)
resolve(data.length)
}
} catch (error) {
reject(error)
}
}
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsText(file)
})
}
const exportParticipants = () => {
const headers = fields.value.map(f => f.label)
const rows = participants.value.map(p => headers.map(h => p[h] || '').join(','))
const csv = [headers.join(','), ...rows].join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `抽奖名单_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(url)
}
// ============ 奖品管理 ============
const addPrize = async (prize) => {
prizes.value.push({
id: Date.now(),
name: prize.name,
stock: parseInt(prize.stock) || 0,
used: 0
})
await saveData('lottery_prizes', prizes.value)
}
const updatePrize = async (prize) => {
const index = prizes.value.findIndex(p => p.id === prize.id)
if (index > -1) {
prizes.value[index] = prize
await saveData('lottery_prizes', prizes.value)
}
}
const removePrize = async (id) => {
const index = prizes.value.findIndex(p => p.id === id)
if (index > -1) {
prizes.value.splice(index, 1)
await saveData('lottery_prizes', prizes.value)
}
}
const getPrizeAvailable = (prizeId) => {
const prize = prizes.value.find(p => p.id === prizeId)
return prize ? prize.stock - prize.used : 0
}
// ============ 轮次管理 ============
const addRound = async (round) => {
rounds.value.push({
id: Date.now(),
name: round.name,
prizeId: round.prizeId,
count: parseInt(round.count) || 1,
completed: false
})
await saveData('lottery_rounds', rounds.value)
}
const updateRound = async (round) => {
const index = rounds.value.findIndex(r => r.id === round.id)
if (index > -1) {
rounds.value[index] = round
await saveData('lottery_rounds', rounds.value)
}
}
const removeRound = async (id) => {
const index = rounds.value.findIndex(r => r.id === id)
if (index > -1) {
rounds.value.splice(index, 1)
await saveData('lottery_rounds', rounds.value)
}
}
const getPrizeForRound = (roundId) => {
const round = rounds.value.find(r => r.id === roundId)
if (!round) return null
return prizes.value.find(p => p.id === round.prizeId)
}
// ============ 抽奖控制 ============
const startLottery = async (round) => {
const prize = getPrizeForRound(round.id)
if (!prize) {
throw new Error('奖品不存在')
}
if (prize.used >= prize.stock) {
throw new Error('奖品库存不足')
}
if (round.completed) {
throw new Error('该轮次已完成')
}
if (participants.value.length === 0) {
throw new Error('暂无名单')
}
// 重置显示模式为滚动模式
displayMode.value = 'scroll'
// 设置滚动状态
isRolling.value = true
currentRound.value = round
// 保存状态
await saveData('lottery_displayMode', displayMode.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_currentRound', currentRound.value)
}
const stopLottery = async () => {
if (!currentRound.value) return
const prize = getPrizeForRound(currentRound.value.id)
if (!prize) {
throw new Error('奖品不存在')
}
// 随机选择中奖者
const available = participants.value.filter(p => {
return !winners.value.some(w => w.participantId === p.id)
})
if (available.length < currentRound.value.count) {
throw new Error(`可用人数不足,需要${currentRound.value.count}人,只有${available.length}`)
}
// Fisher-Yates洗牌算法
for (let i = available.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[available[i], available[j]] = [available[j], available[i]]
}
const selected = available.slice(0, currentRound.value.count)
// 保存中奖记录
selected.forEach(p => {
winners.value.push({
id: Date.now() + Math.random(),
participantId: p.id,
participant: p, // 保存完整的参与者信息
prizeName: prize.name,
roundId: currentRound.value.id,
roundName: currentRound.value.name,
time: new Date().toLocaleString()
})
})
// 更新奖品使用数量
prize.used += currentRound.value.count
// 更新轮次状态
const roundIndex = rounds.value.findIndex(r => r.id === currentRound.value.id)
if (roundIndex > -1) {
rounds.value[roundIndex].completed = true
}
// 停止滚动
isRolling.value = false
displayMode.value = 'result'
currentRound.value = null
// 保存所有相关数据
await saveData('lottery_winners', winners.value)
await saveData('lottery_prizes', prizes.value)
await saveData('lottery_rounds', rounds.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_currentRound', currentRound.value)
await saveData('lottery_displayMode', displayMode.value)
}
const resetLottery = async () => {
winners.value = []
prizes.value.forEach(p => p.used = 0)
rounds.value.forEach(r => r.completed = false)
isRolling.value = false
currentRound.value = null
displayMode.value = 'scroll'
await saveData('lottery_winners', winners.value)
await saveData('lottery_prizes', prizes.value)
await saveData('lottery_rounds', rounds.value)
await saveData('lottery_isRolling', isRolling.value)
await saveData('lottery_currentRound', currentRound.value)
await saveData('lottery_displayMode', displayMode.value)
}
const exportWinners = () => {
const headers = ['姓名', '奖品', '轮次', '时间']
const rows = winners.value.map(w => [w.name, w.prizeName, w.roundName, w.time])
const csv = [headers.join(','), ...rows].join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `中奖名单_${new Date().toLocaleDateString()}.csv`
link.click()
URL.revokeObjectURL(url)
}
// ============ 显示控制 ============
const switchDisplayMode = async (mode) => {
displayMode.value = mode
await saveData('lottery_displayMode', displayMode.value)
}
const setColumnsPerRow = async (columns) => {
columnsPerRow.value = columns
await saveData('lottery_columnsPerRow', columnsPerRow.value)
}
const setBackgroundImage = async (imageData) => {
try {
console.log('setBackgroundImage called with image data length:', imageData ? imageData.length : 0)
await saveData('lottery_backgroundImage', imageData)
backgroundImage.value = imageData
console.log('setBackgroundImage completed successfully')
} catch (error) {
console.error('Failed to save background image:', error)
throw new Error('保存背景图片失败')
}
}
const clearBackgroundImage = async () => {
try {
await indexedDB.delete('lottery_backgroundImage')
backgroundImage.value = ''
} catch (error) {
console.error('Failed to clear background image:', error)
throw new Error('清除背景图片失败')
}
}
return {
// 状态
fields,
participants,
prizes,
rounds,
winners,
isRolling,
currentRound,
displayMode,
backgroundImage,
columnsPerRow,
isInitialized,
// 初始化
initialize,
// 字段管理
addField,
updateField,
removeField,
// 参与者管理
addParticipant,
updateParticipant,
removeParticipant,
clearParticipants,
importParticipantsFromFile,
exportParticipants,
// 奖品管理
addPrize,
updatePrize,
removePrize,
getPrizeAvailable,
// 轮次管理
addRound,
updateRound,
removeRound,
getPrizeForRound,
// 抽奖控制
startLottery,
stopLottery,
resetLottery,
exportWinners,
// 显示控制
switchDisplayMode,
setColumnsPerRow,
setBackgroundImage,
clearBackgroundImage
}
})

340
src/styles/element-plus.css Normal file
View File

@@ -0,0 +1,340 @@
/* Element Plus Custom Styles */
/* Element Plus Variables Override */
:root {
--el-color-primary: var(--color-primary);
--el-color-primary-light-3: #8fa3f7;
--el-color-primary-light-5: #b8c5f8;
--el-color-primary-light-7: #dce0fa;
--el-color-primary-light-8: #ebeefc;
--el-color-primary-light-9: #f5f6fe;
--el-color-primary-dark-2: #5369d6;
--el-color-success: var(--color-success);
--el-color-warning: var(--color-warning);
--el-color-danger: var(--color-danger);
--el-color-info: var(--color-info);
--el-border-radius-base: var(--border-radius-md);
--el-border-radius-small: var(--border-radius-sm);
--el-border-radius-round: 20px;
--el-border-radius-circle: 100%;
--el-font-size-base: var(--font-size-base);
--el-font-size-small: var(--font-size-sm);
--el-font-size-large: var(--font-size-lg);
--el-font-size-extra-large: var(--font-size-xl);
--el-transition-duration: var(--transition-normal);
}
/* Element Plus Button Customization */
.el-button {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
border-radius: var(--border-radius-md);
transition: all var(--transition-normal);
}
.el-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-small);
}
.el-button:active {
transform: translateY(0);
}
.el-button--primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
border: none;
}
.el-button--primary:hover {
background: linear-gradient(135deg, var(--color-primary-dark-2) 0%, var(--color-secondary) 100%);
}
.el-button--success {
background: var(--color-success);
border: none;
}
.el-button--danger {
background: var(--color-danger);
border: none;
}
.el-button--warning {
background: var(--color-warning);
border: none;
}
.el-button--info {
background: var(--color-info);
border: none;
}
/* Element Plus Card Customization */
.el-card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
transition: all var(--transition-normal);
border: 1px solid var(--color-border);
}
.el-card:hover {
box-shadow: var(--shadow-medium);
}
.el-card__header {
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-lg) var(--spacing-xl);
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
}
.el-card__body {
padding: var(--spacing-xl);
}
/* Element Plus Dialog Customization */
.el-dialog {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
}
.el-dialog__header {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
color: var(--color-text-white);
padding: var(--spacing-xl) var(--spacing-2xl);
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
}
.el-dialog__title {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
}
.el-dialog__body {
padding: var(--spacing-2xl);
}
.el-dialog__footer {
padding: var(--spacing-lg) var(--spacing-2xl);
border-top: 1px solid var(--color-border);
}
/* Element Plus Input Customization */
.el-input__wrapper {
border-radius: var(--border-radius-md);
box-shadow: none;
border: 1px solid var(--color-border);
transition: all var(--transition-normal);
}
.el-input__wrapper:hover {
border-color: var(--color-primary);
box-shadow: none;
}
.el-input__wrapper.is-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
.el-input__inner {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
}
/* Element Plus Select Customization */
.el-select .el-input__wrapper {
border-radius: var(--border-radius-md);
}
.el-select-dropdown__item {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
}
.el-select-dropdown__item.is-selected {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
/* Element Plus Form Customization */
.el-form-item__label {
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.el-form-item__error {
font-family: var(--font-family-primary);
font-size: var(--font-size-sm);
}
/* Element Plus Table Customization */
.el-table {
font-family: var(--font-family-primary);
border-radius: var(--border-radius-md);
overflow: hidden;
}
.el-table th {
background: var(--color-background);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-secondary);
font-size: var(--font-size-base);
border-bottom: 2px solid var(--color-border);
}
.el-table td {
color: var(--color-text-primary);
font-size: var(--font-size-base);
border-bottom: 1px solid var(--color-border);
}
.el-table .el-table__row:hover > td {
background: rgba(var(--color-primary-rgb), 0.05);
}
.el-table--striped .el-table__body tr.el-table__row--striped td {
background: rgba(var(--color-primary-rgb), 0.03);
}
.el-table__body tr.current-row > td {
background: rgba(var(--color-primary-rgb), 0.1);
}
/* Element Plus Upload Customization */
.el-upload-dragger {
border-radius: var(--border-radius-lg);
border: 2px dashed var(--color-border);
transition: all var(--transition-normal);
background: var(--color-background);
}
.el-upload-dragger:hover {
border-color: var(--color-primary);
background: rgba(var(--color-primary-rgb), 0.05);
}
.el-upload__tip {
color: var(--color-text-light);
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
/* Element Plus Message Customization */
.el-message {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
padding: var(--spacing-lg) var(--spacing-xl);
}
.el-message__icon {
font-size: var(--font-size-xl);
}
/* Element Plus Message Box Customization */
.el-message-box {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-large);
}
.el-message-box__header {
padding: var(--spacing-xl) var(--spacing-2xl);
border-bottom: 1px solid var(--color-border);
}
.el-message-box__title {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
}
.el-message-box__content {
padding: var(--spacing-2xl);
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
}
.el-message-box__btns {
padding: var(--spacing-lg) var(--spacing-2xl);
border-top: 1px solid var(--color-border);
}
/* Element Plus Checkbox Customization */
.el-checkbox__label {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.el-checkbox__input.is-checked .el-checkbox__inner {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
/* Element Plus Radio Customization */
.el-radio__label {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.el-radio__input.is-checked .el-radio__inner {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
/* Element Plus Switch Customization */
.el-switch.is-checked .el-switch__core {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
/* Element Plus Tag Customization */
.el-tag {
border-radius: var(--border-radius-md);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
.el-tag--primary {
background: rgba(var(--color-primary-rgb), 0.1);
color: var(--color-primary);
border-color: var(--color-primary);
}
.el-tag--success {
background: rgba(var(--color-success), 0.1);
color: var(--color-success);
border-color: var(--color-success);
}
.el-tag--warning {
background: rgba(var(--color-warning), 0.1);
color: var(--color-warning);
border-color: var(--color-warning);
}
.el-tag--danger {
background: rgba(var(--color-danger), 0.1);
color: var(--color-danger);
border-color: var(--color-danger);
}
.el-tag--info {
background: rgba(var(--color-info), 0.1);
color: var(--color-info);
border-color: var(--color-info);
}

241
src/styles/global.css Normal file
View File

@@ -0,0 +1,241 @@
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
background: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100%;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-border-light);
border-radius: var(--border-radius-sm);
}
::-webkit-scrollbar-thumb {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
transition: background var(--transition-normal);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-family-secondary);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
margin-bottom: var(--spacing-lg);
}
h1 { font-size: var(--font-size-4xl); }
h2 { font-size: var(--font-size-3xl); }
h3 { font-size: var(--font-size-2xl); }
h4 { font-size: var(--font-size-xl); }
h5 { font-size: var(--font-size-lg); }
h6 { font-size: var(--font-size-base); }
p {
margin-bottom: var(--spacing-md);
line-height: var(--line-height-relaxed);
}
/* Links */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-normal);
}
a:hover {
color: var(--color-secondary);
}
/* Lists */
ul, ol {
margin-bottom: var(--spacing-md);
padding-left: var(--spacing-2xl);
}
li {
margin-bottom: var(--spacing-sm);
line-height: var(--line-height-normal);
}
/* Code */
code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm);
background: var(--color-border);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--border-radius-sm);
}
pre {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: var(--font-size-sm);
background: var(--color-border);
padding: var(--spacing-lg);
border-radius: var(--border-radius-md);
overflow-x: auto;
line-height: var(--line-height-relaxed);
}
pre code {
background: none;
padding: 0;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--spacing-lg);
}
th, td {
padding: var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
th {
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
tr:hover {
background: var(--color-border-light);
}
/* Forms */
input,
textarea,
select {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
color: var(--color-text-primary);
}
input::placeholder,
textarea::placeholder {
color: var(--color-text-light);
}
/* Buttons */
button {
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
cursor: pointer;
}
/* Images */
img {
max-width: 100%;
height: auto;
display: block;
}
/* Utilities */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-secondary); }
.text-success { color: var(--color-success); }
.text-danger { color: var(--color-danger); }
.text-warning { color: var(--color-warning); }
.text-light { color: var(--color-text-light); }
.bg-primary { background: var(--color-primary); }
.bg-secondary { background: var(--color-secondary); }
.bg-success { background: var(--color-success); }
.bg-danger { background: var(--color-danger); }
.bg-warning { background: var(--color-warning); }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--spacing-xs); }
.mt-2 { margin-top: var(--spacing-sm); }
.mt-3 { margin-top: var(--spacing-md); }
.mt-4 { margin-top: var(--spacing-lg); }
.mt-5 { margin-top: var(--spacing-xl); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--spacing-xs); }
.mb-2 { margin-bottom: var(--spacing-sm); }
.mb-3 { margin-bottom: var(--spacing-md); }
.mb-4 { margin-bottom: var(--spacing-lg); }
.mb-5 { margin-bottom: var(--spacing-xl); }
.p-0 { padding: 0; }
.p-1 { padding: var(--spacing-xs); }
.p-2 { padding: var(--spacing-sm); }
.p-3 { padding: var(--spacing-md); }
.p-4 { padding: var(--spacing-lg); }
.p-5 { padding: var(--spacing-xl); }
.d-flex { display: flex; }
.d-inline-flex { display: inline-flex; }
.d-block { display: block; }
.d-inline-block { display: inline-block; }
.d-none { display: none; }
.flex-row { flex-direction: row; }
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.align-start { align-items: flex-start; }
.align-center { align-items: center; }
.align-end { align-items: flex-end; }
.align-stretch { align-items: stretch; }
.gap-0 { gap: 0; }
.gap-1 { gap: var(--spacing-xs); }
.gap-2 { gap: var(--spacing-sm); }
.gap-3 { gap: var(--spacing-md); }
.gap-4 { gap: var(--spacing-lg); }
.gap-5 { gap: var(--spacing-xl); }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
.position-relative { position: relative; }
.position-absolute { position: absolute; }
.position-fixed { position: fixed; }

82
src/styles/variables.css Normal file
View File

@@ -0,0 +1,82 @@
/* CSS Variables */
:root {
/* Colors */
--color-primary: #667eea;
--color-primary-rgb: 102, 126, 234;
--color-secondary: #764ba2;
--color-secondary-rgb: 118, 75, 162;
--color-accent: #f093fb;
--color-accent-rgb: 240, 147, 251;
--color-success: #00b894;
--color-danger: #e74c3c;
--color-info: #14b8a6;
--color-warning: #f39c12;
--color-background: #f5f7fa;
--color-border: #e2e8f0;
--color-border-light: #f8fafc;
--color-text-primary: #2d3748;
--color-text-secondary: #4a5568;
--color-text-light: #718096;
--color-text-white: #ffffff;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
--spacing-2xl: 32px;
--spacing-3xl: 48px;
--spacing-4xl: 64px;
/* Typography */
--font-family-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-family-secondary: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
--font-size-2xl: 24px;
--font-size-3xl: 32px;
--font-size-4xl: 48px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Border Radius */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
/* Shadows */
--shadow-small: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-large: 0 10px 25px rgba(0, 0, 0, 0.15);
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 300ms;
--transition-slow: 500ms;
--transition-color: color var(--transition-normal) ease;
--transition-background: background var(--transition-normal) ease;
--transition-border: border-color var(--transition-normal) ease;
--transition-box-shadow: box-shadow var(--transition-normal) ease;
/* Layout */
--header-height: 64px;
--sidebar-width: 240px;
--content-max-width: 1200px;
}

1
src/views/.gitkeep Normal file
View File

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

1324
src/views/Admin.vue Normal file

File diff suppressed because it is too large Load Diff

412
src/views/AdminLayout.vue Normal file
View File

@@ -0,0 +1,412 @@
<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>

454
src/views/Display.vue Normal file
View File

@@ -0,0 +1,454 @@
<template>
<div
class="display-container"
@keydown="handleKeydown"
tabindex="0"
:style="{ backgroundImage: store.backgroundImage ? `url(${store.backgroundImage})` : 'none' }"
>
<!-- 快捷键提示 -->
<div class="shortcut-hints" v-if="!store.isRolling">
<div class="hint-item">
<span class="key"> </span>
<span class="desc">切换轮次</span>
</div>
<div class="hint-item">
<span class="key">Space</span>
<span class="desc">开始/停止</span>
</div>
</div>
<!-- 当前轮次信息 -->
<div class="current-round-info" v-if="selectedRound">
<div class="round-name">{{ selectedRound.name }}</div>
<div class="prize-name">{{ getPrizeName(selectedRound.prizeId) }}</div>
</div>
<!-- 滚动模式 -->
<div v-if="store.displayMode === 'scroll'" class="scroll-mode">
<div class="scroll-item" :class="{ 'highlight': true }">
{{ getParticipantName(displayList[highlightIndex]) }}
</div>
</div>
<!-- 抽奖结果模式 -->
<div v-else class="result-mode">
<div class="winners-list" :style="{ 'grid-template-columns': `repeat(${store.columnsPerRow}, 1fr)` }">
<div
v-for="(winner, index) in currentWinners"
:key="winner.id || index"
class="winner-item"
:style="{ animationDelay: `${index * 0.2}s` }"
>
<div class="winner-name">{{ getParticipantName(winner.participant) }}</div>
</div>
</div>
</div>
<!-- 状态指示器 -->
<div class="status-indicator" :class="{ 'active': store.isRolling }">
{{ store.isRolling ? '抽奖中...' : '等待抽奖' }}
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useLotteryStore } from '../store'
const store = useLotteryStore()
const selectedRound = ref(null)
const highlightIndex = ref(0)
let highlightInterval = null
const displayList = computed(() => {
if (store.participants.length === 0) return ['暂无名单']
return store.participants
})
const getPrizeName = (prizeId) => {
const prize = store.prizes.find(p => p.id === prizeId)
return prize ? prize.name : ''
}
const getParticipantName = (person) => {
if (!person) return '无数据'
if (typeof person === 'string') return person
// 如果是旧的中奖记录(只有 name 字段),直接返回 name
if (person.name && !person.participant && Object.keys(person).length === 1) {
return person.name
}
// 收集所有配置字段的值
const displayParts = []
store.fields.forEach(field => {
const value = person[field.key]
if (value && value.trim()) {
displayParts.push(`${field.label}: ${value}`)
}
})
// 如果有配置字段的值,返回所有字段
if (displayParts.length > 0) {
return displayParts.join(' | ')
}
// 如果配置的字段都没有值,尝试从参与者对象的所有字段中找
const allKeys = Object.keys(person).filter(key => key !== 'id' && key !== 'name')
if (allKeys.length > 0) {
// 优先找包含 "name" 的字段
const nameKey = allKeys.find(key => key.toLowerCase().includes('name'))
if (nameKey && person[nameKey]) {
return `${nameKey}: ${person[nameKey]}`
}
// 否则返回第一个字段的值
const firstKey = allKeys[0]
return `${firstKey}: ${person[firstKey]}`
}
return '无数据'
}
const getParticipantDetails = (person) => {
// 已经在 getParticipantName 中显示所有字段,这里返回空
return ''
}
const currentWinners = computed(() => {
if (!selectedRound.value) return []
return store.winners
.filter(w => w.roundId === selectedRound.value.id)
.sort((a, b) => new Date(a.time) - new Date(b.time))
})
const handleKeydown = (e) => {
// 空格键:开始/停止抽奖
if (e.code === 'Space') {
e.preventDefault()
if (store.isRolling) {
handleStopLottery()
} else if (selectedRound.value && !selectedRound.value.completed) {
handleStartLottery()
}
}
// 左右方向键:切换轮次
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {
e.preventDefault()
if (store.rounds.length === 0) return
let currentIndex = -1
if (selectedRound.value) {
currentIndex = store.rounds.findIndex(r => r.id === selectedRound.value.id)
}
if (e.code === 'ArrowLeft') {
// 向左切换
currentIndex = currentIndex <= 0 ? store.rounds.length - 1 : currentIndex - 1
} else {
// 向右切换
currentIndex = currentIndex >= store.rounds.length - 1 ? 0 : currentIndex + 1
}
selectRound(store.rounds[currentIndex])
}
}
const selectRound = (round) => {
selectedRound.value = round
}
const handleStartLottery = () => {
if (!selectedRound.value) return
try {
store.startLottery(selectedRound.value)
startScroll()
} catch (error) {
alert(error.message)
}
}
const handleStopLottery = () => {
store.stopLottery()
stopScroll()
}
const startScroll = () => {
if (highlightInterval) clearInterval(highlightInterval)
highlightInterval = setInterval(() => {
highlightIndex.value = (highlightIndex.value + 1) % displayList.value.length
}, 80)
}
const stopScroll = () => {
if (highlightInterval) {
clearInterval(highlightInterval)
highlightInterval = null
}
}
onMounted(async () => {
await store.initialize()
// 自动聚焦以便接收键盘事件
document.querySelector('.display-container')?.focus()
// 自动选择第一个未完成的轮次
if (store.rounds.length > 0) {
const firstIncompleteRound = store.rounds.find(r => !r.completed)
if (firstIncompleteRound) {
selectedRound.value = firstIncompleteRound
} else {
selectedRound.value = store.rounds[0]
}
}
if (store.displayMode === 'scroll' && store.isRolling) {
startScroll()
}
})
onUnmounted(() => {
stopScroll()
})
</script>
<style scoped>
.display-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
outline: none;
font-family: var(--font-family-primary);
}
.display-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
}
.display-container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1;
}
.display-container > * {
position: relative;
z-index: 2;
}
/* 快捷键提示 */
.shortcut-hints {
position: fixed;
top: var(--spacing-xl);
left: var(--spacing-xl);
display: flex;
gap: var(--spacing-lg);
z-index: 100;
}
.hint-item {
background: rgba(255, 255, 255, 0.15);
border-radius: var(--border-radius-md);
padding: var(--spacing-md) var(--spacing-lg);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
gap: var(--spacing-md);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.key {
background: var(--color-secondary);
color: var(--color-text-white);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
min-width: 30px;
text-align: center;
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.3);
}
.desc {
color: var(--color-text-white);
font-size: var(--font-size-sm);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
/* 当前轮次信息 */
.current-round-info {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.round-name {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
margin-bottom: var(--spacing-lg);
text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
font-family: var(--font-family-secondary);
}
.prize-name {
font-size: 64px;
font-weight: var(--font-weight-bold);
color: var(--color-secondary);
text-shadow: 0 0 30px rgba(var(--color-secondary-rgb), 0.8);
font-family: var(--font-family-secondary);
}
/* 滚动模式 */
.scroll-mode {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 0 var(--spacing-4xl);
}
.scroll-item {
font-size: 64px;
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
padding: var(--spacing-2xl) var(--spacing-4xl);
text-align: center;
white-space: normal;
transition: var(--transition-normal);
font-family: var(--font-family-secondary);
line-height: 1.4;
word-wrap: break-word;
word-break: break-all;
background: rgba(var(--color-secondary-rgb), 0.2);
border-radius: var(--border-radius-xl);
backdrop-filter: blur(10px);
border: 2px solid rgba(var(--color-secondary-rgb), 0.5);
text-shadow: 0 0 30px rgba(var(--color-secondary-rgb), 0.8);
max-width: 1200px;
}
.scroll-item.highlight {
transform: scale(1.05);
background: rgba(var(--color-secondary-rgb), 0.4);
border-color: rgba(var(--color-secondary-rgb), 0.8);
}
/* 结果模式 */
.result-mode {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 var(--spacing-4xl);
}
.winners-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-2xl);
width: 100%;
max-width: 1600px;
}
.winner-item {
font-size: 32px;
font-weight: var(--font-weight-bold);
color: var(--color-text-white);
padding: var(--spacing-xl) var(--spacing-2xl);
background: rgba(255, 255, 255, 0.15);
border-radius: var(--border-radius-xl);
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: fadeInUp 0.6s ease forwards;
opacity: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
font-family: var(--font-family-secondary);
text-align: center;
line-height: 1.4;
word-wrap: break-word;
word-break: break-all;
display: flex;
align-items: center;
justify-content: center;
}
.winner-name {
word-wrap: break-word;
word-break: break-all;
width: 100%;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 状态指示器 */
.status-indicator {
position: fixed;
bottom: var(--spacing-4xl);
right: var(--spacing-4xl);
padding: var(--spacing-lg) var(--spacing-3xl);
background: rgba(255, 255, 255, 0.15);
border-radius: 50px;
color: var(--color-text-white);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
backdrop-filter: blur(10px);
transition: var(--transition-normal);
border: 1px solid rgba(255, 255, 255, 0.2);
font-family: var(--font-family-secondary);
}
.status-indicator.active {
background: rgba(var(--color-secondary-rgb), 0.3);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
</style>

View File

@@ -0,0 +1,452 @@
<template>
<el-card class="card">
<template #header>
<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>
</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>
<el-form-item>
<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 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>
</template>
</el-table-column>
</el-table>
</div>
<div class="participant-count">
{{ store.participants.length }}
</div>
</el-card>
<!-- 字段配置对话框 -->
<el-dialog v-model="showFieldDialog" title="字段配置" width="600px">
<div class="field-config">
<div class="field-list">
<div v-for="field in tempFields" :key="field.id" class="field-item">
<el-input v-model="field.key" placeholder="字段键" size="small" style="width: 120px" />
<el-input v-model="field.label" placeholder="字段名称" size="small" style="width: 120px" />
<el-checkbox v-model="field.required">必填</el-checkbox>
<el-button type="danger" size="small" @click="removeField(field.id)" :disabled="tempFields.length <= 1">删除</el-button>
</div>
</div>
<el-button type="primary" @click="addField" style="margin-top: 15px">添加字段</el-button>
</div>
<template #footer>
<el-button @click="showFieldDialog = false">取消</el-button>
<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-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持 .csv 格式文件第一行为字段名后续行为数据
</div>
</template>
</el-upload>
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="handleImport" :disabled="!selectedFile">导入</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useLotteryStore } from '../../store'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const store = useLotteryStore()
// 初始化store
onMounted(async () => {
await store.initialize()
})
// 字段配置
const showFieldDialog = ref(false)
const tempFields = ref([])
const showFieldConfig = () => {
tempFields.value = JSON.parse(JSON.stringify(store.fields))
showFieldDialog.value = true
}
const addField = () => {
tempFields.value.push({
id: Date.now(),
key: '',
label: '',
required: false
})
}
const removeField = (id) => {
const index = tempFields.value.findIndex(f => f.id === id)
if (index > -1) {
tempFields.value.splice(index, 1)
}
}
const saveFields = () => {
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('字段配置已保存')
}
// 名单管理
const newParticipantData = ref({})
const showImportDialog = ref(false)
const uploadRef = ref(null)
const selectedFile = ref(null)
const initNewParticipantData = () => {
newParticipantData.value = {}
store.fields.forEach(field => {
newParticipantData.value[field.key] = ''
})
}
watch(() => store.fields, () => {
initNewParticipantData()
}, { deep: true })
initNewParticipantData()
const addParticipant = () => {
const missingFields = store.fields.filter(f => f.required && !newParticipantData.value[f.key]?.trim())
if (missingFields.length > 0) {
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
})
initNewParticipantData()
ElMessage.success('添加成功')
}
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('未配置字段:')
unconfiguredKeys.forEach(key => {
details.push(`${key}: ${person[key]}`)
})
}
ElMessage({
message: details.join('\n'),
type: 'info',
duration: 0,
showClose: true
})
}
const removeParticipant = (id) => {
store.removeParticipant(id)
ElMessage.success('删除成功')
}
const handleFileChange = (file) => {
selectedFile.value = file.raw
}
const handleImport = async () => {
try {
if (selectedFile.value) {
const count = await store.importParticipantsFromFile(selectedFile.value)
selectedFile.value = null
uploadRef.value?.clearFiles()
showImportDialog.value = false
ElMessage.success(`成功导入 ${count}`)
} else {
ElMessage.warning('请选择文件')
}
} catch (error) {
ElMessage.error('导入失败:' + error.message)
}
}
const exportParticipants = () => {
store.exportParticipants()
ElMessage.success('导出成功')
}
const clearParticipants = () => {
if (store.participants.length === 0) {
ElMessage.warning('名单已经是空的')
return
}
ElMessageBox.confirm(
'确定要清空所有名单吗?此操作不可恢复。',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
store.clearParticipants()
ElMessage.success('已清空名单')
}).catch(() => {
// 用户取消操作
})
}
</script>
<style scoped>
.card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
border: 1px solid var(--color-border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-family-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
.single-add-form {
margin-bottom: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--color-border-light);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-border);
}
.single-add-form :deep(.el-form-item) {
margin-bottom: var(--spacing-md);
}
.single-add-form :deep(.el-form-item__label) {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
font-family: var(--font-family-primary);
}
.single-add-form :deep(.el-button) {
margin-top: var(--spacing-sm);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
.participant-table {
margin-bottom: var(--spacing-md);
}
.participant-table :deep(.el-table) {
border-radius: var(--border-radius-md);
overflow: hidden;
font-family: var(--font-family-primary);
}
.participant-table :deep(.el-table th) {
background: var(--color-background);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-secondary);
border-bottom: 1px solid var(--color-border);
}
.participant-table :deep(.el-table td) {
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-base);
}
.participant-table :deep(.el-table .el-table__row:hover > td) {
background: rgba(var(--color-primary-rgb), 0.05);
}
.participant-table :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
background: rgba(var(--color-primary-rgb), 0.03);
}
.participant-table :deep(.el-table__body-wrapper) {
max-height: 400px;
overflow-y: auto;
}
.participant-table :deep(.el-table__body-wrapper::-webkit-scrollbar) {
width: 6px;
}
.participant-table :deep(.el-table__body-wrapper::-webkit-scrollbar-track) {
background: var(--color-border);
border-radius: var(--border-radius-sm);
}
.participant-table :deep(.el-table__body-wrapper::-webkit-scrollbar-thumb) {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
}
.participant-table :deep(.el-table__body-wrapper::-webkit-scrollbar-thumb:hover) {
background: var(--color-text-secondary);
}
.participant-count {
text-align: right;
color: var(--color-text-light);
font-size: var(--font-size-sm);
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
}
.field-config {
padding: var(--spacing-md);
}
.field-list {
max-height: 400px;
overflow-y: auto;
margin-bottom: var(--spacing-lg);
}
.field-list::-webkit-scrollbar {
width: 6px;
}
.field-list::-webkit-scrollbar-track {
background: var(--color-border);
border-radius: var(--border-radius-sm);
}
.field-list::-webkit-scrollbar-thumb {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
}
.field-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
.field-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: var(--transition-border), var(--transition-box-shadow);
background: var(--color-background);
}
.field-item:hover {
border-color: var(--color-secondary);
box-shadow: var(--shadow-small);
transform: none;
}
</style>

163
src/views/admin/Prizes.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<el-card class="card">
<template #header>
<div class="card-header">
<span>奖品管理</span>
<el-button type="primary" size="small" @click="showPrizeDialog = true">
添加奖品
</el-button>
</div>
</template>
<div class="prize-list">
<div
v-for="prize in store.prizes"
:key="prize.id"
class="prize-item"
>
<div class="prize-info">
<span class="prize-name">{{ prize.name }}</span>
<span class="prize-stock">库存: {{ prize.used }}/{{ prize.stock }}</span>
</div>
<el-button
type="danger"
size="small"
circle
@click="removePrize(prize.id)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</el-card>
<!-- 添加奖品对话框 -->
<el-dialog v-model="showPrizeDialog" title="添加奖品" width="400px">
<el-form :model="newPrize" label-width="80px">
<el-form-item label="奖品名称">
<el-input v-model="newPrize.name" placeholder="例如:一等奖" />
</el-form-item>
<el-form-item label="库存数量">
<el-input-number v-model="newPrize.stock" :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPrizeDialog = false">取消</el-button>
<el-button type="primary" @click="addPrize">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useLotteryStore } from '../../store'
import { Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const store = useLotteryStore()
// 初始化store
onMounted(async () => {
await store.initialize()
})
// 奖品管理
const showPrizeDialog = ref(false)
const newPrize = ref({
name: '',
stock: 1
})
const addPrize = () => {
if (newPrize.value.name.trim()) {
store.addPrize(newPrize.value)
newPrize.value = { name: '', stock: 1 }
showPrizeDialog.value = false
ElMessage.success('添加成功')
}
}
const removePrize = (id) => {
store.removePrize(id)
ElMessage.success('删除成功')
}
</script>
<style scoped>
.card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
border: 1px solid var(--color-border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-family-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
.prize-list {
max-height: 400px;
overflow-y: auto;
}
.prize-list::-webkit-scrollbar {
width: 6px;
}
.prize-list::-webkit-scrollbar-track {
background: var(--color-border);
border-radius: var(--border-radius-sm);
}
.prize-list::-webkit-scrollbar-thumb {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
}
.prize-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
.prize-item {
padding: var(--spacing-lg);
margin-bottom: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: var(--transition-border), var(--transition-box-shadow);
background: var(--color-background);
display: flex;
justify-content: space-between;
align-items: center;
}
.prize-item:hover {
border-color: var(--color-secondary);
box-shadow: var(--shadow-small);
transform: none;
}
.prize-info {
display: flex;
flex-direction: column;
flex: 1;
}
.prize-name {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-xs);
font-family: var(--font-family-primary);
color: var(--color-text-primary);
}
.prize-stock {
color: var(--color-text-light);
font-size: var(--font-size-sm);
font-family: var(--font-family-primary);
}
</style>

199
src/views/admin/Rounds.vue Normal file
View File

@@ -0,0 +1,199 @@
<template>
<el-card class="card">
<template #header>
<div class="card-header">
<span>轮次管理</span>
<el-button type="primary" size="small" @click="showRoundDialog = true">
添加轮次
</el-button>
</div>
</template>
<div class="round-list">
<div
v-for="round in store.rounds"
:key="round.id"
class="round-item"
:class="{ 'completed': round.completed }"
>
<div class="round-info">
<span class="round-name">{{ round.name }}</span>
<span class="round-detail">{{ getPrizeName(round.prizeId) }} × {{ round.count }}</span>
</div>
<div class="round-actions">
<el-button
type="danger"
size="small"
circle
@click="removeRound(round.id)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 添加轮次对话框 -->
<el-dialog v-model="showRoundDialog" title="添加轮次" width="400px">
<el-form :model="newRound" label-width="80px">
<el-form-item label="轮次名称">
<el-input v-model="newRound.name" placeholder="例如:第一轮" />
</el-form-item>
<el-form-item label="选择奖品">
<el-select v-model="newRound.prizeId" placeholder="请选择奖品" style="width: 100%">
<el-option
v-for="prize in store.prizes"
:key="prize.id"
:label="`${prize.name} (剩余: ${prize.stock - prize.used})`"
:value="prize.id"
/>
</el-select>
</el-form-item>
<el-form-item label="抽取人数">
<el-input-number v-model="newRound.count" :min="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showRoundDialog = false">取消</el-button>
<el-button type="primary" @click="addRound">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useLotteryStore } from '../../store'
import { Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const store = useLotteryStore()
// 初始化store
onMounted(async () => {
await store.initialize()
})
// 轮次管理
const showRoundDialog = ref(false)
const newRound = ref({
name: '',
prizeId: null,
count: 1
})
const getPrizeName = (prizeId) => {
const prize = store.prizes.find(p => p.id === prizeId)
return prize ? prize.name : ''
}
const addRound = () => {
if (newRound.value.name.trim() && newRound.value.prizeId) {
store.addRound(newRound.value)
newRound.value = { name: '', prizeId: null, count: 1 }
showRoundDialog.value = false
ElMessage.success('添加成功')
} else {
ElMessage.warning('请填写完整信息')
}
}
const removeRound = (id) => {
store.removeRound(id)
ElMessage.success('删除成功')
}
</script>
<style scoped>
.card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
border: 1px solid var(--color-border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-family-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
.round-list {
max-height: 400px;
overflow-y: auto;
}
.round-list::-webkit-scrollbar {
width: 6px;
}
.round-list::-webkit-scrollbar-track {
background: var(--color-border);
border-radius: var(--border-radius-sm);
}
.round-list::-webkit-scrollbar-thumb {
background: var(--color-text-light);
border-radius: var(--border-radius-sm);
}
.round-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
.round-item {
padding: var(--spacing-lg);
margin-bottom: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: var(--transition-border), var(--transition-box-shadow);
background: var(--color-background);
display: flex;
justify-content: space-between;
align-items: center;
}
.round-item:hover {
border-color: var(--color-secondary);
box-shadow: var(--shadow-small);
transform: none;
}
.round-item.completed {
opacity: 0.6;
background: var(--color-border-light);
}
.round-info {
display: flex;
flex-direction: column;
flex: 1;
}
.round-name {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-xs);
font-family: var(--font-family-primary);
color: var(--color-text-primary);
}
.round-detail {
color: var(--color-text-light);
font-size: var(--font-size-sm);
font-family: var(--font-family-primary);
}
.round-actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.round-actions .el-button {
flex: 1;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<el-card class="card">
<template #header>
<div class="card-header">
<span>中奖记录</span>
<div>
<el-button type="success" size="small" @click="exportWinners">
导出
</el-button>
<el-button type="danger" size="small" @click="resetLottery">
清空
</el-button>
</div>
</div>
</template>
<el-table :data="store.winners" style="width: 100%" stripe>
<el-table-column prop="name" label="姓名" width="150" />
<el-table-column prop="prizeName" label="奖品" width="150" />
<el-table-column prop="roundName" label="轮次" width="150" />
<el-table-column prop="time" label="时间" />
</el-table>
</el-card>
</template>
<script setup>
import { onMounted } from 'vue'
import { useLotteryStore } from '../../store'
import { ElMessage } from 'element-plus'
const store = useLotteryStore()
// 初始化store
onMounted(async () => {
await store.initialize()
})
// 重置抽奖
const resetLottery = () => {
store.resetLottery()
ElMessage.success('已重置')
}
// 导出中奖名单
const exportWinners = () => {
if (store.winners.length === 0) {
ElMessage.warning('暂无中奖记录')
return
}
store.exportWinners()
ElMessage.success('导出成功')
}
</script>
<style scoped>
.card {
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-small);
border: 1px solid var(--color-border);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-family-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
:deep(.el-table) {
border-radius: var(--border-radius-md);
overflow: hidden;
font-family: var(--font-family-primary);
}
:deep(.el-table th) {
background: var(--color-background);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-secondary);
border-bottom: 1px solid var(--color-border);
}
:deep(.el-table td) {
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-base);
}
:deep(.el-table .el-table__row:hover > td) {
background: rgba(var(--color-primary-rgb), 0.05);
}
:deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
background: rgba(var(--color-primary-rgb), 0.03);
}
</style>

6
test_participants.csv Normal file
View File

@@ -0,0 +1,6 @@
姓名,部门,职位
张三,技术部,工程师
李四,产品部,产品经理
王五,市场部,市场专员
赵六,人事部,HR
钱七,财务部,会计
1 姓名 部门 职位
2 张三 技术部 工程师
3 李四 产品部 产品经理
4 王五 市场部 市场专员
5 赵六 人事部 HR
6 钱七 财务部 会计

145
utils/indexedDB.js Normal file
View File

@@ -0,0 +1,145 @@
// IndexedDB 封装工具
const DB_NAME = 'LotteryDB'
const DB_VERSION = 1
const STORE_NAME = 'lotteryData'
class IndexedDBHelper {
constructor() {
this.db = null
}
async init() {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
reject(new Error('Failed to open IndexedDB'))
}
request.onsuccess = () => {
this.db = request.result
resolve(this.db)
}
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
})
}
async set(key, value) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put(value, key)
request.onsuccess = () => {
console.log(`IndexedDB: Successfully saved ${key}`)
resolve()
}
request.onerror = () => {
console.error(`IndexedDB: Failed to save ${key}`, request.error)
reject(new Error('Failed to save to IndexedDB'))
}
})
}
async get(key) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(key)
request.onsuccess = () => {
console.log(`IndexedDB: Retrieved ${key}:`, request.result ? 'Yes' : 'No')
resolve(request.result)
}
request.onerror = () => {
console.error(`IndexedDB: Failed to retrieve ${key}`)
reject(new Error('Failed to read from IndexedDB'))
}
})
}
async delete(key) {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.delete(key)
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error('Failed to delete from IndexedDB'))
})
}
async getAll() {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readonly')
const store = transaction.objectStore(STORE_NAME)
const keysRequest = store.getAllKeys()
const result = {}
let completed = 0
let totalKeys = 0
keysRequest.onsuccess = () => {
const keys = keysRequest.result
totalKeys = keys.length
console.log(`IndexedDB: Found ${totalKeys} keys:`, keys)
if (totalKeys === 0) {
resolve(result)
return
}
keys.forEach(key => {
const getRequest = store.get(key)
getRequest.onsuccess = () => {
result[key] = getRequest.result
console.log(`IndexedDB: Retrieved ${key}:`, getRequest.result ? 'Yes' : 'No')
completed++
if (completed === totalKeys) {
console.log('IndexedDB: All data retrieved:', Object.keys(result))
resolve(result)
}
}
getRequest.onerror = () => {
console.error(`IndexedDB: Failed to retrieve ${key}`)
completed++
if (completed === totalKeys) {
resolve(result)
}
}
})
}
keysRequest.onerror = () => reject(new Error('Failed to read all from IndexedDB'))
})
}
async clear() {
if (!this.db) await this.init()
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error('Failed to clear IndexedDB'))
})
}
}
export const indexedDB = new IndexedDBHelper()

14
vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@utils': path.resolve(__dirname, './utils')
},
extensions: ['.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
}
})