Appearance
实现 RandomSelector 工具
分类:education | 标签:随机、决策、抽签、转盘 内置吃什么/谁发言/随机数/大转盘,支持自定义选择场景和备选列表
2.1 功能说明
内置吃什么/谁发言/随机数/大转盘,支持自定义选择场景和备选列表
核心能力
- 历史
- 最小值
- 最大值
- 生成数量
- 不重复
- 12
- 20
- ssq
- dlt
- 3d
- group
- seat
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
lsGet()lsSet()lsRemove()randomInt()shuffle()formatTime()getCustomSelectors()saveCustomSelectors()getSelectorItems()saveSelectorItems()getAllSelectors()getSelector()getHistory()addHistory()clearHistoryForSelector()getSelectorConfig()saveSelectorConfig()getNumberConfig()saveNumberConfig()getExcludedItems()saveExcludedItems()getPickedRecords()savePickedRecords()loadData()onSelectorTap()onSelectorContextMenu()openAddModal()openEditModal()addFormItem()removeFormItem()batchReplace()batchAppend()parseBatchText()confirmAdd()confirmEdit()confirmDelete()deleteSelector()loadCurrentHistory()clearCurrentHistory()onGenerateNumber()getAvailableItems()onPickerSpin()slowSpin()tick()toggleExclude()resetExclusions()removePickedRecord()clearPickedRecords()onDisplayStyleChange()savePickerConfig()onExcludePickedChange()addEditItem()removeEditItem()drawWheel()onWheelSpin()isPipOn()onDiceConfigChange()onRollDice()onCoinFlip()onNamePick()clearNamepickPicked()generateLotterySet()onLotteryGenerate()padNum()downloadLottery()onGroupGenerate()downloadGroup()goBack()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, reactive, computed, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElIcon, ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Plus, ArrowRight } from '@element-plus/icons-vue'
const router = useRouter()
const STORAGE_PREFIX = 'rs_'
const BUILTIN_SELECTORS = [
{
id: 'what-to-eat',
name: '吃什么',
icon: '🍜',
type: 'list',
desc: '不知道吃什么?让随机来决定',
defaultItems: ['火锅', '烧烤', '炒菜', '面条', '饺子', '汉堡', '披萨', '寿司', '麻辣烫', '黄焖鸡', '兰州拉面', '螺蛳粉', '煎饼果子', '肉夹馍', '沙县小吃'],
isBuiltin: true
},
{
id: 'who-speaks',
name: '谁发言',
icon: '🎤',
type: 'list',
desc: '随机指定发言人,公平公正',
defaultItems: ['张伟', '王芳', '李明', '赵静', '刘洋', '陈晨', '杨帆', '周杰'],
isBuiltin: true
},
{
id: 'random-number',
name: '随机数',
icon: '🔢',
type: 'number',
desc: '指定范围生成随机数,支持不重复',
isBuiltin: true
},
{
id: 'wheel',
name: '大转盘',
icon: '🎡',
type: 'wheel',
desc: '转盘抽奖,仪式感拉满',
defaultItems: ['一等奖', '二等奖', '三等奖', '幸运奖', '谢谢参与', '再来一次'],
isBuiltin: true
},
{
id: 'dice',
name: '掷骰子',
icon: '骰',
type: 'dice',
desc: '多面骰子,支持D4/D6/D8/D12/D20',
isBuiltin: true
},
{
id: 'coin',
name: '抛硬币',
icon: '币',
type: 'coin',
desc: '正反二选一,翻转动画',
isBuiltin: true
},
{
id: 'name-pick',
name: '随机点名',
icon: '名',
type: 'namepick',
desc: '粘贴名单随机抽取,支持去重连抽',
isBuiltin: true
},
{
id: 'lottery',
name: '彩票机选',
icon: '彩',
type: 'lottery',
desc: '双色球、大乐透、福彩3D机选号码',
isBuiltin: true
},
{
id: 'group',
name: '分组排座',
icon: '组',
type: 'group',
desc: '名单随机分组、按列排座位',
isBuiltin: true
}
]
const ICON_OPTIONS = [
'🍜', '🎤', '🎡', '🔢', '🎵', '🏀', '🎯', '🎨',
'👍', '🎉', '🎈', '✅', '💡', '🌟', '📚', '🏆',
'🚀', '🌸', '🌀', '🎖️'
]
const DISPLAY_STYLES = [
{ key: 'circle', label: '圆形大卡片', desc: '居中大圆展示结果' },
{ key: 'horizontal', label: '左右滚动', desc: '选项从左向右滚动' },
{ key: 'vertical', label: '上下滚动', desc: '选项从上向下滚动' }
]
const WHEEL_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8B739', '#52C41A', '#EB2F96', '#722ED1'
]
function lsGet(key, def) {
try {
const v = localStorage.getItem(STORAGE_PREFIX + key)
return v ? JSON.parse(v) : def
} catch { return def }
}
function lsSet(key, val) {
try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(val)) } catch {}
}
function lsRemove(key) {
try { localStorage.removeItem(STORAGE_PREFIX + key) } catch {}
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function shuffle(arr) {
const r = arr.slice()
for (let i = r.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[r[i], r[j]] = [r[j], r[i]]
}
return r
}
function formatTime(ts) {
const d = new Date(ts)
const pad = n => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function getCustomSelectors() {
return lsGet('custom_selectors', [])
}
function saveCustomSelectors(list) {
lsSet('custom_selectors', list)
}
function getSelectorItems(id) {
const stored = lsGet('items_' + id, null)
if (stored && Array.isArray(stored)) return stored
const builtin = BUILTIN_SELECTORS.find(b => b.id === id)
if (builtin && builtin.defaultItems) return builtin.defaultItems.slice()
const custom = getCustomSelectors().find(c => c.id === id)
if (custom && custom.items) return custom.items.slice()
return []
}
function saveSelectorItems(id, items) {
lsSet('items_' + id, items)
}
function getAllSelectors() {
const custom = getCustomSelectors()
const list = BUILTIN_SELECTORS.map(b => ({
...b,
items: getSelectorItems(b.id),
itemCount: b.type === 'number' ? 0 : getSelectorItems(b.id).length
}))
custom.forEach(c => {
const items = getSelectorItems(c.id)
list.push({ ...c, items, itemCount: items.length })
})
return list
}
function getSelector(id) {
return getAllSelectors().find(s => s.id === id) || null
}
function getHistory() {
return lsGet('history', [])
}
function addHistory(record) {
const history = getHistory()
history.unshift({ ...record, time: Date.now() })
if (history.length > 50) history.splice(50)
lsSet('history', history)
}
function clearHistoryForSelector(selectorId) {
const history = getHistory().filter(h => h.selectorId !== selectorId)
lsSet('history', history)
}
function getSelectorConfig(id) {
return lsGet('config_' + id, { displayStyle: 'circle', spinDuration: 3, excludePicked: true })
}
function saveSelectorConfig(id, config) {
lsSet('config_' + id, config)
}
function getNumberConfig() {
return lsGet('number_config', { min: 1, max: 100, count: 1, unique: false })
}
function saveNumberConfig(config) {
lsSet('number_config', config)
}
function getExcludedItems(selectorId) {
return lsGet('excluded_' + selectorId, [])
}
function saveExcludedItems(selectorId, items) {
lsSet('excluded_' + selectorId, items)
}
function getPickedRecords(selectorId) {
return lsGet('picked_' + selectorId, [])
}
function savePickedRecords(selectorId, records) {
lsSet('picked_' + selectorId, records)
}
const selectors = ref([])
const activeSelectorId = ref('')
const activeSelector = computed(() => {
if (!activeSelectorId.value) return null
return selectors.value.find(s => s.id === activeSelectorId.value) || null
})
const showAddModal = ref(false)
const showEditModal = ref(false)
const formName = ref('')
const formIcon = ref('🌟')
const formItems = ref([])
const newItemText = ref('')
const batchMode = ref(false)
const batchText = ref('')
const editingId = ref('')
const isEditingBuiltin = ref(false)
const isSpinning = ref(false)
const hasResult = ref(false)
const displayText = ref('?')
const pickerResult = ref('')
const wheelResult = ref('')
const showWheelResult = ref(false)
const numberActiveTab = ref('use')
const pickerActiveTab = ref('use')
const numberConfig = reactive({ min: 1, max: 100, count: 1, unique: false })
const numberAnimDuration = ref(1)
const numberResults = ref([])
const isGenerating = ref(false)
const pickerConfig = reactive({ displayStyle: 'circle', spinDuration: 3, excludePicked: true })
const displayStyles = DISPLAY_STYLES
const excludedItems = ref([])
const pickedRecords = ref([])
const editItems = ref([])
const currentHistory = ref([])
const wheelSpinDuration = ref(4)
const wheelCanvasRef = ref(null)
let wheelRotation = 0
let spinTimer = null
let extraTimer = null
const extraActiveTab = ref('use')
const PIP_PATTERNS = {
1: [5],
2: [1, 9],
3: [1, 5, 9],
4: [1, 3, 7, 9],
5: [1, 3, 5, 7, 9],
6: [1, 3, 4, 6, 7, 9]
}
const diceConfig = reactive({ count: 1, faces: 6 })
const diceResults = ref([])
const diceTotal = ref(0)
const isRolling = ref(false)
const coinResult = ref('')
const coinFlipping = ref(false)
const coinFlipAngle = ref(0)
const namepickNames = ref('')
const namepickConfig = reactive({ count: 1, unique: true })
const namepickResults = ref([])
const namepickRolling = ref(false)
const namepickDisplay = ref('')
const namepickPickedSet = ref(new Set())
const LOTTERY_CONFIGS = {
ssq: { name: '双色球', desc: '6个红球(1-33) + 1个蓝球(1-16)', redCount: 6, redMax: 33, blueCount: 1, blueMax: 16 },
dlt: { name: '大乐透', desc: '5个前区(1-35) + 2个后区(1-12)', redCount: 5, redMax: 35, blueCount: 2, blueMax: 12 },
'3d': { name: '福彩3D', desc: '3个数字(0-9)', redCount: 3, redMax: 9, blueCount: 0, blueMax: 0 }
}
const lotteryType = ref('ssq')
const lotteryCount = ref(1)
const lotteryResults = ref([])
const groupNames = ref('')
const groupMode = ref('group')
const groupCount = ref(2)
const groupColCount = ref(4)
const groupResults = ref([])
const displayItems = computed(() => {
if (!activeSelector.value || activeSelector.value.type === 'number') return []
const items = activeSelector.value.items
return items.map(text => {
let excluded = false
let excludeReason = ''
if (excludedItems.value.includes(text)) {
excluded = true
excludeReason = 'manual'
}
if (pickerConfig.excludePicked && pickedRecords.value.some(r => r.result === text)) {
excluded = true
excludeReason = 'picked'
}
return { text, excluded, excludeReason, highlight: text === pickerResult.value && hasResult.value }
})
})
function loadData() {
selectors.value = getAllSelectors()
}
function onSelectorTap(selector) {
activeSelectorId.value = selector.id
hasResult.value = false
displayText.value = '?'
pickerResult.value = ''
wheelResult.value = ''
numberResults.value = []
isSpinning.value = false
isGenerating.value = false
if (selector.type === 'number') {
const cfg = getNumberConfig()
Object.assign(numberConfig, cfg)
const sCfg = getSelectorConfig(selector.id)
numberAnimDuration.value = sCfg.spinDuration || 1
numberActiveTab.value = 'use'
} else if (selector.type === 'wheel') {
const cfg = getSelectorConfig(selector.id)
wheelSpinDuration.value = cfg.spinDuration || 4
editItems.value = selector.items.slice()
pickerActiveTab.value = 'use'
nextTick(() => drawWheel())
} else if (['dice', 'coin', 'namepick', 'lottery', 'group'].includes(selector.type)) {
diceResults.value = []
diceTotal.value = 0
isRolling.value = false
coinResult.value = ''
coinFlipping.value = false
namepickResults.value = []
namepickRolling.value = false
namepickDisplay.value = ''
lotteryResults.value = []
groupResults.value = []
const dcfg = lsGet('dice_config', null)
if (dcfg) Object.assign(diceConfig, dcfg)
const lcfg = lsGet('lottery_config', null)
if (lcfg) {
lotteryType.value = lcfg.type || 'ssq'
lotteryCount.value = lcfg.count || 1
}
extraActiveTab.value = 'use'
} else {
const cfg = getSelectorConfig(selector.id)
Object.assign(pickerConfig, cfg)
excludedItems.value = getExcludedItems(selector.id)
pickedRecords.value = getPickedRecords(selector.id)
editItems.value = selector.items.slice()
pickerActiveTab.value = 'use'
}
loadCurrentHistory()
}
function onSelectorContextMenu(e, selector) {
if (selector.isBuiltin) {
ElMessageBox.confirm('编辑备选列表?', '操作', {
confirmButtonText: '编辑',
cancelButtonText: '取消',
type: 'info'
}).then(() => openEditModal(selector)).catch(() => {})
} else {
ElMessageBox.confirm('请选择操作', '操作', {
distinguishCancelAndClose: true,
confirmButtonText: '编辑',
cancelButtonText: '删除',
type: 'info'
}).then(() => openEditModal(selector)).catch(action => {
if (action === 'cancel') confirmDelete(selector)
})
}
}
function openAddModal() {
formName.value = ''
formIcon.value = '🌟'
formItems.value = ['选项一', '选项二']
newItemText.value = ''
batchMode.value = false
batchText.value = ''
showAddModal.value = true
}
function openEditModal(selector) {
editingId.value = selector.id
isEditingBuiltin.value = selector.isBuiltin
formName.value = selector.name
formIcon.value = selector.icon
formItems.value = selector.items.slice()
newItemText.value = ''
batchMode.value = false
batchText.value = ''
showEditModal.value = true
}
function addFormItem() {
const text = newItemText.value.trim()
if (!text) { ElMessage.warning('请输入选项内容'); return }
if (text.length > 20) { ElMessage.warning('选项最多20个字'); return }
formItems.value.push(text)
newItemText.value = ''
}
function removeFormItem(idx) {
formItems.value.splice(idx, 1)
}
function batchReplace() {
const items = parseBatchText()
if (items.length < 2) { ElMessage.warning('至少需要2个有效选项'); return }
formItems.value = items
batchText.value = ''
ElMessage.success(`已替换${items.length}项`)
}
function batchAppend() {
const items = parseBatchText()
if (items.length === 0) { ElMessage.warning('请输入有效内容'); return }
const merged = formItems.value.slice()
items.forEach(t => { if (!merged.includes(t)) merged.push(t) })
formItems.value = merged
batchText.value = ''
ElMessage.success(`已追加${items.length}项`)
}
function parseBatchText() {
const text = batchText.value.trim()
if (!text) return []
return text.split(/\n/).map(l => l.trim()).filter(l => l && l.length <= 20)
}
function confirmAdd() {
const name = formName.value.trim()
if (!name) { ElMessage.warning('请输入选择器名称'); return }
if (formItems.value.length < 2) { ElMessage.warning('至少需要2个选项'); return }
const custom = getCustomSelectors()
custom.push({
id: 'custom_' + Date.now(),
name,
icon: formIcon.value,
type: 'list',
desc: '自定义选择器',
items: formItems.value.slice(),
isBuiltin: false,
createdAt: Date.now()
})
saveCustomSelectors(custom)
showAddModal.value = false
loadData()
ElMessage.success('添加成功')
}
function confirmEdit() {
const name = formName.value.trim()
if (!name) { ElMessage.warning('请输入选择器名称'); return }
if (formItems.value.length < 2) { ElMessage.warning('至少需要2个选项'); return }
const id = editingId.value
if (isEditingBuiltin.value) {
saveSelectorItems(id, formItems.value.slice())
} else {
const custom = getCustomSelectors()
const idx = custom.findIndex(c => c.id === id)
if (idx >= 0) {
custom[idx].name = name
custom[idx].icon = formIcon.value
custom[idx].items = formItems.value.slice()
saveCustomSelectors(custom)
}
}
showEditModal.value = false
loadData()
if (activeSelectorId.value === id) {
onSelectorTap(selectors.value.find(s => s.id === id))
}
ElMessage.success('保存成功')
}
function confirmDelete(selector) {
ElMessageBox.confirm(`确定删除「${selector.name}」?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const custom = getCustomSelectors().filter(c => c.id !== selector.id)
saveCustomSelectors(custom)
lsRemove('items_' + selector.id)
if (activeSelectorId.value === selector.id) {
activeSelectorId.value = ''
}
loadData()
ElMessage.success('已删除')
}).catch(() => {})
}
function deleteSelector() {
if (!activeSelector.value) return
confirmDelete(activeSelector.value)
}
function loadCurrentHistory() {
const id = activeSelectorId.value
if (!id) { currentHistory.value = []; return }
currentHistory.value = getHistory().filter(h => h.selectorId === id).slice(0, 20)
}
function clearCurrentHistory() {
ElMessageBox.confirm('确定清除所有历史记录?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
clearHistoryForSelector(activeSelectorId.value)
currentHistory.value = []
ElMessage.success('已清除')
}).catch(() => {})
}
function onGenerateNumber() {
if (isGenerating.value) return
const { min, max, count, unique } = numberConfig
if (isNaN(min) || isNaN(max)) { ElMessage.warning('请输入有效数字'); return }
if (min >= max) { ElMessage.warning('最小值须小于最大值'); return }
if (isNaN(count) || count < 1) { ElMessage.warning('数量至少为1'); return }
if (count > 100) { ElMessage.warning('最多生成100个'); return }
const range = max - min + 1
if (unique && count > range) { ElMessage.warning('不重复数量超过范围'); return }
isGenerating.value = true
numberResults.value = []
saveNumberConfig({ min, max, count, unique })
let results = []
if (unique) {
const pool = []
for (let i = min; i <= max; i++) pool.push(i)
results = shuffle(pool).slice(0, count)
} else {
for (let j = 0; j < count; j++) results.push(randomInt(min, max))
}
const animSteps = numberAnimDuration.value * 8
let step = 0
spinTimer = setInterval(() => {
step++
const temp = []
for (let k = 0; k < results.length; k++) temp.push(randomInt(min, max))
numberResults.value = temp
if (step >= animSteps) {
clearInterval(spinTimer)
spinTimer = null
numberResults.value = results
isGenerating.value = false
let resultStr = results.join(', ')
if (resultStr.length > 30) resultStr = resultStr.substring(0, 30) + '...'
addHistory({ selectorId: 'random-number', selectorName: '随机数', icon: '🔢', result: resultStr })
loadCurrentHistory()
}
}, 80)
}
function getAvailableItems() {
if (!activeSelector.value) return []
const items = activeSelector.value.items
let available = items.filter(item => !excludedItems.value.includes(item))
if (pickerConfig.excludePicked) {
const pickedNames = pickedRecords.value.map(r => r.result)
available = available.filter(item => !pickedNames.includes(item))
}
return available
}
function onPickerSpin() {
if (isSpinning.value) return
const availableItems = getAvailableItems()
if (availableItems.length < 1) { ElMessage.warning('没有可用的选项'); return }
if (availableItems.length < 2 && !pickerConfig.excludePicked) { ElMessage.warning('至少需要2个可用选项'); return }
isSpinning.value = true
hasResult.value = false
pickerResult.value = ''
const finalIndex = randomInt(0, availableItems.length - 1)
const finalResult = availableItems[finalIndex]
const duration = pickerConfig.spinDuration || 3
const totalSteps = Math.round(duration * 10)
let step = 0
spinTimer = setInterval(() => {
step++
const randomIdx = randomInt(0, availableItems.length - 1)
displayText.value = availableItems[randomIdx]
if (step >= totalSteps * 0.6) {
clearInterval(spinTimer)
slowSpin(finalResult, availableItems)
}
}, 60)
}
function slowSpin(finalResult, availableItems) {
let step = 0
const maxStep = 10
let interval = 100
function tick() {
step++
if (step < maxStep) {
const randomIdx = randomInt(0, availableItems.length - 1)
displayText.value = availableItems[randomIdx]
interval += 30
spinTimer = setTimeout(tick, interval)
} else {
displayText.value = finalResult
isSpinning.value = false
hasResult.value = true
pickerResult.value = finalResult
addHistory({
selectorId: activeSelectorId.value,
selectorName: activeSelector.value.name,
icon: activeSelector.value.icon,
result: finalResult
})
loadCurrentHistory()
if (pickerConfig.excludePicked) {
pickedRecords.value = [...pickedRecords.value, { result: finalResult, time: Date.now() }]
savePickedRecords(activeSelectorId.value, pickedRecords.value)
}
}
}
tick()
}
function toggleExclude(dItem) {
if (dItem.excludeReason === 'picked') {
ElMessage.info('已选排除,请删除下方记录恢复')
return
}
const itemText = dItem.text
const idx = excludedItems.value.indexOf(itemText)
if (idx >= 0) {
excludedItems.value.splice(idx, 1)
} else {
excludedItems.value.push(itemText)
}
saveExcludedItems(activeSelectorId.value, excludedItems.value)
}
function resetExclusions() {
ElMessageBox.confirm('将清空所有手工排除和已选排除记录,恢复全部选项为可用状态', '重置排除状态', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
saveExcludedItems(activeSelectorId.value, [])
savePickedRecords(activeSelectorId.value, [])
excludedItems.value = []
pickedRecords.value = []
ElMessage.success('已重置,全部选项恢复可用')
}).catch(() => {})
}
function removePickedRecord(idx) {
pickedRecords.value.splice(idx, 1)
savePickedRecords(activeSelectorId.value, pickedRecords.value)
}
function clearPickedRecords() {
ElMessageBox.confirm('清除后,所有被排除的选项将恢复可用', '清除排除记录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
savePickedRecords(activeSelectorId.value, [])
pickedRecords.value = []
ElMessage.success('已清除,选项恢复可用')
}).catch(() => {})
}
function onDisplayStyleChange(key) {
pickerConfig.displayStyle = key
savePickerConfig()
}
function savePickerConfig() {
saveSelectorConfig(activeSelectorId.value, { ...pickerConfig })
}
function onExcludePickedChange(val) {
savePickerConfig()
if (val) {
pickedRecords.value = getPickedRecords(activeSelectorId.value)
} else {
savePickedRecords(activeSelectorId.value, [])
pickedRecords.value = []
}
}
function addEditItem() {
const text = newItemText.value.trim()
if (!text) { ElMessage.warning('请输入选项内容'); return }
if (text.length > 20) { ElMessage.warning('选项最多20个字'); return }
if (activeSelector.value && activeSelector.value.type === 'wheel' && editItems.value.length >= 12) {
ElMessage.warning('最多12个选项')
return
}
editItems.value.push(text)
saveSelectorItems(activeSelectorId.value, editItems.value.slice())
newItemText.value = ''
loadData()
if (activeSelector.value && activeSelector.value.type === 'wheel') {
nextTick(() => drawWheel())
}
}
function removeEditItem(idx) {
if (editItems.value.length <= 2) { ElMessage.warning('至少保留2项'); return }
editItems.value.splice(idx, 1)
saveSelectorItems(activeSelectorId.value, editItems.value.slice())
loadData()
if (activeSelector.value && activeSelector.value.type === 'wheel') {
wheelRotation = 0
nextTick(() => drawWheel())
}
}
function drawWheel() {
const canvas = wheelCanvasRef.value
if (!canvas) return
const items = activeSelector.value ? activeSelector.value.items : []
if (!items || items.length === 0) return
const ctx = canvas.getContext('2d')
const size = 400
const center = size / 2
const radius = size / 2 - 4
const segAngle = (Math.PI * 2) / items.length
ctx.clearRect(0, 0, size, size)
for (let i = 0; i < items.length; i++) {
const startAngle = i * segAngle - Math.PI / 2
const endAngle = startAngle + segAngle
ctx.beginPath()
ctx.moveTo(center, center)
ctx.arc(center, center, radius, startAngle, endAngle)
ctx.closePath()
ctx.fillStyle = WHEEL_COLORS[i % WHEEL_COLORS.length]
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.stroke()
ctx.save()
ctx.translate(center, center)
ctx.rotate(startAngle + segAngle / 2)
ctx.fillStyle = '#fff'
ctx.font = 'bold 14px sans-serif'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
let text = items[i]
if (text.length > 6) text = text.substring(0, 6) + '...'
ctx.fillText(text, radius - 16, 0)
ctx.restore()
}
ctx.beginPath()
ctx.arc(center, center, 24, 0, Math.PI * 2)
ctx.fillStyle = '#fff'
ctx.fill()
ctx.strokeStyle = '#378add'
ctx.lineWidth = 3
ctx.stroke()
}
function onWheelSpin() {
if (isSpinning.value) return
const items = activeSelector.value ? activeSelector.value.items : []
if (items.length < 2) { ElMessage.warning('至少需要2个选项'); return }
if (items.length > 12) { ElMessage.warning('大转盘最多12个选项'); return }
showWheelResult.value = false
const segAngle = 360 / items.length
const target = randomInt(0, items.length - 1)
const offset = (Math.random() - 0.5) * segAngle * 0.7
const targetAngle = 360 - (target * segAngle + segAngle / 2) + offset
const currentMod = ((wheelRotation % 360) + 360) % 360
const delta = ((targetAngle - currentMod) + 360) % 360
const finalRotation = wheelRotation + 5 * 360 + delta
const durationMs = (wheelSpinDuration.value || 4) * 1000
const canvas = wheelCanvasRef.value
if (canvas) {
canvas.style.transition = `transform ${durationMs}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`
canvas.style.transform = `rotate(${finalRotation}deg)`
}
isSpinning.value = true
hasResult.value = false
setTimeout(() => {
const result = items[target]
wheelResult.value = result
wheelRotation = finalRotation
isSpinning.value = false
hasResult.value = true
showWheelResult.value = true
addHistory({
selectorId: activeSelectorId.value,
selectorName: activeSelector.value ? activeSelector.value.name : '大转盘',
icon: activeSelector.value ? activeSelector.value.icon : '🎡',
result
})
loadCurrentHistory()
}, durationMs + 100)
}
function isPipOn(value, pos) {
const pattern = PIP_PATTERNS[value]
return pattern ? pattern.includes(pos) : false
}
function onDiceConfigChange() {
lsSet('dice_config', { count: diceConfig.count, faces: diceConfig.faces })
diceResults.value = []
diceTotal.value = 0
}
function onRollDice() {
if (isRolling.value) return
const count = diceConfig.count
const faces = diceConfig.faces
isRolling.value = true
diceResults.value = Array.from({ length: count }, () => randomInt(1, faces))
let step = 0
const maxSteps = 16
extraTimer = setInterval(() => {
step++
diceResults.value = diceResults.value.map(() => randomInt(1, faces))
if (step >= maxSteps) {
clearInterval(extraTimer)
extraTimer = null
diceResults.value = diceResults.value.map(() => randomInt(1, faces))
diceTotal.value = diceResults.value.reduce((a, b) => a + b, 0)
isRolling.value = false
const resultStr = diceResults.value.join('+') + '=' + diceTotal.value
addHistory({ selectorId: 'dice', selectorName: '掷骰子', icon: '骰', result: resultStr })
loadCurrentHistory()
}
}, 80)
}
function onCoinFlip() {
if (coinFlipping.value) return
coinFlipping.value = true
const result = Math.random() < 0.5 ? '正面' : '反面'
const currentMod = ((coinFlipAngle.value % 360) + 360) % 360
const targetMod = result === '正面' ? 0 : 180
let diff = targetMod - currentMod
if (diff <= 0) diff += 360
coinFlipAngle.value += 360 * 5 + diff
extraTimer = setTimeout(() => {
coinResult.value = result
coinFlipping.value = false
addHistory({ selectorId: 'coin', selectorName: '抛硬币', icon: '币', result })
loadCurrentHistory()
}, 1300)
}
const namepickNameList = computed(() => {
const text = namepickNames.value.trim()
if (!text) return []
return text.split(/\n/).map(l => l.trim()).filter(l => l)
})
function onNamePick() {
if (namepickRolling.value) return
const allNames = namepickNameList.value
if (allNames.length === 0) { ElMessage.warning('请先输入名单'); return }
let pool = allNames.slice()
if (namepickConfig.unique) {
pool = pool.filter(n => !namepickPickedSet.value.has(n))
}
const count = namepickConfig.count
if (pool.length < count) {
ElMessage.warning(namepickConfig.unique ? '剩余可抽人数不足,请减少人数或清除已抽记录' : '名单人数不足')
return
}
const shuffled = shuffle(pool)
const picked = shuffled.slice(0, count)
namepickRolling.value = true
namepickResults.value = []
namepickDisplay.value = ''
let step = 0
const maxSteps = 25
extraTimer = setInterval(() => {
step++
namepickDisplay.value = pool[randomInt(0, pool.length - 1)]
if (step >= maxSteps) {
clearInterval(extraTimer)
extraTimer = null
namepickDisplay.value = ''
namepickResults.value = picked
namepickRolling.value = false
if (namepickConfig.unique) {
picked.forEach(n => namepickPickedSet.value.add(n))
}
addHistory({ selectorId: 'name-pick', selectorName: '随机点名', icon: '名', result: picked.join('、') })
loadCurrentHistory()
}
}, 70)
}
function clearNamepickPicked() {
namepickPickedSet.value = new Set()
ElMessage.success('已清除已抽记录')
}
function generateLotterySet(type) {
const cfg = LOTTERY_CONFIGS[type]
if (type === '3d') {
return { red: Array.from({ length: 3 }, () => randomInt(0, 9)), blue: [] }
}
const redPool = []
for (let i = 1; i <= cfg.redMax; i++) redPool.push(i)
const red = shuffle(redPool).slice(0, cfg.redCount).sort((a, b) => a - b)
let blue = []
if (cfg.blueCount > 0) {
const bluePool = []
for (let i = 1; i <= cfg.blueMax; i++) bluePool.push(i)
blue = shuffle(bluePool).slice(0, cfg.blueCount).sort((a, b) => a - b)
}
return { red, blue }
}
function onLotteryGenerate() {
const count = lotteryCount.value
const type = lotteryType.value
lsSet('lottery_config', { type, count })
const results = []
for (let i = 0; i < count; i++) results.push(generateLotterySet(type))
lotteryResults.value = results
const cfg = LOTTERY_CONFIGS[type]
const resultStr = results.map(r => {
const red = r.red.map(n => String(n).padStart(2, '0')).join(' ')
if (r.blue.length > 0) {
const blue = r.blue.map(n => String(n).padStart(2, '0')).join(' ')
return red + ' + ' + blue
}
return red
}).join(' | ')
addHistory({ selectorId: 'lottery', selectorName: '彩票机选', icon: '彩', result: cfg.name + ': ' + resultStr })
loadCurrentHistory()
}
function padNum(n) {
return String(n).padStart(2, '0')
}
function downloadLottery() {
if (lotteryResults.value.length === 0) { ElMessage.warning('请先生成号码'); return }
const cfg = LOTTERY_CONFIGS[lotteryType.value]
const lines = [cfg.name + ' 机选号码']
lotteryResults.value.forEach((r, idx) => {
const red = r.red.map(n => padNum(n)).join(' ')
if (r.blue.length > 0) {
const blue = r.blue.map(n => padNum(n)).join(' ')
lines.push('第' + (idx + 1) + '注:' + red + ' + ' + blue)
} else {
lines.push('第' + (idx + 1) + '注:' + red)
}
})
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = cfg.name + '_机选.txt'
a.click()
URL.revokeObjectURL(url)
}
const groupNameList = computed(() => {
const text = groupNames.value.trim()
if (!text) return []
return text.split(/\n/).map(l => l.trim()).filter(l => l)
})
function onGroupGenerate() {
const names = groupNameList.value
if (names.length === 0) { ElMessage.warning('请先输入名单'); return }
const shuffled = shuffle(names)
if (groupMode.value === 'group') {
const numGroups = groupCount.value
if (numGroups < 2) { ElMessage.warning('至少分2组'); return }
if (numGroups > names.length) { ElMessage.warning('组数不能超过人数'); return }
const groups = Array.from({ length: numGroups }, () => [])
shuffled.forEach((name, idx) => { groups[idx % numGroups].push(name) })
groupResults.value = groups.map((g, i) => ({ index: i + 1, members: g }))
} else {
const cols = groupColCount.value
if (cols < 1) { ElMessage.warning('列数至少为1'); return }
const rows = Math.ceil(shuffled.length / cols)
const seats = []
for (let r = 0; r < rows; r++) {
const row = []
for (let c = 0; c < cols; c++) {
const idx = r * cols + c
row.push(idx < shuffled.length ? shuffled[idx] : '')
}
seats.push(row)
}
groupResults.value = seats.map((row, i) => ({ index: i + 1, members: row }))
}
let resultStr = ''
if (groupMode.value === 'group') {
resultStr = groupCount.value + '组分组:' + groupResults.value.map(g => g.members.join(',')).join(' | ')
} else {
resultStr = groupColCount.value + '列排座:' + groupResults.value.flatMap(g => g.members).filter(m => m).join(',')
}
if (resultStr.length > 60) resultStr = resultStr.substring(0, 60) + '...'
addHistory({ selectorId: 'group', selectorName: '分组排座', icon: '组', result: resultStr })
loadCurrentHistory()
}
function downloadGroup() {
if (groupResults.value.length === 0) { ElMessage.warning('请先生成结果'); return }
const lines = []
if (groupMode.value === 'group') {
lines.push('分组结果')
groupResults.value.forEach(g => {
lines.push('第' + g.index + '组:' + g.members.join('、'))
})
} else {
lines.push('座位表')
groupResults.value.forEach(row => {
lines.push('第' + row.index + '排:' + row.members.filter(m => m).join(' '))
})
}
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = (groupMode.value === 'group' ? '分组结果' : '座位表') + '.txt'
a.click()
URL.revokeObjectURL(url)
}
function goBack() {
router.push('/general')
}
onMounted(() => {
loadData()
})
onUnmounted(() => {
if (spinTimer) {
clearInterval(spinTimer)
clearTimeout(spinTimer)
spinTimer = null
}
if (extraTimer) {
clearInterval(extraTimer)
clearTimeout(extraTimer)
extraTimer = null
}
})
watch(activeSelectorId, () => {
if (spinTimer) {
clearInterval(spinTimer)
clearTimeout(spinTimer)
spinTimer = null
}
if (extraTimer) {
clearInterval(extraTimer)
clearTimeout(extraTimer)
extraTimer = null
}
isSpinning.value = false
isGenerating.value = false
isRolling.value = false
coinFlipping.value = false
namepickRolling.value = false
})2.3 效果截图
