diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 99fa5f1..123fe4c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -15,7 +15,7 @@ jobs: contents: read strategy: matrix: - node-version: [20.x] + node-version: [22.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/package.json b/package.json index 5d5a7b7..482e7f0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "axios": "^1.7.8", "canvas-confetti": "^1.9.3", "dayjs": "^1.11.13", + "dexie": "^4.2.1", "github-markdown-css": "^5.8.0", "localforage": "^1.10.0", "markdown-it": "^14.1.0", @@ -58,6 +59,7 @@ "@vitest/ui": "^3.2.4", "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.20", + "baseline-browser-mapping": "^2.8.32", "daisyui": "^5.1.13", "eslint": "^9.15.0", "eslint-plugin-vue": "^10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9cda78..8799787 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.13 + dexie: + specifier: ^4.2.1 + version: 4.2.1 github-markdown-css: specifier: ^5.8.0 version: 5.8.0 @@ -129,6 +132,9 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.49) + baseline-browser-mapping: + specifier: ^2.8.32 + version: 2.8.32 daisyui: specifier: ^5.1.13 version: 5.1.13 @@ -1363,42 +1369,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.0': resolution: {integrity: sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.0': resolution: {integrity: sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.0': resolution: {integrity: sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.0': resolution: {integrity: sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.0': resolution: {integrity: sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.0': resolution: {integrity: sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==} @@ -1469,67 +1469,56 @@ packages: resolution: {integrity: sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.0': resolution: {integrity: sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.0': resolution: {integrity: sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.0': resolution: {integrity: sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.0': resolution: {integrity: sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.0': resolution: {integrity: sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.0': resolution: {integrity: sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.0': resolution: {integrity: sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.0': resolution: {integrity: sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.0': resolution: {integrity: sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.0': resolution: {integrity: sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.0': resolution: {integrity: sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==} @@ -1607,28 +1596,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.13': resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.13': resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.13': resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.13': resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} @@ -2281,8 +2266,8 @@ packages: resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} engines: {node: '>=0.10.0'} - baseline-browser-mapping@2.8.6: - resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==} + baseline-browser-mapping@2.8.32: + resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true bidi-js@1.0.3: @@ -2701,6 +2686,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3747,28 +3735,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -7823,7 +7807,7 @@ snapshots: mixin-deep: 1.3.2 pascalcase: 0.1.1 - baseline-browser-mapping@2.8.6: {} + baseline-browser-mapping@2.8.32: {} bidi-js@1.0.3: dependencies: @@ -7886,7 +7870,7 @@ snapshots: browserslist@4.26.2: dependencies: - baseline-browser-mapping: 2.8.6 + baseline-browser-mapping: 2.8.32 caniuse-lite: 1.0.30001743 electron-to-chromium: 1.5.222 node-releases: 2.0.21 @@ -8238,6 +8222,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.2.1: {} + dom-accessibility-api@0.5.16: {} dom-serializer@0.2.2: diff --git a/src/App.vue b/src/App.vue index 0aee9b9..96991db 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,13 @@ + + + + + + {{ title }} + + + {{ desc }} + + + + + + {{ cancelText }} + + + {{ submitText }} + + + + + + + + diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts new file mode 100644 index 0000000..aa1cc9f --- /dev/null +++ b/src/components/Loading/index.ts @@ -0,0 +1,5 @@ +import type { LoadingOptions } from './loading-context' +import Loading from './index.vue' +import { loadingKey, loadingState } from './loading-context' + +export { Loading, loadingKey, LoadingOptions, loadingState } diff --git a/src/components/Loading/index.vue b/src/components/Loading/index.vue new file mode 100644 index 0000000..62a2781 --- /dev/null +++ b/src/components/Loading/index.vue @@ -0,0 +1,22 @@ + + + + + + {{ text ? text : '加载中' }} + + + + diff --git a/src/components/Loading/loading-context.ts b/src/components/Loading/loading-context.ts new file mode 100644 index 0000000..6093acf --- /dev/null +++ b/src/components/Loading/loading-context.ts @@ -0,0 +1,61 @@ +// src/contexts/loading-context.ts +import type { InjectionKey, Ref } from 'vue' +import { ref } from 'vue' + +// 定义 Loading 配置类型 +export interface LoadingOptions { + visible: Ref + text: Ref + fullscreen: Ref + zIndex: Ref + count: Ref + show: (options?: Partial<{ text: string, fullscreen: boolean, zIndex: number }>) => void + hide: () => void +} + +// 注入密钥(Symbol 确保唯一性) +export const loadingKey: InjectionKey = Symbol('loading') + +// 全局状态(单例) +const visible = ref(false) +const text = ref('') +const fullscreen = ref(true) +const zIndex = ref(9999) +const count = ref(0) + +// 显示 Loading +function show(options?: Partial<{ text: string, fullscreen: boolean, zIndex: number }>) { + count.value++ + if (count.value > 1) + return + visible.value = true + if (options) { + text.value = options.text || '' + fullscreen.value = options.fullscreen ?? true + zIndex.value = options.zIndex || 9999 + } +} + +// 隐藏 Loading +function hide() { + if (count.value <= 0) + return + count.value-- + if (count.value === 0) { + visible.value = false + text.value = '' + fullscreen.value = true + zIndex.value = 9999 + } +} + +// 导出全局状态(供根组件提供) +export const loadingState: LoadingOptions = { + visible, + text, + fullscreen, + zIndex, + count, + show, + hide, +} diff --git a/src/components/PageHeader/index.vue b/src/components/PageHeader/index.vue new file mode 100644 index 0000000..fd14914 --- /dev/null +++ b/src/components/PageHeader/index.vue @@ -0,0 +1,24 @@ + + + + + + {{ props.title }} + + + + + + + + + + + + diff --git a/src/components/StarsBackground/index.vue b/src/components/StarsBackground/index.vue deleted file mode 100644 index 85a4ac5..0000000 --- a/src/components/StarsBackground/index.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - diff --git a/src/layout/index.vue b/src/layout/index.vue index acd521c..10cc77e 100644 --- a/src/layout/index.vue +++ b/src/layout/index.vue @@ -1,12 +1,12 @@ + + + + + + + + + + + {{ t('button.allDelete') }} + + + {{ t('button.downloadTemplate') }} + + + + + + + {{ t('button.importData') }} + + + + + {{ t('button.resetData') }} + + + {{ t('button.exportResult') }} + + + {{ t('table.luckyPeopleNumber') }}: + {{ alreadyPersonList.length }} + / + {{ allPersonList.length }} + + + + + + + + + + diff --git a/src/views/Config/Person/PersonAll/useViewModel.ts b/src/views/Config/Person/PersonAll/useViewModel.ts new file mode 100644 index 0000000..9464db9 --- /dev/null +++ b/src/views/Config/Person/PersonAll/useViewModel.ts @@ -0,0 +1,167 @@ +import type { Ref } from 'vue' +import type { IPersonConfig } from '@/types/storeType' +import { storeToRefs } from 'pinia' +import { inject } from 'vue' +import * as XLSX from 'xlsx' +import { loadingKey } from '@/components/Loading' +import i18n from '@/locales/i18n' +import useStore from '@/store' +import { readFileBinary } from '@/utils/file' +import ImportExcelWorker from './importExcel.worker?worker' + +export function useViewModel({ exportInputFileRef }: { exportInputFileRef: Ref }) { + const worker: Worker | null = new ImportExcelWorker() + const loading = inject(loadingKey) + const personConfig = useStore().personConfig + const { getAllPersonList: allPersonList, getAlreadyPersonList: alreadyPersonList } = storeToRefs(personConfig) + const tableColumns = [ + { + label: i18n.global.t('data.number'), + props: 'uid', + }, + { + label: i18n.global.t('data.name'), + props: 'name', + }, + { + label: i18n.global.t('data.department'), + props: 'department', + }, + { + label: i18n.global.t('data.avatar'), + props: 'avatar', + formatValue(row: any) { + return row.avatar ? `` : '-' + }, + }, + { + label: i18n.global.t('data.identity'), + props: 'identity', + }, + { + label: i18n.global.t('data.isWin'), + props: 'isWin', + formatValue(row: IPersonConfig) { + return row.isWin ? i18n.global.t('data.yes') : i18n.global.t('data.no') + }, + }, + { + label: i18n.global.t('data.operation'), + actions: [ + // { + // label: '编辑', + // type: 'btn-info', + // onClick: (row: any) => { + // delPersonItem(row) + // } + // }, + { + label: i18n.global.t('data.delete'), + type: 'btn-error', + onClick: (row: IPersonConfig) => { + delPersonItem(row) + }, + }, + + ], + }, + ] + /// 向worker发送消息 + function sendWorkerMessage(message: any) { + if (worker) { + worker.postMessage(message) + } + } + /// 开始导入 + function startWorker(data: Event) { + loading?.show() + sendWorkerMessage({ type: 'start', data }) + } + /** + * 获取用户数据 + */ + async function handleFileChange(e: Event) { + // worker = new ImportExcelWorker() + if (worker) { + worker.onmessage = (e) => { + if (e.data.type === 'done') { + personConfig.resetPerson() + personConfig.addNotPersonList(e.data.data) + // 导入成功后清空file input + clearFileInput() + } + loading?.hide() + } + } + const dataBinary = await readFileBinary(((e.target as HTMLInputElement).files as FileList)[0]!) + startWorker(dataBinary) + } + // 清空file input + function clearFileInput() { + if (exportInputFileRef.value) { + exportInputFileRef.value.value = '' + } + } + /// 导出数据 + function exportData() { + let data = JSON.parse(JSON.stringify(allPersonList.value)) + // 排除一些字段 + for (let i = 0; i < data.length; i++) { + delete data[i].x + delete data[i].y + delete data[i].id + delete data[i].createTime + delete data[i].updateTime + delete data[i].prizeId + // 修改字段名称 + if (data[i].isWin) { + data[i].isWin = i18n.global.t('data.yes') + } + else { + data[i].isWin = i18n.global.t('data.no') + } + // 格式化数组为 + data[i].prizeTime = data[i].prizeTime.join(',') + data[i].prizeName = data[i].prizeName.join(',') + } + let dataString = JSON.stringify(data) + dataString = dataString + .replaceAll(/uid/g, i18n.global.t('data.number')) + .replaceAll(/isWin/g, i18n.global.t('data.isWin')) + .replaceAll(/department/g, i18n.global.t('data.department')) + .replaceAll(/name/g, i18n.global.t('data.name')) + .replaceAll(/identity/g, i18n.global.t('data.identity')) + .replaceAll(/prizeName/g, i18n.global.t('data.prizeName')) + .replaceAll(/prizeTime/g, i18n.global.t('data.prizeTime')) + + data = JSON.parse(dataString) + + if (data.length > 0) { + const dataBinary = XLSX.utils.json_to_sheet(data) + const dataBinaryBinary = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(dataBinaryBinary, dataBinary, 'Sheet1') + XLSX.writeFile(dataBinaryBinary, 'data.xlsx') + } + } + + function resetData() { + personConfig.resetAlreadyPerson() + } + + function deleteAll() { + personConfig.deleteAllPerson() + } + + function delPersonItem(row: IPersonConfig) { + personConfig.deletePerson(row) + } + return { + resetData, + deleteAll, + handleFileChange, + exportData, + alreadyPersonList, + allPersonList, + tableColumns, + } +} diff --git a/src/views/Config/Person/PersonAlready.vue b/src/views/Config/Person/PersonAlready.vue index 04335d3..cb290c9 100644 --- a/src/views/Config/Person/PersonAlready.vue +++ b/src/views/Config/Person/PersonAlready.vue @@ -1,12 +1,13 @@ diff --git a/src/views/Home/components/HeaderTitle/index.vue b/src/views/Home/components/HeaderTitle/index.vue new file mode 100644 index 0000000..03e6a4d --- /dev/null +++ b/src/views/Home/components/HeaderTitle/index.vue @@ -0,0 +1,89 @@ + + + + + + {{ topTitle }} + + + + {{ t('button.noInfoAndImport') }} + + + {{ t('button.useDefault') }} + + + + + + diff --git a/src/views/Home/components/OptionsButton/index.scss b/src/views/Home/components/OptionsButton/index.scss new file mode 100644 index 0000000..96d3f8f --- /dev/null +++ b/src/views/Home/components/OptionsButton/index.scss @@ -0,0 +1,335 @@ +#menu { + position: absolute; + z-index: 100; + width: 100%; + bottom: 50px; + text-align: center; + margin: 0 auto; + font-size: 32px; + + .start { + // 居中 + display: flex; + justify-content: center; + } + + .btn-stars { + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + width: 13rem; + overflow: hidden; + height: 3rem; + background-size: 300% 300%; + backdrop-filter: blur(1rem); + border-radius: 5rem; + transition: 0.5s; + animation: gradient_301 5s ease infinite; + border: double 4px transparent; + background-image: linear-gradient(#212121, #212121), linear-gradient(137.48deg, #ffdb3b 10%, #FE53BB 45%, #8F51EA 67%, #0044ff 87%); + background-origin: border-box; + background-clip: content-box, border-box; + -webkit-animation: pulsate-fwd 1.2s ease-in-out infinite both; + animation: pulsate-fwd 1.2s ease-in-out infinite both; + + &:hover #container-stars { + z-index: 1; + background-color: #212121; + } + + &:hover { + transform: scale(1.1) + } + + &:active { + border: double 4px #FE53BB; + background-origin: border-box; + background-clip: content-box, border-box; + animation: none; + } + + &:active .circle { + background: #FE53BB; + } + + strong { + z-index: 2; + font-family: 'Avalors Personal Use'; + font-size: 12px; + letter-spacing: 5px; + color: #FFFFFF; + text-shadow: 0 0 4px white; + } + + #container-stars { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + overflow: hidden; + transition: 0.5s; + backdrop-filter: blur(1rem); + border-radius: 5rem; + + #stars { + position: relative; + background: transparent; + width: 200rem; + height: 200rem; + + &::after { + content: ""; + position: absolute; + top: -10rem; + left: -100rem; + width: 100%; + height: 100%; + animation: animStarRotate 90s linear infinite; + } + + &::after { + background-image: radial-gradient(#ffffff 1px, transparent 1%); + background-size: 50px 50px; + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: -50%; + width: 170%; + height: 500%; + animation: animStar 60s linear infinite; + } + + &::before { + background-image: radial-gradient(#ffffff 1px, transparent 1%); + background-size: 50px 50px; + opacity: 0.5; + } + } + + + } + + #glow { + position: absolute; + display: flex; + width: 12rem; + + .circle { + width: 100%; + height: 30px; + filter: blur(2rem); + animation: pulse_3011 4s infinite; + z-index: -1; + + &:nth-of-type(1) { + background: rgba(254, 83, 186, 0.636); + } + + &:nth-of-type(2) { + background: rgba(142, 81, 234, 0.704); + } + } + } + } + + .btn-neon { + --glow-color: rgb(217, 176, 255); + --glow-spread-color: rgba(191, 123, 255, 0.781); + --enhanced-glow-color: rgb(231, 206, 255); + --btn-color: rgb(100, 61, 136); + -webkit-animation: pulsate-fwd 0.9s ease-in-out infinite both; + animation: pulsate-fwd 0.9s ease-in-out infinite both; + cursor: pointer; + border: .25em solid var(--glow-color); + padding: 1em 3em; + color: var(--glow-color); + font-size: 15px; + font-weight: bold; + background-color: var(--btn-color); + border-radius: 1em; + outline: none; + box-shadow: 0 0 1em .25em var(--glow-color), + 0 0 4em 1em var(--glow-spread-color), + inset 0 0 .75em .25em var(--glow-color); + text-shadow: 0 0 .5em var(--glow-color); + position: relative; + transition: all 0.3s; + -webkit-animation: swing-in-top-fwd 0.5s cubic-bezier(0.175, 0.885, 0.320, 1.275) both; + animation: swing-in-top-fwd 0.5s cubic-bezier(0.175, 0.885, 0.320, 1.275) both; + + &::after { + pointer-events: none; + content: ""; + position: absolute; + top: 120%; + left: 0; + height: 100%; + width: 100%; + background-color: var(--glow-spread-color); + filter: blur(2em); + opacity: .7; + transform: perspective(1.5em) rotateX(35deg) scale(1, .6); + } + + &:hover { + color: var(--btn-color); + background-color: var(--glow-color); + box-shadow: 0 0 1em .25em var(--glow-color), + 0 0 4em 2em var(--glow-spread-color), + inset 0 0 .75em .25em var(--glow-color); + } + + &:active { + box-shadow: 0 0 0.6em .25em var(--glow-color), + 0 0 2.5em 2em var(--glow-spread-color), + inset 0 0 .5em .25em var(--glow-color); + } + } + + .btn-cancel { + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + width: 13rem; + overflow: hidden; + height: 3rem; + background-size: 300% 300%; + backdrop-filter: blur(1rem); + border-radius: 5rem; + transition: 0.5s; + animation: gradient_301 5s ease infinite; + border: double 4px transparent; + background-image: linear-gradient(#212121, #212121), linear-gradient(137.48deg, #ffdb3b 10%, #FE53BB 45%, #8F51EA 67%, #0044ff 87%); + background-origin: border-box; + background-clip: content-box, border-box; + } +} + +@keyframes animStar { + from { + transform: translateY(0); + } + + to { + transform: translateY(-135rem); + } +} + +@keyframes animStarRotate { + from { + transform: rotate(360deg); + } + + to { + transform: rotate(0); + } +} + +@keyframes gradient_301 { + 0% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} + +@keyframes pulse_3011 { + 0% { + transform: scale(0.75); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); + } + + 100% { + transform: scale(0.75); + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); + } +} + +// 按钮动画 +@-webkit-keyframes pulsate-fwd { + 0% { + -webkit-transform: scale(1); + transform: scale(1); + } + + 50% { + -webkit-transform: scale(1.1); + transform: scale(1.1); + } + + 100% { + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@keyframes pulsate-fwd { + 0% { + -webkit-transform: scale(1); + transform: scale(1); + } + + 50% { + -webkit-transform: scale(1.2); + transform: scale(1.2); + } + + 100% { + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@-webkit-keyframes tracking-in-expand-fwd { + 0% { + letter-spacing: -0.5em; + -webkit-transform: translateZ(-700px); + transform: translateZ(-700px); + opacity: 0; + } + + 40% { + opacity: 0.6; + } + + 100% { + -webkit-transform: translateZ(0); + transform: translateZ(0); + opacity: 1; + } +} + +@keyframes tracking-in-expand-fwd { + 0% { + letter-spacing: -0.5em; + -webkit-transform: translateZ(-700px); + transform: translateZ(-700px); + opacity: 0; + } + + 40% { + opacity: 0.6; + } + + 100% { + -webkit-transform: translateZ(0); + transform: translateZ(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/views/Home/components/OptionsButton/index.vue b/src/views/Home/components/OptionsButton/index.vue new file mode 100644 index 0000000..a220239 --- /dev/null +++ b/src/views/Home/components/OptionsButton/index.vue @@ -0,0 +1,79 @@ + + + + + + {{ t('button.enterLottery') }} + + + + + {{ t('button.start') }} + + + + + + + + + + + + + {{ t('button.selectLucky') }} + + + + + + {{ t('button.continue') }} + + + + + + + + + + + + + + {{ t('button.cancel') }} + + + + + + + + + + + + + + + diff --git a/src/views/Home/components/PrizeList/index.scss b/src/views/Home/components/PrizeList/index.scss new file mode 100644 index 0000000..1d63d31 --- /dev/null +++ b/src/views/Home/components/PrizeList/index.scss @@ -0,0 +1,138 @@ +.label { + width: 120px; +} + +.prize-list-enter-active { + -webkit-animation: slide-right 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; + animation: slide-right 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; +} + +.prize-list-leave-active { + -webkit-animation: slide-left 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; + animation: slide-left 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both; +} + +.prize-operate-enter-active { + // 延时显示 + animation: show-operate 0.6s; + -webkit-animation: show-operate 0.6s; +} + +.current-prize { + position: relative; + display: block; + overflow: hidden; + isolation: isolate; + + border-radius: 20px; + padding: 3px; +} + +.current-prize::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 400%; + height: 100%; + background: linear-gradient(115deg, #4fcf70, #fad648, #a767e5, #12bcfe, #44ce7b); + background-size: 25% 100%; + animation: an-at-keyframe-css-at-rule-that-translates-via-the-transform-property-the-background-by-negative-25-percent-of-its-width-so-that-it-gives-a-nice-border-animation_-We-use-the-translate-property-to-have-a-nice-transition-so-it_s-not-a-jerk-of-a-start-or-stop .75s linear infinite; + // animation-play-state: paused; + translate: -5% 0%; + transition: translate 0.25s ease-out; + animation-play-state: running; + transition-duration: 0.75s; + translate: 0% 0%; +} + +.current-prize::after { + content: ""; + position: absolute; + inset: 4px; + border-top-left-radius: 20px; + border-bottom-right-radius: 20px; + z-index: -1; +} + +@keyframes an-at-keyframe-css-at-rule-that-translates-via-the-transform-property-the-background-by-negative-25-percent-of-its-width-so-that-it-gives-a-nice-border-animation_-We-use-the-translate-property-to-have-a-nice-transition-so-it_s-not-a-jerk-of-a-start-or-stop { + to { + transform: translateX(-25%); + } +} + +@-webkit-keyframes slide-right { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); + } + + 100% { + -webkit-transform: translateX(30px); + transform: translateX(30px); + } +} + +@keyframes slide-right { + 0% { + -webkit-transform: translateX(-200px); + transform: translateX(-200px); + } + + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); + } +} + +@-webkit-keyframes slide-left { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); + } + + 100% { + -webkit-transform: translateX(-100px); + transform: translateX(-100px); + } +} + +@keyframes slide-left { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); + } + + 100% { + -webkit-transform: translateX(-400px); + transform: translateX(-400px); + } +} + +@-webkit-keyframes show-operate { + 0% { + opacity: 0; + } + + 99% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes show-operate { + 0% { + opacity: 0; + } + + 99% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} \ No newline at end of file diff --git a/src/views/Home/PrizeList.vue b/src/views/Home/components/PrizeList/index.vue similarity index 80% rename from src/views/Home/PrizeList.vue rename to src/views/Home/components/PrizeList/index.vue index ea9cfcd..6a15b1b 100644 --- a/src/views/Home/PrizeList.vue +++ b/src/views/Home/components/PrizeList/index.vue @@ -1,17 +1,17 @@ + + + + + + + + + diff --git a/src/views/Home/index.vue b/src/views/Home/index.vue index 9351c44..70f295d 100644 --- a/src/views/Home/index.vue +++ b/src/views/Home/index.vue @@ -1,1127 +1,42 @@ - - - {{ topTitle }} - - - - {{ t('button.noInfoAndImport') }} - - - {{ t('button.useDefault') }} - - - + - - - - {{ t('button.enterLottery') }} - - - - - {{ t('button.start') }} - - - - - - - - - - - - - {{ t('button.selectLucky') }} - - - - - - {{ t('button.continue') }} - - - - - - - - - - - - - - {{ t('button.cancel') }} - - - - - - - - - - - - - + diff --git a/src/views/Home/type.ts b/src/views/Home/type.ts new file mode 100644 index 0000000..1a2f9c3 --- /dev/null +++ b/src/views/Home/type.ts @@ -0,0 +1,12 @@ +export enum LotteryStatus { + init = 0, + ready = 1, + running = 2, + end = 3, +} +export interface TargetType { + grid: any[] + helix: any[] + table: any[] + sphere: any[] +} diff --git a/src/views/Home/useViewModel.ts b/src/views/Home/useViewModel.ts new file mode 100644 index 0000000..cebfdf3 --- /dev/null +++ b/src/views/Home/useViewModel.ts @@ -0,0 +1,606 @@ +import type { Material, Object3D } from 'three' +import type { TargetType } from './type' +import type { IPersonConfig } from '@/types/storeType' +import * as TWEEN from '@tweenjs/tween.js' +import { storeToRefs } from 'pinia' +import { PerspectiveCamera, Scene } from 'three' +import { CSS3DObject, CSS3DRenderer } from 'three-css3d' +import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js' +import { nextTick, onMounted, onUnmounted, ref } from 'vue' +import { useToast } from 'vue-toast-notification' +import { useElementPosition, useElementStyle } from '@/hooks/useElement' +import i18n from '@/locales/i18n' +import useStore from '@/store' +import { selectCard } from '@/utils' +import { rgba } from '@/utils/color' +import { LotteryStatus } from './type' +import { confettiFire, createSphereVertices, createTableVertices, initTableData } from './util' + +export function useViewModel() { + const toast = useToast() + // store里面存储的值 + const { personConfig, globalConfig, prizeConfig } = useStore() + const { + getAllPersonList: allPersonList, + getNotPersonList: notPersonList, + getNotThisPrizePersonList: notThisPrizePersonList, + } = storeToRefs(personConfig) + const { getCurrentPrize: currentPrize } = storeToRefs(prizeConfig) + const { getCardColor: cardColor, getPatterColor: patternColor, getPatternList: patternList, getTextColor: textColor, getLuckyColor: luckyColor, getCardSize: cardSize, getTextSize: textSize, getRowCount: rowCount, getIsShowAvatar: isShowAvatar } = storeToRefs(globalConfig) + // three初始值 + const ballRotationY = ref(0) + const containerRef = ref() + const canOperate = ref(true) + const cameraZ = ref(3000) + const scene = ref() + const camera = ref() + const renderer = ref() + const controls = ref() + const objects = ref([]) + const targets: TargetType = { + grid: [], + helix: [], + table: [], + sphere: [], + } + // 页面数据初始值 + const currentStatus = ref(LotteryStatus.init) // 0为初始状态, 1为抽奖准备状态,2为抽奖中状态,3为抽奖结束状态 + const tableData = ref([]) + const luckyTargets = ref([]) + const luckyCardList = ref([]) + const luckyCount = ref(10) + const personPool = ref([]) + const intervalTimer = ref(null) + + function init() { + const felidView = 40 + const width = window.innerWidth + const height = window.innerHeight + const aspect = width / height + const nearPlane = 1 + const farPlane = 10000 + const WebGLoutput = containerRef.value + + scene.value = new Scene() + camera.value = new PerspectiveCamera(felidView, aspect, nearPlane, farPlane) + camera.value.position.z = cameraZ.value + renderer.value = new CSS3DRenderer() + renderer.value.setSize(width, height * 0.9) + renderer.value.domElement.style.position = 'absolute' + // 垂直居中 + renderer.value.domElement.style.paddingTop = '50px' + renderer.value.domElement.style.top = '50%' + renderer.value.domElement.style.left = '50%' + renderer.value.domElement.style.transform = 'translate(-50%, -50%)' + WebGLoutput!.appendChild(renderer.value.domElement) + + controls.value = new TrackballControls(camera.value, renderer.value.domElement) + controls.value.rotateSpeed = 1 + controls.value.staticMoving = true + controls.value.minDistance = 500 + controls.value.maxDistance = 6000 + controls.value.addEventListener('change', render) + + const tableLen = tableData.value.length + for (let i = 0; i < tableLen; i++) { + let element = document.createElement('div') + element.className = 'element-card' + + const number = document.createElement('div') + number.className = 'card-id' + number.textContent = tableData.value[i].uid + if (isShowAvatar.value) + number.style.display = 'none' + element.appendChild(number) + + const symbol = document.createElement('div') + symbol.className = 'card-name' + symbol.textContent = tableData.value[i].name + if (isShowAvatar.value) + symbol.className = 'card-name card-avatar-name' + element.appendChild(symbol) + + const detail = document.createElement('div') + detail.className = 'card-detail' + detail.innerHTML = `${tableData.value[i].department}${tableData.value[i].identity}` + if (isShowAvatar.value) + detail.style.display = 'none' + element.appendChild(detail) + + const avatar = document.createElement('img') + avatar.className = 'card-avatar' + avatar.src = tableData.value[i].avatar + avatar.alt = 'avatar' + avatar.style.width = '140px' + avatar.style.height = '140px' + if (!isShowAvatar.value) + avatar.style.display = 'none' + element.appendChild(avatar) + + element = useElementStyle(element, tableData.value[i], i, patternList.value, patternColor.value, cardColor.value, cardSize.value, textSize.value) + const object = new CSS3DObject(element) + object.position.x = Math.random() * 4000 - 2000 + object.position.y = Math.random() * 4000 - 2000 + object.position.z = Math.random() * 4000 - 2000 + scene.value.add(object) + + objects.value.push(object) + } + // 创建横铺的界面 + const tableVertices = createTableVertices({ tableData: tableData.value, rowCount: rowCount.value, cardSize: cardSize.value }) + targets.table = tableVertices + // 创建球体 + const sphereVertices = createSphereVertices({ objectsLength: objects.value.length }) + targets.sphere = sphereVertices + window.addEventListener('resize', onWindowResize, false) + transform(targets.table, 1000) + render() + } + function render() { + if (renderer.value) { + renderer.value.render(scene.value, camera.value) + } + } + /** + * @description: 位置变换 + * @param targets 目标位置 + * @param duration 持续时间 + */ + function transform(targets: any[], duration: number) { + TWEEN.removeAll() + if (intervalTimer.value) { + clearInterval(intervalTimer.value) + intervalTimer.value = null + randomBallData('sphere') + } + + return new Promise((resolve) => { + const objLength = objects.value.length + for (let i = 0; i < objLength; ++i) { + const object = objects.value[i] + const target = targets[i] + // console.log('target', i, target, targets) + new TWEEN.Tween(object.position) + .to({ x: target.position.x, y: target.position.y, z: target.position.z }, Math.random() * duration + duration) + .easing(TWEEN.Easing.Exponential.InOut) + .start() + + new TWEEN.Tween(object.rotation) + .to({ x: target.rotation.x, y: target.rotation.y, z: target.rotation.z }, Math.random() * duration + duration) + .easing(TWEEN.Easing.Exponential.InOut) + .start() + .onComplete(() => { + if (luckyCardList.value.length) { + luckyCardList.value.forEach((cardIndex: any) => { + const item = objects.value[cardIndex] + useElementStyle(item.element, {} as any, i, patternList.value, patternColor.value, cardColor.value, cardSize.value, textSize.value, 'sphere') + }) + } + luckyTargets.value = [] + luckyCardList.value = [] + canOperate.value = true + }) + } + + // 这个补间用来在位置与旋转补间同步执行,通过onUpdate在每次更新数据后渲染scene和camera + new TWEEN.Tween({}) + .to({}, duration * 2) + .onUpdate(render) + .start() + .onComplete(() => { + canOperate.value = true + resolve('') + }) + }) + } + /** + * @description: 窗口大小改变时重新设置渲染器的大小 + */ + function onWindowResize() { + camera.value.aspect = window.innerWidth / window.innerHeight + camera.value.updateProjectionMatrix() + + renderer.value.setSize(window.innerWidth, window.innerHeight) + render() + } + + /** + * [animation update all tween && controls] + */ + function animation() { + TWEEN.update() + if (controls.value) { + controls.value.update() + } + // 设置自动旋转 + // 设置相机位置 + requestAnimationFrame(animation) + } + /** + * @description: 旋转的动画 + * @param rotateY 绕y轴旋转圈数 + * @param duration 持续时间,单位秒 + */ + function rollBall(rotateY: number, duration: number) { + TWEEN.removeAll() + + return new Promise((resolve) => { + scene.value.rotation.y = 0 + ballRotationY.value = Math.PI * rotateY * 1000 + const rotateObj = new TWEEN.Tween(scene.value.rotation) + rotateObj + .to( + { + // x: Math.PI * rotateX * 1000, + x: 0, + y: ballRotationY.value, + // z: Math.PI * rotateZ * 1000 + z: 0, + }, + duration * 1000, + ) + .onUpdate(render) + .start() + .onStop(() => { + resolve('') + }) + .onComplete(() => { + resolve('') + }) + }) + } + /** + * @description: 视野转回正面 + */ + function resetCamera() { + new TWEEN.Tween(camera.value.position) + .to( + { + x: 0, + y: 0, + z: 3000, + }, + 1000, + ) + .onUpdate(render) + .start() + .onComplete(() => { + new TWEEN.Tween(camera.value.rotation) + .to( + { + x: 0, + y: 0, + z: 0, + }, + 1000, + ) + .onUpdate(render) + .start() + .onComplete(() => { + canOperate.value = true + // camera.value.lookAt(scene.value.position) + camera.value.position.y = 0 + camera.value.position.x = 0 + camera.value.position.z = 3000 + camera.value.rotation.x = 0 + camera.value.rotation.y = 0 + camera.value.rotation.z = -0 + controls.value.reset() + }) + }) + } + + /** + * @description: 开始抽奖,由横铺变换为球体(或其他图形) + * @returns 随机抽取球数据 + */ + /// description 进入抽奖准备状态 + async function enterLottery() { + if (!canOperate.value) { + return + } + if (!intervalTimer.value) { + randomBallData() + } + if (patternList.value.length) { + for (let i = 0; i < patternList.value.length; i++) { + if (i < rowCount.value * 7) { + objects.value[patternList.value[i] - 1].element.style.backgroundColor = rgba(cardColor.value, Math.random() * 0.5 + 0.25) + } + } + } + canOperate.value = false + await transform(targets.sphere, 1000) + currentStatus.value = LotteryStatus.ready + rollBall(0.1, 2000) + } + /** + * @description 开始抽奖 + */ + function startLottery() { + if (!canOperate.value) { + return + } + // 验证是否已抽完全部奖项 + if (currentPrize.value.isUsed || !currentPrize.value) { + toast.open({ + message: i18n.global.t('error.personIsAllDone'), + type: 'warning', + position: 'top-right', + duration: 10000, + }) + + return + } + personPool.value = currentPrize.value.isAll ? notThisPrizePersonList.value : notPersonList.value + // 验证抽奖人数是否还够 + if (personPool.value.length < currentPrize.value.count - currentPrize.value.isUsedCount) { + toast.open({ + message: i18n.global.t('error.personNotEnough'), + type: 'warning', + position: 'top-right', + duration: 10000, + }) + + return + } + luckyCount.value = 10 + // 自定义抽奖个数 + + let leftover = currentPrize.value.count - currentPrize.value.isUsedCount + const customCount = currentPrize.value.separateCount + if (customCount && customCount.enable && customCount.countList.length > 0) { + for (let i = 0; i < customCount.countList.length; i++) { + if (customCount.countList[i].isUsedCount < customCount.countList[i].count) { + leftover = customCount.countList[i].count - customCount.countList[i].isUsedCount + break + } + } + } + luckyCount.value = leftover < luckyCount.value ? leftover : luckyCount.value + for (let i = 0; i < luckyCount.value; i++) { + if (personPool.value.length > 0) { + // 解决随机元素概率过于不均等问题 + const randomIndex = Math.floor(Math.random() * (personPool.value.length - 1)) + luckyTargets.value.push(personPool.value[randomIndex]) + personPool.value.splice(randomIndex, 1) + } + } + + toast.open({ + // message: `现在抽取${currentPrize.value.name} ${leftover}人`, + message: i18n.global.t('error.startDraw', { count: currentPrize.value.name, leftover }), + type: 'default', + position: 'top-right', + duration: 8000, + }) + currentStatus.value = LotteryStatus.running + rollBall(10, 3000) + } + /** + * @description: 停止抽奖,抽出幸运人 + */ + async function stopLottery() { + if (!canOperate.value) { + return + } + // clearInterval(intervalTimer.value) + // intervalTimer.value = null + canOperate.value = false + rollBall(0, 1) + + const windowSize = { width: window.innerWidth, height: window.innerHeight } + luckyTargets.value.forEach((person: IPersonConfig, index: number) => { + const cardIndex = selectCard(luckyCardList.value, tableData.value.length, person.id) + luckyCardList.value.push(cardIndex) + const totalLuckyCount = luckyTargets.value.length + const item = objects.value[cardIndex] + const { xTable, yTable } = useElementPosition(item, rowCount.value, totalLuckyCount, { width: cardSize.value.width * 2, height: cardSize.value.height * 2 }, windowSize, index) + new TWEEN.Tween(item.position) + .to({ + x: xTable, + y: yTable, + z: 1000, + }, 1200) + .easing(TWEEN.Easing.Exponential.InOut) + .onStart(() => { + item.element = useElementStyle(item.element, person, cardIndex, patternList.value, patternColor.value, luckyColor.value, { width: cardSize.value.width * 2, height: cardSize.value.height * 2 }, textSize.value * 2, 'lucky') + }) + .start() + .onComplete(() => { + canOperate.value = true + currentStatus.value = LotteryStatus.end + }) + new TWEEN.Tween(item.rotation) + .to({ + x: 0, + y: 0, + z: 0, + }, 900) + .easing(TWEEN.Easing.Exponential.InOut) + .start() + .onComplete(() => { + confettiFire() + resetCamera() + }) + }) + } + /** + * @description: 继续,意味着这抽奖作数,计入数据库 + */ + async function continueLottery() { + if (!canOperate.value) { + return + } + + const customCount = currentPrize.value.separateCount + if (customCount && customCount.enable && customCount.countList.length > 0) { + for (let i = 0; i < customCount.countList.length; i++) { + if (customCount.countList[i].isUsedCount < customCount.countList[i].count) { + customCount.countList[i].isUsedCount += luckyCount.value + break + } + } + } + currentPrize.value.isUsedCount += luckyCount.value + luckyCount.value = 0 + if (currentPrize.value.isUsedCount >= currentPrize.value.count) { + currentPrize.value.isUsed = true + currentPrize.value.isUsedCount = currentPrize.value.count + } + personConfig.addAlreadyPersonList(luckyTargets.value, currentPrize.value) + prizeConfig.updatePrizeConfig(currentPrize.value) + await enterLottery() + } + /** + * @description: 放弃本次抽奖,回到初始状态 + */ + function quitLottery() { + enterLottery() + currentStatus.value = LotteryStatus.init + } + + /** + * @description: 随机替换卡片中的数据(不改变原有的值,只是显示) + * @param {string} mod 模式 + */ + function randomBallData(mod: 'default' | 'lucky' | 'sphere' = 'default') { + // 两秒执行一次 + intervalTimer.value = setInterval(() => { + // 产生随机数数组 + const indexLength = 4 + const cardRandomIndexArr: number[] = [] + const personRandomIndexArr: number[] = [] + for (let i = 0; i < indexLength; i++) { + // 解决随机元素概率过于不均等问题 + const randomCardIndex = Math.floor(Math.random() * (tableData.value.length - 1)) + const randomPersonIndex = Math.floor(Math.random() * (allPersonList.value.length - 1)) + if (luckyCardList.value.includes(randomCardIndex)) { + continue + } + cardRandomIndexArr.push(randomCardIndex) + personRandomIndexArr.push(randomPersonIndex) + } + for (let i = 0; i < cardRandomIndexArr.length; i++) { + if (!objects.value[cardRandomIndexArr[i]]) { + continue + } + objects.value[cardRandomIndexArr[i]].element = useElementStyle(objects.value[cardRandomIndexArr[i]].element, allPersonList.value[personRandomIndexArr[i]], cardRandomIndexArr[i], patternList.value, patternColor.value, cardColor.value, { width: cardSize.value.width, height: cardSize.value.height }, textSize.value, mod, 'change') + } + }, 200) + } + /** + * @description: 键盘监听,快捷键操作 + */ + function listenKeyboard(e: any) { + if ((e.keyCode !== 32 || e.keyCode !== 27) && !canOperate.value) { + return + } + if (e.keyCode === 27 && currentStatus.value === LotteryStatus.running) { + quitLottery() + } + if (e.keyCode !== 32) { + return + } + switch (currentStatus.value) { + case LotteryStatus.init: + enterLottery() + break + case LotteryStatus.ready: + startLottery() + break + case LotteryStatus.running: + stopLottery() + break + case LotteryStatus.end: + continueLottery() + break + default: + break + } + } + /** + * @description: 清理资源,避免内存溢出 + */ + function cleanup() { + clearInterval(intervalTimer.value) + intervalTimer.value = null + if (scene.value) { + scene.value.traverse((object: Object3D) => { + if ((object as any).material) { + if (Array.isArray((object as any).material)) { + (object as any).material.forEach((material: Material) => { + material.dispose() + }) + } + else { + (object as any).material.dispose() + } + } + if ((object as any).geometry) { + (object as any).geometry.dispose() + } + if ((object as any).texture) { + (object as any).texture.dispose() + } + }) + scene.value.clear() + } + + if (objects.value) { + objects.value.forEach((object) => { + if (object.element) { + object.element.remove() + } + }) + objects.value = [] + } + + if (controls.value) { + controls.value.removeEventListener('change') + controls.value.dispose() + } + // 移除所有事件监听 + window.removeEventListener('resize', onWindowResize) + scene.value = null + camera.value = null + renderer.value = null + controls.value = null + } + /** + * @description: 设置默认人员列表 + */ + function setDefaultPersonList() { + personConfig.setDefaultPersonList() + // 刷新页面 + window.location.reload() + } + onMounted(() => { + tableData.value = initTableData({ allPersonList: allPersonList.value, rowCount: rowCount.value }) + init() + animation() + containerRef.value!.style.color = `${textColor}` + randomBallData() + window.addEventListener('keydown', listenKeyboard) + }) + onUnmounted(() => { + nextTick(() => { + cleanup() + }) + clearInterval(intervalTimer.value) + intervalTimer.value = null + window.removeEventListener('keydown', listenKeyboard) + }) + + return { + setDefaultPersonList, + startLottery, + continueLottery, + quitLottery, + containerRef, + stopLottery, + enterLottery, + tableData, + currentStatus, + } +} diff --git a/src/views/Home/util.ts b/src/views/Home/util.ts new file mode 100644 index 0000000..1c3cea1 --- /dev/null +++ b/src/views/Home/util.ts @@ -0,0 +1,131 @@ +import type { IPersonConfig } from '@/types/storeType' +import confetti from 'canvas-confetti' +import { Object3D, Vector3 } from 'three' +import { filterData } from '@/utils' +/** + * @description 初始化表格数据 + * @param0 allPersonList 所有人的列表 + * @param1 rowCount 行数,默认是7行 + * @returns 表格数据 + */ +export function initTableData({ allPersonList, rowCount }: { allPersonList: IPersonConfig[], rowCount: number }): IPersonConfig[] { + let tableData: IPersonConfig[] = [] + if (allPersonList.length <= 0) { + return [] + } + const totalCount = rowCount * 7 + const allPersonLength = allPersonList.length + if (allPersonLength < totalCount) { + tableData = Array.from({ length: totalCount }, () => JSON.parse(JSON.stringify(allPersonList))).flat() + } + else { + tableData = allPersonList.slice(0, totalCount) + } + tableData = filterData(tableData.slice(0, totalCount), rowCount) + return tableData +} + +/** + * @description 横铺图形:处理数据,把每个卡片在界面的位置写入 + * @param0 tableData 表格数据 + * @param1 rowCount 每行有多少个元素 + * @param2 cardSize 卡片的大小 + * @returns Object3D[] + */ +export function createTableVertices({ tableData, rowCount, cardSize }: { tableData: IPersonConfig[], rowCount: number, cardSize: { width: number, height: number } }): Object3D[] { + const tableLen = tableData.length + const objects: Object3D[] = [] + for (let i = 0; i < tableLen; i++) { + const object = new Object3D() + + object.position.x = tableData[i].x * (cardSize.width + 40) - rowCount * 90 + object.position.y = -tableData[i].y * (cardSize.height + 20) + 1000 + object.position.z = 0 + objects.push(object) + // targets.table.push(object) + } + return objects +} +/** + * @description 创建球体 + * @param0 objectsLength 物体的个数 + * @returns Object3D[] + */ +export function createSphereVertices({ objectsLength }: { objectsLength: number }): Object3D[] { + let i = 0 + const resObjects: Object3D[] = [] + // const objLength = objects.value.length + const vector = new Vector3() + + for (; i < objectsLength; ++i) { + const phi = Math.acos(-1 + (2 * i) / objectsLength) + const theta = Math.sqrt(objectsLength * Math.PI) * phi + const object = new Object3D() + + object.position.x = 800 * Math.cos(theta) * Math.sin(phi) + object.position.y = 800 * Math.sin(theta) * Math.sin(phi) + object.position.z = -800 * Math.cos(phi) + + // rotation object + vector.copy(object.position).multiplyScalar(2) + object.lookAt(vector) + resObjects.push(object) + } + return resObjects +} + +export function confettiFire() { + const duration = 3 * 1000 + const end = Date.now() + duration; + (function frame() { + // launch a few confetti from the left edge + confetti({ + particleCount: 2, + angle: 60, + spread: 55, + origin: { x: 0 }, + }) + // and launch a few from the right edge + confetti({ + particleCount: 2, + angle: 120, + spread: 55, + origin: { x: 1 }, + }) + + // keep going until we are out of time + if (Date.now() < end) { + requestAnimationFrame(frame) + } + }()) + centerFire(0.25, { + spread: 26, + startVelocity: 55, + }) + centerFire(0.2, { + spread: 60, + }) + centerFire(0.35, { + spread: 100, + decay: 0.91, + scalar: 0.8, + }) + centerFire(0.1, { + spread: 120, + startVelocity: 25, + decay: 0.92, + scalar: 1.2, + }) + centerFire(0.1, { + spread: 120, + startVelocity: 45, + }) +} +function centerFire(particleRatio: number, opts: any) { + const count = 200 + confetti({ + origin: { y: 0.7 }, + ...opts, + particleCount: Math.floor(count * particleRatio), + }) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 85276f8..74dd46a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,11 +1,12 @@ /// declare module '*.vue' { - import type { DefineComponent } from 'vue' + import type { DefineComponent } from 'vue' - const component: DefineComponent - export default component + const component: DefineComponent + export default component } declare module 'sparticles' declare module 'three-trackballcontrols' +declare module 'virtual:svg-icons-register'
+ {{ desc }} +