Skip to content

实现 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-inputel-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 效果截图

RandomSelector 效果截图

工具访问地址:https://www.i91tools.com/tools/random-selector