Merge branch 'dev' into main

This commit is contained in:
LOG1997
2025-12-09 20:28:52 +08:00
committed by GitHub
75 changed files with 8087 additions and 5832 deletions

View File

@@ -15,7 +15,7 @@ jobs:
contents: read contents: read
strategy: strategy:
matrix: matrix:
node-version: [20.x] node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps: steps:

View File

@@ -1,41 +0,0 @@
import Button from '@/components/Button/index.vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'
// 测试分组
describe('Button', () => {
// mount
test('Buttons slot text', () => {
// @vue/test-utils
const wrapper = shallowMount(Button, {
slots: {
default: 'Button',
},
})
// 断言
expect(wrapper.text()).toBe('Button')
})
test('Button click', () => {
const wrapper = shallowMount(Button)
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
test('Button disabled', () => {
const wrapper = shallowMount(Button, {
props: {
disabled: true,
},
})
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
test('Button not disabled', () => {
const wrapper = shallowMount(Button, {
props: {
disabled: false,
},
})
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
})

View File

@@ -1,41 +0,0 @@
import Button from '@/components/Button/index.vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'
// 测试分组
describe('Button', () => {
// mount
test('Buttons slot text', () => {
// @vue/test-utils
const wrapper = shallowMount(Button, {
slots: {
default: 'Button',
},
})
// 断言
expect(wrapper.text()).toBe('Button')
})
test('Button click', () => {
const wrapper = shallowMount(Button)
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
test('Button disabled', () => {
const wrapper = shallowMount(Button, {
props: {
disabled: true,
},
})
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
test('Button not disabled', () => {
const wrapper = shallowMount(Button, {
props: {
disabled: false,
},
})
wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
})

124
__test__/Random.test.ts Normal file
View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { getRandomElements } from '@/views/Home/utils/random'
describe('getRandomElements', () => {
// 测试基本功能:从数组中获取指定数量的元素
it('should return specified number of elements', () => {
const sourceArray = [1, 2, 3, 4, 5]
const result = getRandomElements(sourceArray, 3)
expect(result).toHaveLength(3)
result.forEach((element) => {
expect(sourceArray).toContain(element)
})
})
// 测试边界情况count为0
it('should return empty array when count is 0', () => {
const sourceArray = [1, 2, 3]
const result = getRandomElements(sourceArray, 0)
expect(result).toEqual([])
})
// 测试边界情况count为负数
it('should return empty array when count is negative', () => {
const sourceArray = [1, 2, 3]
const result = getRandomElements(sourceArray, -1)
expect(result).toEqual([])
})
// 测试边界情况count大于等于数组长度
it('should return shuffled array when count equals or exceeds array length', () => {
const sourceArray = [1, 2, 3]
const result1 = getRandomElements(sourceArray, 3)
const result2 = getRandomElements(sourceArray, 5)
expect(result1).toHaveLength(3)
expect(result2).toHaveLength(3)
// 验证返回的元素与原数组相同
expect(result1.sort()).toEqual(sourceArray.sort())
expect(result2.sort()).toEqual(sourceArray.sort())
})
// 测试空数组情况
it('should return empty array when source array is empty', () => {
const sourceArray: number[] = []
const result = getRandomElements(sourceArray, 3)
expect(result).toEqual([])
})
// 测试单元素数组
it('should handle single element array', () => {
const sourceArray = [42]
const result = getRandomElements(sourceArray, 1)
expect(result).toEqual([42])
})
// 测试字符串数组
it('should work with string arrays', () => {
const sourceArray = ['a', 'b', 'c', 'd', 'e']
const result = getRandomElements(sourceArray, 2)
expect(result).toHaveLength(2)
result.forEach((element) => {
expect(sourceArray).toContain(element)
})
})
// 测试对象数组
it('should work with object arrays', () => {
const sourceArray = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]
const result = getRandomElements(sourceArray, 2)
expect(result).toHaveLength(2)
result.forEach((element) => {
expect(sourceArray).toContain(element)
})
})
// 测试多次调用应产生不同结果(概率性测试)
it('should produce different results on multiple calls', () => {
const sourceArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
const results = new Set()
// 多次调用并收集结果
for (let i = 0; i < 10; i++) {
const result = getRandomElements(sourceArray, 5).sort().join(',')
results.add(result)
}
// 虽然有极小概率会相同,但大多数情况下应该有不同的结果
expect(results.size).toBeGreaterThan(1)
})
// 多次调用,每个元素抽中的概率基本上相等
it('should have approximately equal probabilities for each element', () => {
const sourceArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
const times = 200000 // 次数
const count = 5 // 抽奖个数
const expectedProbability = count / sourceArray.length
const elementCounts = new Map()
// 多次调用并统计元素出现的次数
for (let i = 0; i < times; i++) {
const result = getRandomElements(sourceArray, count)
result.forEach((element) => {
const count = elementCounts.get(element) || 0
elementCounts.set(element, count + 1)
})
}
elementCounts.forEach((value) => {
// 验证每个元素出现的概率接近相等
const probability = value / times
expect(probability).toBeCloseTo(expectedProbability, 2)
})
})
})

View File

@@ -1,31 +0,0 @@
// test axios request
import Request from '@/api/request';
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import axios from 'axios';
const fn = vi.fn();
const mockRes = {
data: {
code: 200,
success: true,
message: 'success',
data: {
name: 'test',
age: 18,
},
},
};
fn(mockRes);
fn.mock.calls[0] === [mockRes];
describe('Request', () => {
it('should return data when request success', async () => {
const request = new Request();
const res = await request({
url: '/test',
method: 'GET',
});
expect(res).toEqual(mockRes.data);
});
});

View File

@@ -1,7 +1,13 @@
import antfu from '@antfu/eslint-config' import antfu from '@antfu/eslint-config'
export default antfu( export default antfu(
{ {
ignores: ['**/node_modules', '**/public', '**/dist', '**/package.json', '**/*.yaml', '**/.gitignore', '**/.env*', '**/tsconfig*'], ignores: ['**/node_modules', '**/public', '**/dist', '**/package.json', '**/*.yaml', '**/.gitignore', '**/.env*', '**/tsconfig*', '**/*.config.js'],
}, },
{
rules: {
'no-console': 'warn',
'no-debugger': 'warn',
}
},
) )

View File

@@ -17,70 +17,78 @@
}, },
"dependencies": { "dependencies": {
"@tweenjs/tween.js": "^23.1.2", "@tweenjs/tween.js": "^23.1.2",
"@vueuse/core": "^11.3.0", "@vueuse/core": "^14.1.0",
"axios": "^1.7.8", "axios": "^1.7.8",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dexie": "^4.2.1",
"github-markdown-css": "^5.8.0", "github-markdown-css": "^5.8.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash-es": "^4.17.21",
"lucide-vue-next": "^0.555.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pinia": "^2.2.6", "pinia": "^3.0.3",
"pinia-plugin-persist": "^1.0.0", "pinia-plugin-persist": "^1.0.0",
"sparticles": "^1.3.1", "sparticles": "^1.3.1",
"three": "^0.166.0", "three": "^0.166.0",
"three-css3d": "^1.0.6", "three-css3d": "^1.0.6",
"uuid": "^13.0.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-dompurify-html": "^5.2.0", "vue-dompurify-html": "^5.2.0",
"vue-i18n": "^10.0.4", "vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-toast-notification": "^3", "vue-toast-notification": "^3",
"vue3-colorpicker": "^2.3.0", "vue3-colorpicker": "^2.3.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^3.23.8" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^3.9.2", "@antfu/eslint-config": "^6.4.1",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.15.0", "@eslint/js": "^9.15.0",
"@iconify-json/ep": "^1.2.1", "@iconify-json/ep": "^1.2.1",
"@iconify-json/fluent": "^1.2.8", "@iconify-json/fluent": "^1.2.8",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.13",
"@testing-library/vue": "^8.1.0", "@testing-library/vue": "^8.1.0",
"@types/canvas-confetti": "^1.6.4", "@types/canvas-confetti": "^1.6.4",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^22.9.4", "@types/node": "^24.5.2",
"@types/three": "^0.166.0", "@types/three": "^0.166.0",
"@typescript-eslint/eslint-plugin": "^8.16.0", "@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0", "@typescript-eslint/parser": "^8.16.0",
"@vitejs/plugin-legacy": "^6.0.0", "@vitejs/plugin-legacy": "^7.2.1",
"@vitejs/plugin-vue": "^5.2.0", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/ui": "^2.1.5", "@vitest/ui": "^4.0.15",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"daisyui": "^4.12.14", "baseline-browser-mapping": "^2.8.32",
"daisyui": "^5.1.13",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-vue": "^9.31.0", "eslint-plugin-vue": "^10.4.0",
"globals": "^15.12.0", "globals": "^16.4.0",
"happy-dom": "^15.11.6", "happy-dom": "^18.0.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^25.0.1", "jsdom": "^27.0.0",
"path": "^0.12.7", "path": "^0.12.7",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^6.0.3",
"sass": "^1.81.0", "sass": "^1.81.0",
"sass-loader": "^16.0.3", "sass-loader": "^16.0.3",
"tailwindcss": "^3.4.15", "tailwindcss": "^4.1.13",
"terser": "^5.36.0", "terser": "^5.36.0",
"typescript": "5.5.3", "typescript": "5.5.3",
"unplugin-auto-import": "^0.18.5", "unplugin-auto-import": "^20.1.0",
"unplugin-icons": "^0.20.1", "unplugin-icons": "^22.3.0",
"unplugin-vue-components": "^0.27.4", "unplugin-vue-components": "^29.1.0",
"vite": "^5.4.11", "vite": "^7.1.6",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-inspect": "^0.8.8", "vite-plugin-inspect": "^11.3.3",
"vite-plugin-svg-icons": "^2.0.1", "vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-devtools": "^7.6.4", "vite-plugin-vue-devtools": "^8.0.2",
"vitest": "^2.1.5", "vitest": "^3.2.4",
"vue-tsc": "^2.1.10" "vue-tsc": "^3.0.7"
} }
} }

6250
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}

View File

@@ -1,86 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import PlayMusic from '@/components/PlayMusic/index.vue' import { storeToRefs } from 'pinia'
import { onMounted, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { loadingKey, loadingState } from '@/components/Loading'
// import PlayMusic from '@/components/PlayMusic/index.vue'
import useStore from '@/store' import useStore from '@/store'
import { themeChange } from '@/utils' import { themeChange } from '@/utils'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
provide(loadingKey, loadingState)
const { t } = useI18n() const { t } = useI18n()
const globalConfig = useStore().globalConfig const globalConfig = useStore().globalConfig
const prizeConfig = useStore().prizeConfig const prizeConfig = useStore().prizeConfig
const system = useStore().system const system = useStore().system
const { getTheme: localTheme } = storeToRefs(globalConfig) const { getTheme: localTheme } = storeToRefs(globalConfig)
const { getPrizeConfig: prizeList } = storeToRefs(prizeConfig) const { getPrizeConfig: prizeList } = storeToRefs(prizeConfig)
const tipDialog = ref()
// 设置当前奖列表
function setCurrentPrize() {
if (prizeList.value.length <= 0) {
return
}
for (let i = 0; i < prizeList.value.length; i++) {
if (!prizeList.value[i].isUsed) {
prizeConfig.setCurrentPrize(prizeList.value[i])
break
}
}
}
// 判断是否手机端访问
function judgeMobile() {
const ua = navigator.userAgent
const isAndroid = ua.includes('Android') || ua.includes('Adr')
const isIOS = !!ua.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)
system.setIsMobile(isAndroid || isIOS)
return isAndroid || isIOS
}
// 判断是否chrome或者edge访问
function judgeChromeOrEdge() {
const ua = navigator.userAgent
const isChrome = ua.includes('Chrome')
const isEdge = ua.includes('Edg')
system.setIsChrome(isChrome)
return isChrome || isEdge
}
onMounted(() => {
themeChange(localTheme.value.name)
setCurrentPrize()
if (judgeMobile() || !judgeChromeOrEdge()) {
tipDialog.value.showModal()
}
})
</script> </script>
<template> <template>
<dialog id="my_modal_1" ref="tipDialog" class="border-none modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
{{ t('dialog.titleTip') }}
</h3>
<p v-if="judgeMobile()" class="py-4">
{{ t('dialog.dialogPCWeb') }}
</p>
<p v-if=" !judgeChromeOrEdge()" class="py-4">
{{ t('dialog.dialogLatestBrowser') }}
</p>
<div class="modal-action">
<form method="dialog" class="flex justify-start w-full gap-3">
<!-- if there is a button in form, it will close the modal -->
<button class="btn">
{{ t('button.confirm') }}
</button>
</form>
</div>
</div>
</dialog>
<router-view /> <router-view />
<PlayMusic class="absolute right-0 bottom-1/2" />
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

6
src/components.d.ts vendored
View File

@@ -9,13 +9,15 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
DaiysuiTable: typeof import('./components/DaiysuiTable/index.vue')['default'] DaiysuiTable: typeof import('./components/DaiysuiTable/index.vue')['default']
Dialog: typeof import('./components/Dialog/index.vue')['default']
EditSeparateDialog: typeof import('./components/NumberSeparate/EditSeparateDialog.vue')['default'] EditSeparateDialog: typeof import('./components/NumberSeparate/EditSeparateDialog.vue')['default']
FileUpload: typeof import('./components/FileUpload/index.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default']
ImageSync: typeof import('./components/ImageSync/index.vue')['default'] ImageSync: typeof import('./components/ImageSync/index.vue')['default']
PlayMusic: typeof import('./components/PlayMusic/index.vue')['default'] Loading: typeof import('./components/Loading/index.vue')['default']
PageHeader: typeof import('./components/PageHeader/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StarsBackground: typeof import('./components/StarsBackground/index.vue')['default']
SvgIcon: typeof import('./components/SvgIcon/index.vue')['default'] SvgIcon: typeof import('./components/SvgIcon/index.vue')['default']
ToTop: typeof import('./components/ToTop/index.vue')['default'] ToTop: typeof import('./components/ToTop/index.vue')['default']
} }

View File

@@ -0,0 +1,73 @@
<script setup lang='ts'>
import { onMounted, ref, toRefs } from 'vue'
import i18n from '@/locales/i18n'
interface Props {
title: string
desc?: string
cancelText?: string
submitText?: string
submitFunc?: () => void
cancelFunc?: () => void
}
const props = withDefaults(defineProps<Props>(), {
cancelText: i18n.global.t('button.cancel'),
submitText: i18n.global.t('button.confirm'),
cancelFunc: () => {},
})
const visible = defineModel('visible', {
type: Boolean,
default: false,
})
const dialogRef = ref <HTMLDialogElement | null> (null)
function defaultCancelFunc() {
dialogRef.value?.close()
}
function showDialog() {
dialogRef.value?.showModal()
}
defineExpose({
showDialog,
closed,
})
onMounted(() => {
dialogRef.value?.addEventListener('close', () => {
visible.value = false
})
})
const { title, desc, cancelText, submitText, submitFunc, cancelFunc = defaultCancelFunc } = toRefs(props)
</script>
<template>
<dialog id="my_modal" ref="dialogRef" class="border-none modal">
<div class="modal-box">
<h3 v-if="title" class="text-lg font-bold">
{{ title }}
</h3>
<p v-if="desc" class="py-4">
{{ desc }}
</p>
<div>
<slot name="content" />
</div>
<div class="modal-action">
<form method="dialog" class="flex gap-3">
<!-- if there is a button in form, it will close the modal -->
<button class="btn" @click="cancelFunc">
{{ cancelText }}
</button>
<button class="btn" @click="submitFunc">
{{ submitText }}
</button>
</form>
</div>
</div>
</dialog>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang='ts'>
import type { IFileData } from './type'
import { ListMusic, Upload, X } from 'lucide-vue-next'
import { ref } from 'vue'
import { readFileData } from '@/utils/file'
defineProps<{
limitType?: string
}>()
const emits = defineEmits<{
uploadFile: [fileData: IFileData | null]
}>()
const originFileName = ref<string | null>(null)
const fileData = ref<IFileData | null>(null)
async function handleFileChange(e: Event) {
const file = ((e.target as HTMLInputElement).files as FileList)[0]
const type = file.type
const { dataUrl, fileName } = await readFileData(file)
fileData.value = { dataUrl, fileName, type }
originFileName.value = fileName
emits('uploadFile', fileData.value)
}
function removeFile() {
fileData.value = null
emits('uploadFile', null)
}
</script>
<template>
<div class="w-full h-full flex flex-col items-center mt-6">
<input
id="file-upload"
:disabled="fileData !== null"
type="file" class="w-full bg-red-400/50 max-h-52 cursor-pointer absolute" style="display: none;" :accept="limitType"
@change="handleFileChange"
>
<label for="file-upload" :class="fileData ? 'cursor-not-allowed' : null" class="w-full h-52 cursor-pointer border-2 border-dashed flex items-center justify-center overflow-hidden">
<img v-if="fileData && fileData.type.includes('image')" class="w-full object-cover stroke-0" :src="fileData.dataUrl" alt="">
<ListMusic v-else-if="fileData && fileData.type.includes('audio')" class="w-2/3 h-2/3 stroke-1 text-gray-500/50" />
<div v-else class="w-full h-full flex justify-center items-center flex-col gap-4">
<Upload class="w-2/3 h-2/3 stroke-1 text-gray-500/50" />
<span class="btn btn-neutral">点击上传</span>
</div>
</label>
<div v-if="fileData" class="w-full flex items-center justify-between mt-2">
<p class="max-w-[3/4] truncate text-sm">
{{ originFileName }}
</p>
<button class="btn btn-xs btn-square btn-ghost" @click="removeFile">
<X />
</button>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,5 @@
export interface IFileData {
dataUrl: string
fileName: string
type: string
}

View File

@@ -18,7 +18,8 @@ async function getImageStoreItem(item: any): Promise<string> {
let image = '' let image = ''
if (item.url === 'Storage') { if (item.url === 'Storage') {
const key = item.id const key = item.id
image = await imageDbStore.getItem(key) as string const imageData = await imageDbStore.getItem(key) as any
image = imageData.dataUrl
} }
else { else {
image = item.url image = item.url

View File

@@ -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 }

View File

@@ -0,0 +1,22 @@
<script setup lang='ts'>
import type { LoadingOptions } from './loading-context'
import { inject } from 'vue'
import { loadingKey } from './loading-context'
// 注入全局状态
const loading = inject(loadingKey) as LoadingOptions
// 解构状态(响应式)
const { visible, text } = loading
</script>
<template>
<div v-if="visible" class="fixed top-0 left-0 w-screen h-screen bg-[rgba(0,0,0,0.5)] flex flex-col gap-6 justify-center items-center z-50">
<span v-if="visible" class="loading loading-spinner loading-xl" />
<span>{{ text ? text : '加载中' }}</span>
</div>
</template>
<style scoped>
</style>

View File

@@ -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<boolean>
text: Ref<string>
fullscreen: Ref<boolean>
zIndex: Ref<number>
count: Ref<number>
show: (options?: Partial<{ text: string, fullscreen: boolean, zIndex: number }>) => void
hide: () => void
}
// 注入密钥Symbol 确保唯一性)
export const loadingKey: InjectionKey<LoadingOptions> = 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,
}

View File

@@ -0,0 +1,24 @@
<script setup lang='ts'>
const props = defineProps<{
title: string
}>()
</script>
<template>
<header>
<h1 class="text-lg leading-10">
{{ props.title }}
</h1>
<div class="button-group my-4">
<slot name="buttons" />
</div>
<div class="divider mt-0" />
<div>
<slot name="alerts" />
</div>
</header>
</template>
<style scoped>
</style>

View File

@@ -1,71 +0,0 @@
<script setup lang='ts'>
import Sparticles from 'sparticles';
import { ref, onMounted, onUnmounted } from 'vue';
import { useElementSize } from '@vueuse/core';
import localforage from 'localforage'
const props = defineProps({
homeBackground: {
type: Object,
default: () => ({
id: '',
name: '',
url: ''
})
}
})
const imageDbStore = localforage.createInstance({
name: 'imgStore'
})
const imgUrl = ref('')
const starRef = ref();
const { width, height } = useElementSize(starRef);
let options = ref({ shape: 'star', parallax: 1.2, rotate: true, twinkle: true, speed: 10, count: 200 });
function addSparticles(node: any, width: number, height: number) {
new Sparticles(node, options.value, width, height);
}
// 页面大小改变时
const listenWindowSize = () => {
window.addEventListener('resize', () => {
if (width.value && height.value) {
addSparticles(starRef.value, width.value, height.value);
}
});
}
const getImageStoreItem = async (item: any): Promise<string> => {
let image = ''
if (item.url == 'Storage') {
const key = item.id;
image = await imageDbStore.getItem(key) as string
}
else {
image = item.url
}
return image
}
onMounted(() => {
getImageStoreItem(props.homeBackground).then((image) => {
imgUrl.value = image
})
addSparticles(starRef.value, width.value, height.value);
listenWindowSize()
})
onUnmounted(() => {
window.removeEventListener('resize', listenWindowSize)
})
</script>
<template>
<div class="home-background w-screen h-screen overflow-hidden" v-if="homeBackground.url">
<img :src="imgUrl" class="w-full h-full object-cover" alt="">
</div>
<div v-else class="w-screen h-screen overflow-hidden" ref="starRef">
</div>
</template>
<style lang='scss' scoped>
</style>

View File

@@ -1,10 +1,10 @@
<script setup lang='ts'> <script setup lang='ts'>
import { ChevronUp } from 'lucide-vue-next'
</script> </script>
<template> <template>
<div class="fixed z-50 flex items-center justify-center w-10 h-10 rounded-full shadow-lg cursor-pointer right-12 bottom-12 bg-slate-700 hover:bg-slate-600"> <div class="fixed z-50 flex items-center justify-center w-10 h-10 rounded-full shadow-lg cursor-pointer right-12 bottom-12 bg-slate-700 hover:bg-slate-600">
<svg-icon name="toTop" /> <ChevronUp />
</div> </div>
</template> </template>

38
src/constant/theme.ts Normal file
View File

@@ -0,0 +1,38 @@
export const daisyuiThemes = [
'valentine',
'halloween',
'cmyk',
'winter',
'corporate',
'fantasy',
'aqua',
'pastel',
'lemonade',
'garden',
'silk',
'black',
'coffee',
'wireframe',
'caramellatte',
'acid',
'cyberpunk',
'nord',
'bumblebee',
'night',
'dracula',
'synthwave',
'light',
'dark',
'forest',
'emerald',
'business',
'cupcake',
'autumn',
'abyss',
'dim',
'lofi',
'sunset',
'luxury',
'retro',
]

View File

@@ -7,7 +7,7 @@ function skip(url: string) {
</script> </script>
<template> <template>
<div class="footer-container"> <div class="footer-container h-[280px]">
<ul class="flex justify-center"> <ul class="flex justify-center">
<li <li
v-for="item in footerList.data" v-for="item in footerList.data"

View File

@@ -1,97 +1,25 @@
<script setup lang='ts'> <script setup lang='ts'>
import useStore from '@/store' import { ref } from 'vue'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { usePlayMusic } from './usePlayMusic'
const { playMusic, currentMusic, nextPlay } = usePlayMusic()
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
const audio = ref(new Audio())
const settingRef = ref() const settingRef = ref()
// const audio = ref(new Audio()) // const audio = ref(new Audio())
const globalConfig = useStore().globalConfig
const { getMusicList: localMusicList, getCurrentMusic: currentMusic } = storeToRefs(globalConfig)
// const localMusicListValue = ref(localMusicList) // const localMusicListValue = ref(localMusicList)
async function play(item: any) {
if (!item) {
return
}
// if (!audio.value.paused && !skip) {
// audio.value.pause()
// return
// }
let audioUrl = ''
if (!item.url) {
return
}
if (item.url === 'Storage') {
audioUrl = await audioDbStore.getItem(item.name) as string
}
else {
audioUrl = item.url
}
audio.value.pause()
audio.value.src = audioUrl
audio.value.play()
}
function playMusic(item: any, skip = false) {
if (!item) {
return
}
if (!currentMusic.value.paused && !skip) {
globalConfig.setCurrentMusic(item, true)
return
}
globalConfig.setCurrentMusic(item, false)
}
function nextPlay() {
//
if (localMusicList.value.length >= 1) {
let index = localMusicList.value.findIndex((item: any) => item.name === currentMusic.value.item.name)
index++
if (index >= localMusicList.value.length) {
index = 0
}
globalConfig.setCurrentMusic(localMusicList.value[index], false)
}
}
//
function onPlayEnd() {
audio.value.addEventListener('ended', nextPlay)
}
function enterConfig() { function enterConfig() {
router.push('/log-lottery/config') router.push('/log-lottery/config')
} }
function enterHome() { function enterHome() {
router.push('/log-lottery') router.push('/log-lottery')
} }
onMounted(() => {
globalConfig.setCurrentMusic(localMusicList.value[0], true)
onPlayEnd()
// 使audio
})
onUnmounted(() => {
audio.value.removeEventListener('ended', nextPlay)
})
watch(currentMusic, (val: any) => {
if (!val.paused && audio.value) {
play(val.item)
}
else {
audio.value.pause()
}
}, { deep: true })
</script> </script>
<template> <template>

View File

@@ -0,0 +1,85 @@
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import useStore from '@/store'
export function usePlayMusic() {
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
const globalConfig = useStore().globalConfig
const { getMusicList: localMusicList, getCurrentMusic: currentMusic } = storeToRefs(globalConfig)
const audio = ref(new Audio())
async function play(item: any) {
if (!item) {
return
}
// if (!audio.value.paused && !skip) {
// audio.value.pause()
// return
// }
let audioUrl = ''
if (!item.url) {
return
}
if (item.url === 'Storage') {
audioUrl = await audioDbStore.getItem(item.name) as string
}
else {
audioUrl = item.url
}
audio.value.pause()
audio.value.src = audioUrl
audio.value.play()
}
function playMusic(item: any, skip = false) {
if (!item) {
return
}
if (!currentMusic.value.paused && !skip) {
globalConfig.setCurrentMusic(item, true)
return
}
globalConfig.setCurrentMusic(item, false)
}
function nextPlay() {
// 播放下一首
if (localMusicList.value.length >= 1) {
let index = localMusicList.value.findIndex((item: any) => item.name === currentMusic.value.item.name)
index++
if (index >= localMusicList.value.length) {
index = 0
}
globalConfig.setCurrentMusic(localMusicList.value[index], false)
}
}
// 监听播放成后开始下一首
function onPlayEnd() {
audio.value.addEventListener('ended', nextPlay)
}
onMounted(() => {
globalConfig.setCurrentMusic(localMusicList.value[0], true)
onPlayEnd()
// 不使用空格控制audio
})
onUnmounted(() => {
audio.value.removeEventListener('ended', nextPlay)
})
watch(currentMusic, (val: any) => {
if (!val.paused && audio.value) {
play(val.item)
}
else {
audio.value.pause()
}
}, { deep: true })
return {
currentMusic,
playMusic,
nextPlay,
}
}

View File

@@ -1,31 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import ToTop from '@/components/ToTop/index.vue'
import { useScroll } from '@vueuse/core' import { useScroll } from '@vueuse/core'
// import Header from './Header/index.vue';
// import Footer from './Footer/index.vue';
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import CustomModal from '@/components/Dialog/index.vue'
import { Loading } from '@/components/Loading'
import ToTop from '@/components/ToTop/index.vue'
import RightButton from './RightButton/index.vue'
import { useMounted } from './useMounted'
const tipDialog = ref()
const { tipDesc } = useMounted(tipDialog)
const { t } = useI18n()
const mainContainer = ref<HTMLElement | null>(null) const mainContainer = ref<HTMLElement | null>(null)
const { y } = useScroll(mainContainer) const { y } = useScroll(mainContainer)
function scrollToTop() { function scrollToTop() {
y.value = 0 mainContainer.value?.scrollTo({
top: 0,
behavior: 'smooth',
})
} }
</script> </script>
<template> <template>
<div class="w-screen"> <div class="w-screen">
<!-- <header class="shadow-2xl head-container h-14"> <Loading />
<Header></Header>
</header> -->
<ToTop v-if="y > 400" @click="scrollToTop" /> <ToTop v-if="y > 400" @click="scrollToTop" />
<main ref="mainContainer" class="box-content w-screen h-screen overflow-x-hidden overflow-y-auto main-container"> <main ref="mainContainer" class="box-content w-screen h-screen overflow-x-hidden overflow-y-auto main-container">
<router-view class="h-full main-container-content" /> <router-view class="h-full main-container-content" />
</main> </main>
<!-- <footer class="w-screen footer-container"> <RightButton class="absolute right-0 bottom-1/2" />
<Footer></Footer> <CustomModal ref="tipDialog" :title="t('dialog.titleTip')" :desc="tipDesc" />
</footer> -->
</div> </div>
</template> </template>

65
src/layout/useMounted.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onMounted, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { loadingKey, loadingState } from '@/components/Loading'
import useStore from '@/store'
import { themeChange } from '@/utils'
export function useMounted(tipDialog: Ref<any>) {
provide(loadingKey, loadingState)
const globalConfig = useStore().globalConfig
const prizeConfig = useStore().prizeConfig
const system = useStore().system
const { getTheme: localTheme } = storeToRefs(globalConfig)
const { getPrizeConfig: prizeList } = storeToRefs(prizeConfig)
const tipDesc = ref('')
const { t } = useI18n()
// 设置当前奖列表
function setCurrentPrize() {
if (prizeList.value.length <= 0) {
return
}
for (let i = 0; i < prizeList.value.length; i++) {
if (!prizeList.value[i].isUsed) {
prizeConfig.setCurrentPrize(prizeList.value[i])
break
}
}
}
// 判断是否手机端访问
function judgeMobile() {
const ua = navigator.userAgent
const isAndroid = ua.includes('Android') || ua.includes('Adr')
const isIOS = !!ua.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/)
system.setIsMobile(isAndroid || isIOS)
return isAndroid || isIOS
}
// 判断是否chrome或者edge访问
function judgeChromeOrEdge() {
const ua = navigator.userAgent
const isChrome = ua.includes('Chrome')
const isEdge = ua.includes('Edg')
system.setIsChrome(isChrome)
return isChrome || isEdge
}
onMounted(() => {
themeChange(localTheme.value.name)
setCurrentPrize()
if (judgeMobile()) {
tipDialog.value.showDialog()
tipDesc.value = t('dialog.dialogPCWeb')
}
else if (!judgeChromeOrEdge()) {
tipDialog.value.showDialog()
tipDesc.value = t('dialog.dialogLatestBrowser')
}
})
return { tipDesc }
}

View File

@@ -1,20 +1,20 @@
import svgIcon from '@/components/SvgIcon/index.vue' // pinia
import i18n from '@/locales/i18n' import { createPinia } from 'pinia'
// pinia持久化
import piniaPluginPersist from 'pinia-plugin-persist'
import * as THREE from 'three' import * as THREE from 'three'
import { createApp } from 'vue' import { createApp } from 'vue'
import VueDOMPurifyHTML from 'vue-dompurify-html' import VueDOMPurifyHTML from 'vue-dompurify-html'
import svgIcon from '@/components/SvgIcon/index.vue'
import i18n from '@/locales/i18n'
// svg全局组件// 路由
import router from '@/router'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
import './style/markdown.css' import './style/markdown.css'
import './style/style.scss' import './style/style.scss'
// 全局svg组件 // 全局svg组件
import 'virtual:svg-icons-register' import 'virtual:svg-icons-register'
// svg全局组件// 路由
import router from '@/router'
// pinia
import { createPinia } from 'pinia'
// pinia持久化
import piniaPluginPersist from 'pinia-plugin-persist'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()

View File

@@ -1,142 +1,142 @@
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue' import Layout from '@/layout/index.vue'
import i18n from '@/locales/i18n' import i18n from '@/locales/i18n'
import Home from '@/views/Home/index.vue' import Home from '@/views/Home/index.vue'
import { createRouter, createWebHistory,createWebHashHistory } from 'vue-router'
export const configRoutes = { export const configRoutes = {
path: '/log-lottery/config', path: '/log-lottery/config',
name: 'Config', name: 'Config',
component: () => import('@/views/Config/index.vue'), component: () => import('@/views/Config/index.vue'),
children: [ children: [
{
path: '',
redirect: '/log-lottery/config/person',
},
{
path: '/log-lottery/config/person',
name: 'PersonConfig',
component: () => import('@/views/Config/Person/PersonConfig.vue'),
meta: {
title: i18n.global.t('sidebar.personConfiguration'),
icon: 'person',
},
children: [
{ {
path: '', path: '',
redirect: '/log-lottery/config/person/all', redirect: '/log-lottery/config/person',
}, },
{ {
path: '/log-lottery/config/person/all', path: '/log-lottery/config/person',
name: 'AllPersonConfig', name: 'PersonConfig',
component: () => import('@/views/Config/Person/PersonAll.vue'), component: () => import('@/views/Config/Person/PersonConfig.vue'),
meta: { meta: {
title: i18n.global.t('sidebar.personList'), title: i18n.global.t('sidebar.personConfiguration'),
icon: 'all', icon: 'person',
}, },
children: [
{
path: '',
redirect: '/log-lottery/config/person/all',
},
{
path: '/log-lottery/config/person/all',
name: 'AllPersonConfig',
component: () => import('@/views/Config/Person/PersonAll/index.vue'),
meta: {
title: i18n.global.t('sidebar.personList'),
icon: 'all',
},
},
{
path: '/log-lottery/config/person/already',
name: 'AlreadyPerson',
component: () => import('@/views/Config/Person/PersonAlready.vue'),
meta: {
title: i18n.global.t('sidebar.winnerList'),
icon: 'already',
},
},
// {
// path:'other',
// name:'OtherPersonConfig',
// component:()=>import('@/views/Config/Person/OtherPersonConfig.vue'),
// meta:{
// title:'其他配置',
// icon:'other'
// }
// }
],
}, },
{ {
path: '/log-lottery/config/person/already', path: '/log-lottery/config/prize',
name: 'AlreadyPerson', name: 'PrizeConfig',
component: () => import('@/views/Config/Person/PersonAlready.vue'), component: () => import('@/views/Config/Prize/PrizeConfig.vue'),
meta: { meta: {
title: i18n.global.t('sidebar.winnerList'), title: i18n.global.t('sidebar.prizeConfiguration'),
icon: 'already', icon: 'prize',
}, },
},
// {
// path:'other',
// name:'OtherPersonConfig',
// component:()=>import('@/views/Config/Person/OtherPersonConfig.vue'),
// meta:{
// title:'其他配置',
// icon:'other'
// }
// }
],
},
{
path: '/log-lottery/config/prize',
name: 'PrizeConfig',
component: () => import('@/views/Config/Prize/PrizeConfig.vue'),
meta: {
title: i18n.global.t('sidebar.prizeConfiguration'),
icon: 'prize',
},
},
{
path: '/log-lottery/config/global',
name: 'GlobalConfig',
redirect: '/log-lottery/config/global/all',
meta: {
title: i18n.global.t('sidebar.globalSetting'),
icon: 'global',
},
children: [
{
path: '/log-lottery/config/global/face',
name: 'FaceConfig',
component: () => import('@/views/Config/Global/FaceConfig.vue'),
meta: {
title: i18n.global.t('sidebar.viewSetting'),
icon: 'face',
},
}, },
{ {
path: '/log-lottery/config/global/image', path: '/log-lottery/config/global',
name: 'ImageConfig', name: 'GlobalConfig',
component: () => import('@/views/Config/Global/ImageConfig.vue'), redirect: '/log-lottery/config/global/all',
meta: { meta: {
title: i18n.global.t('sidebar.imagesManagement'), title: i18n.global.t('sidebar.globalSetting'),
icon: 'image', icon: 'global',
}, },
children: [
{
path: '/log-lottery/config/global/face',
name: 'FaceConfig',
component: () => import('@/views/Config/Global/FaceConfig/index.vue'),
meta: {
title: i18n.global.t('sidebar.viewSetting'),
icon: 'face',
},
},
{
path: '/log-lottery/config/global/image',
name: 'ImageConfig',
component: () => import('@/views/Config/Global/ImageConfig/index.vue'),
meta: {
title: i18n.global.t('sidebar.imagesManagement'),
icon: 'image',
},
},
{
path: '/log-lottery/config/global/music',
name: 'MusicConfig',
component: () => import('@/views/Config/Global/MusicConfig/index.vue'),
meta: {
title: i18n.global.t('sidebar.musicManagement'),
icon: 'music',
},
},
],
}, },
{ {
path: '/log-lottery/config/global/music', path: '/log-lottery/config/readme',
name: 'MusicConfig', name: 'Readme',
component: () => import('@/views/Config/Global/MusicConfig.vue'), component: () => import('@/views/Config/Readme/index.vue'),
meta: { meta: {
title: i18n.global.t('sidebar.musicManagement'), title: i18n.global.t('sidebar.operatingInstructions'),
icon: 'music', icon: 'readme',
}, },
}, },
], ],
},
{
path: '/log-lottery/config/readme',
name: 'Readme',
component: () => import('@/views/Config/Readme/index.vue'),
meta: {
title: i18n.global.t('sidebar.operatingInstructions'),
icon: 'readme',
},
},
],
} }
const routes = [ const routes = [
{ {
path: '/log-lottery', path: '/log-lottery',
component: Layout, component: Layout,
redirect: '/log-lottery/home', redirect: '/log-lottery/home',
children: [ children: [
{ {
path: '/log-lottery/home', path: '/log-lottery/home',
name: 'Home', name: 'Home',
component: Home, component: Home,
}, },
{ {
path: '/log-lottery/demo', path: '/log-lottery/demo',
name: 'Demo', name: 'Demo',
component: () => import('@/views/Demo/index.vue'), component: () => import('@/views/Demo/index.vue'),
}, },
configRoutes, configRoutes,
], ],
}, },
]; ]
const envMode=import.meta.env.MODE; const envMode = import.meta.env.MODE
const router = createRouter({ const router = createRouter({
// 读取环境变量 // 读取环境变量
history: envMode==='file'?createWebHashHistory():createWebHistory(), history: envMode === 'file' ? createWebHashHistory() : createWebHistory(),
routes, routes,
}) })
export default router export default router

View File

@@ -278,4 +278,4 @@ export const defaultImageList = [
url: `${originUrl}/resource/image/image5.png`, url: `${originUrl}/resource/image/image5.png`,
}, },
] ]
export const defaultPatternList = [21, 38, 55, 54, 53, 70, 87, 88, 89, 23, 40, 57, 74, 91, 92, 93, 76, 59, 42, 25, 24, 27, 28, 29, 46, 63, 62, 61, 78, 95, 96, 97, 20, 19, 31, 48, 65, 66, 67, 84, 101, 100, 99, 32, 33] export const defaultPatternList = [21, 38, 55, 54, 53, 70, 87, 88, 89, 23, 40, 57, 74, 91, 92, 76, 59, 42, 25, 24, 27, 28, 29, 46, 63, 62, 61, 78, 95, 96, 97, 20, 19, 31, 48, 66, 67, 84, 101, 100, 32, 33, 93, 65, 82, 99]

View File

@@ -1,280 +1,280 @@
import type { IImage, IMusic } from '@/types/storeType' import type { IImage, IMusic } from '@/types/storeType'
import i18n, { browserLanguage } from '@/locales/i18n'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import i18n, { browserLanguage } from '@/locales/i18n'
import { defaultImageList, defaultMusicList, defaultPatternList } from './data' import { defaultImageList, defaultMusicList, defaultPatternList } from './data'
// import { IPrizeConfig } from '@/types/storeType'; // import { IPrizeConfig } from '@/types/storeType';
export const useGlobalConfig = defineStore('global', { export const useGlobalConfig = defineStore('global', {
state() { state() {
return { return {
globalConfig: { globalConfig: {
rowCount: 17, rowCount: 17,
isSHowPrizeList: true, isSHowPrizeList: true,
isShowAvatar: false, isShowAvatar: false,
topTitle: i18n.global.t('data.defaultTitle'), topTitle: i18n.global.t('data.defaultTitle'),
language: browserLanguage, language: browserLanguage,
theme: { theme: {
name: 'dracula', name: 'dracula',
detail: { primary: '#0f5fd3' }, detail: { primary: '#0f5fd3' },
cardColor: '#ff79c6', cardColor: '#ff79c6',
cardWidth: 140, cardWidth: 140,
cardHeight: 200, cardHeight: 200,
textColor: '#ffffff', textColor: '#ffffff',
luckyCardColor: '#ECB1AC', luckyCardColor: '#ECB1AC',
textSize: 30, textSize: 30,
patternColor: '#1b66c9', patternColor: '#1b66c9',
patternList: defaultPatternList as number[], patternList: defaultPatternList as number[],
background: {}, // 背景颜色或图片 background: {}, // 背景颜色或图片
},
musicList: defaultMusicList as IMusic[],
imageList: defaultImageList as IImage[],
},
currentMusic: {
item: defaultMusicList[0],
paused: true,
},
}
},
getters: {
// 获取全部配置
getGlobalConfig(state) {
return state.globalConfig
}, },
musicList: defaultMusicList as IMusic[], // 获取标题
imageList: defaultImageList as IImage[], getTopTitle(state) {
}, return state.globalConfig.topTitle
currentMusic: { },
item: defaultMusicList[0], // 获取行数
paused: true, getRowCount(state) {
}, return state.globalConfig.rowCount
} },
}, // 获取主题
getters: { getTheme(state) {
// 获取全部配置 return state.globalConfig.theme
getGlobalConfig(state) { },
return state.globalConfig // 获取卡片颜色
}, getCardColor(state) {
// 获取标题 return state.globalConfig.theme.cardColor
getTopTitle(state) { },
return state.globalConfig.topTitle // 获取中奖颜色
}, getLuckyColor(state) {
// 获取行数 return state.globalConfig.theme.luckyCardColor
getRowCount(state) { },
return state.globalConfig.rowCount // 获取文字颜色
}, getTextColor(state) {
// 获取主题 return state.globalConfig.theme.textColor
getTheme(state) { },
return state.globalConfig.theme // 获取卡片宽高
}, getCardSize(state) {
// 获取卡片颜色 return {
getCardColor(state) { width: state.globalConfig.theme.cardWidth,
return state.globalConfig.theme.cardColor height: state.globalConfig.theme.cardHeight,
}, }
// 获取中奖颜色 },
getLuckyColor(state) { // 获取文字大小
return state.globalConfig.theme.luckyCardColor getTextSize(state) {
}, return state.globalConfig.theme.textSize
// 获取文字颜色 },
getTextColor(state) { // 获取图案颜色
return state.globalConfig.theme.textColor getPatterColor(state) {
}, return state.globalConfig.theme.patternColor
// 获取卡片宽高 },
getCardSize(state) { // 获取图案列表
return { getPatternList(state) {
width: state.globalConfig.theme.cardWidth, return state.globalConfig.theme.patternList
height: state.globalConfig.theme.cardHeight, },
} // 获取音乐列表
}, getMusicList(state) {
// 获取文字大小 return state.globalConfig.musicList
getTextSize(state) { },
return state.globalConfig.theme.textSize // 获取当前音乐
}, getCurrentMusic(state) {
// 获取图案颜色 return state.currentMusic
getPatterColor(state) { },
return state.globalConfig.theme.patternColor // 获取图片列表
}, getImageList(state) {
// 获取图案列表 return state.globalConfig.imageList
getPatternList(state) { },
return state.globalConfig.theme.patternList // 获取是否显示奖品列表
}, getIsShowPrizeList(state) {
// 获取音乐列表 return state.globalConfig.isSHowPrizeList
getMusicList(state) { },
return state.globalConfig.musicList // 获取当前语言
}, getLanguage(state) {
// 获取当前音乐 return state.globalConfig.language
getCurrentMusic(state) { },
return state.currentMusic // 获取背景图片设置
}, getBackground(state) {
// 获取图片列表 return state.globalConfig.theme.background
getImageList(state) { },
return state.globalConfig.imageList // 获取是否显示头像
}, getIsShowAvatar(state) {
// 获取是否显示奖品列表 return state.globalConfig.isShowAvatar
getIsShowPrizeList(state) {
return state.globalConfig.isSHowPrizeList
},
// 获取当前语言
getLanguage(state) {
return state.globalConfig.language
},
// 获取背景图片设置
getBackground(state) {
return state.globalConfig.theme.background
},
// 获取是否显示头像
getIsShowAvatar(state) {
return state.globalConfig.isShowAvatar
},
},
actions: {
// 设置rowCount
setRowCount(rowCount: number) {
this.globalConfig.rowCount = rowCount
},
// 设置标题
setTopTitle(topTitle: string) {
this.globalConfig.topTitle = topTitle
},
// 设置主题
setTheme(theme: any) {
const { name, detail } = theme
this.globalConfig.theme.name = name
this.globalConfig.theme.detail = detail
},
// 设置卡片颜色
setCardColor(cardColor: string) {
this.globalConfig.theme.cardColor = cardColor
},
// 设置中奖颜色
setLuckyCardColor(luckyCardColor: string) {
this.globalConfig.theme.luckyCardColor = luckyCardColor
},
// 设置文字颜色
setTextColor(textColor: string) {
this.globalConfig.theme.textColor = textColor
},
// 设置卡片宽高
setCardSize(cardSize: { width: number, height: number }) {
this.globalConfig.theme.cardWidth = cardSize.width
this.globalConfig.theme.cardHeight = cardSize.height
},
// 设置文字大小
setTextSize(textSize: number) {
this.globalConfig.theme.textSize = textSize
},
// 设置图案颜色
setPatterColor(patterColor: string) {
this.globalConfig.theme.patternColor = patterColor
},
// 设置图案列表
setPatternList(patternList: number[]) {
this.globalConfig.theme.patternList = patternList
},
// 重置图案列表
resetPatternList() {
this.globalConfig.theme.patternList = defaultPatternList
},
// 添加音乐
addMusic(music: IMusic) {
// 验证音乐是否已存在看name字段
for (let i = 0; i < this.globalConfig.musicList.length; i++) {
if (this.globalConfig.musicList[i].name === music.name) {
return
}
}
this.globalConfig.musicList.push(music)
},
// 删除音乐
removeMusic(musicId: string) {
for (let i = 0; i < this.globalConfig.musicList.length; i++) {
if (this.globalConfig.musicList[i].id === musicId) {
this.globalConfig.musicList.splice(i, 1)
break
}
}
},
// 设置当前播放音乐
setCurrentMusic(musicItem: IMusic, paused: boolean = true) {
this.currentMusic = {
item: musicItem,
paused,
}
},
// 重置音乐列表
resetMusicList() {
this.globalConfig.musicList = JSON.parse(JSON.stringify(defaultMusicList)) as IMusic[]
},
// 清空音乐列表
clearMusicList() {
this.globalConfig.musicList = [] as IMusic[]
},
// 添加图片
addImage(image: IImage) {
for (let i = 0; i < this.globalConfig.imageList.length; i++) {
if (this.globalConfig.imageList[i].name === image.name) {
return
}
}
this.globalConfig.imageList.push(image)
},
// 删除图片
removeImage(imageId: string) {
for (let i = 0; i < this.globalConfig.imageList.length; i++) {
if (this.globalConfig.imageList[i].id === imageId) {
this.globalConfig.imageList.splice(i, 1)
break
}
}
},
// 重置图片列表
resetImageList() {
this.globalConfig.imageList = defaultImageList as IImage[]
},
// 清空图片列表
clearImageList() {
this.globalConfig.imageList = [] as IImage[]
},
// 设置是否显示奖品列表
setIsShowPrizeList(isShowPrizeList: boolean) {
this.globalConfig.isSHowPrizeList = isShowPrizeList
},
// 设置
setLanguage(language: string) {
this.globalConfig.language = language
i18n.global.locale.value = language
},
// 设置背景图片
setBackground(background: any) {
this.globalConfig.theme.background = background
},
// 设置是否显示头像
setIsShowAvatar(isShowAvatar: boolean) {
this.globalConfig.isShowAvatar = isShowAvatar
},
// 重置所有配置
reset() {
this.globalConfig = {
rowCount: 17,
isSHowPrizeList: true,
isShowAvatar: false,
topTitle: i18n.global.t('data.defaultTitle'),
language: browserLanguage,
theme: {
name: 'dracula',
detail: { primary: '#0f5fd3' },
cardColor: '#ff79c6',
cardWidth: 140,
cardHeight: 200,
textColor: '#ffffff',
luckyCardColor: '#ECB1AC',
textSize: 30,
patternColor: '#1b66c9',
patternList: defaultPatternList as number[],
background: {}, // 背景颜色或图片
}, },
musicList: defaultMusicList as IMusic[],
imageList: defaultImageList as IImage[],
}
this.currentMusic = {
item: defaultMusicList[0],
paused: true,
}
}, },
}, actions: {
persist: { // 设置rowCount
enabled: true, setRowCount(rowCount: number) {
strategies: [ this.globalConfig.rowCount = rowCount
{ },
// 如果要存储在localStorage中 // 设置标题
storage: localStorage, setTopTitle(topTitle: string) {
key: 'globalConfig', this.globalConfig.topTitle = topTitle
paths: ['globalConfig'], },
}, // 设置主题
], setTheme(theme: any) {
}, const { name, detail } = theme
this.globalConfig.theme.name = name
this.globalConfig.theme.detail = detail
},
// 设置卡片颜色
setCardColor(cardColor: string) {
this.globalConfig.theme.cardColor = cardColor
},
// 设置中奖颜色
setLuckyCardColor(luckyCardColor: string) {
this.globalConfig.theme.luckyCardColor = luckyCardColor
},
// 设置文字颜色
setTextColor(textColor: string) {
this.globalConfig.theme.textColor = textColor
},
// 设置卡片宽高
setCardSize(cardSize: { width: number, height: number }) {
this.globalConfig.theme.cardWidth = cardSize.width
this.globalConfig.theme.cardHeight = cardSize.height
},
// 设置文字大小
setTextSize(textSize: number) {
this.globalConfig.theme.textSize = textSize
},
// 设置图案颜色
setPatterColor(patterColor: string) {
this.globalConfig.theme.patternColor = patterColor
},
// 设置图案列表
setPatternList(patternList: number[]) {
this.globalConfig.theme.patternList = patternList
},
// 重置图案列表
resetPatternList() {
this.globalConfig.theme.patternList = defaultPatternList
},
// 添加音乐
addMusic(music: IMusic) {
// 验证音乐是否已存在看name字段
for (let i = 0; i < this.globalConfig.musicList.length; i++) {
if (this.globalConfig.musicList[i].name === music.name) {
return
}
}
this.globalConfig.musicList.push(music)
},
// 删除音乐
removeMusic(musicId: string) {
for (let i = 0; i < this.globalConfig.musicList.length; i++) {
if (this.globalConfig.musicList[i].id === musicId) {
this.globalConfig.musicList.splice(i, 1)
break
}
}
},
// 设置当前播放音乐
setCurrentMusic(musicItem: IMusic, paused: boolean = true) {
this.currentMusic = {
item: musicItem,
paused,
}
},
// 重置音乐列表
resetMusicList() {
this.globalConfig.musicList = JSON.parse(JSON.stringify(defaultMusicList)) as IMusic[]
},
// 清空音乐列表
clearMusicList() {
this.globalConfig.musicList = [] as IMusic[]
},
// 添加图片
addImage(image: IImage) {
for (let i = 0; i < this.globalConfig.imageList.length; i++) {
if (this.globalConfig.imageList[i].name === image.name) {
return
}
}
this.globalConfig.imageList.push(image)
},
// 删除图片
removeImage(imageId: string) {
for (let i = 0; i < this.globalConfig.imageList.length; i++) {
if (this.globalConfig.imageList[i].id === imageId) {
this.globalConfig.imageList.splice(i, 1)
break
}
}
},
// 重置图片列表
resetImageList() {
this.globalConfig.imageList = defaultImageList as IImage[]
},
// 清空图片列表
clearImageList() {
this.globalConfig.imageList = [] as IImage[]
},
// 设置是否显示奖品列表
setIsShowPrizeList(isShowPrizeList: boolean) {
this.globalConfig.isSHowPrizeList = isShowPrizeList
},
// 设置
setLanguage(language: string) {
this.globalConfig.language = language
i18n.global.locale.value = language
},
// 设置背景图片
setBackground(background: any) {
this.globalConfig.theme.background = background
},
// 设置是否显示头像
setIsShowAvatar(isShowAvatar: boolean) {
this.globalConfig.isShowAvatar = isShowAvatar
},
// 重置所有配置
reset() {
this.globalConfig = {
rowCount: 17,
isSHowPrizeList: true,
isShowAvatar: false,
topTitle: i18n.global.t('data.defaultTitle'),
language: browserLanguage,
theme: {
name: 'dracula',
detail: { primary: '#0f5fd3' },
cardColor: '#ff79c6',
cardWidth: 140,
cardHeight: 200,
textColor: '#ffffff',
luckyCardColor: '#ECB1AC',
textSize: 30,
patternColor: '#1b66c9',
patternList: defaultPatternList as number[],
background: {}, // 背景颜色或图片
},
musicList: defaultMusicList as IMusic[],
imageList: defaultImageList as IImage[],
}
this.currentMusic = {
item: defaultMusicList[0],
paused: true,
}
},
},
persist: {
enabled: true,
strategies: [
{
// 如果要存储在localStorage中
storage: localStorage,
key: 'globalConfig',
paths: ['globalConfig'],
},
],
},
}) })

View File

@@ -1,159 +1,185 @@
import type { IPersonConfig, IPrizeConfig } from '@/types/storeType' import type { IPersonConfig, IPrizeConfig } from '@/types/storeType'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref, toRaw, watch } from 'vue'
import { IndexDb } from '@/utils/dexie'
import { defaultPersonList } from './data' import { defaultPersonList } from './data'
import { usePrizeConfig } from './prizeConfig' import { usePrizeConfig } from './prizeConfig'
export const usePersonConfig = defineStore('person', { // 获取IPersonConfig的key组成数组
state() { export const personListKey = Object.keys(defaultPersonList[0])
return { export const usePersonConfig = defineStore('person', () => {
personConfig: { const personDb = new IndexDb('person', ['allPersonList', 'alreadyPersonList'], 1, personListKey)
// NOTE: state
const personConfig = ref({
allPersonList: [] as IPersonConfig[], allPersonList: [] as IPersonConfig[],
alreadyPersonList: [] as IPersonConfig[], alreadyPersonList: [] as IPersonConfig[],
}, })
} personDb.getAllData('allPersonList').then((data) => {
}, personConfig.value.allPersonList = data
getters: { })
personDb.getAllData('alreadyPersonList').then((data) => {
console.log(data)
personConfig.value.alreadyPersonList = data
})
// NOTE: getter
// 获取全部配置 // 获取全部配置
getPersonConfig(state) { const getPersonConfig = computed(() => personConfig.value)
return state.personConfig
},
// 获取全部人员名单 // 获取全部人员名单
getAllPersonList(state) { const getAllPersonList = computed(() => personConfig.value.allPersonList)
return state.personConfig.allPersonList.filter((item: IPersonConfig) => {
return item
})
},
// 获取未获此奖的人员名单 // 获取未获此奖的人员名单
getNotThisPrizePersonList(state: any) { const getNotThisPrizePersonList = computed(() => {
const currentPrize = usePrizeConfig().prizeConfig.currentPrize const currentPrize = usePrizeConfig().prizeConfig.currentPrize
const data = state.personConfig.allPersonList.filter((item: IPersonConfig) => { const data = personConfig.value.allPersonList.filter((item: IPersonConfig) => {
return !item.prizeId.includes(currentPrize.id as string) return !item.prizeId.includes(currentPrize.id as string)
})
return data
},
// 获取已中奖人员名单
getAlreadyPersonList(state) {
return state.personConfig.allPersonList.filter((item: IPersonConfig) => {
return item.isWin === true
})
},
// 获取中奖人员详情
getAlreadyPersonDetail(state) {
return state.personConfig.alreadyPersonList
},
// 获取未中奖人员名单
getNotPersonList(state) {
return state.personConfig.allPersonList.filter((item: IPersonConfig) => {
return item.isWin === false
})
},
},
actions: {
// 添加未中奖人员
addNotPersonList(personList: IPersonConfig[]) {
if (personList.length <= 0) {
return
}
personList.forEach((item: IPersonConfig) => {
this.personConfig.allPersonList.push(item)
})
},
// 添加已中奖人员
addAlreadyPersonList(personList: IPersonConfig[], prize: IPrizeConfig | null) {
if (personList.length <= 0) {
return
}
personList.forEach((person: IPersonConfig) => {
this.personConfig.allPersonList.map((item: IPersonConfig) => {
if (item.id === person.id && prize != null) {
item.isWin = true
// person.isWin = true
item.prizeName.push(prize.name)
// person.prizeName += prize.name
item.prizeTime.push(dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss'))
// person.prizeTime = new Date().toString()
item.prizeId.push(prize.id as string)
}
return item
}) })
this.personConfig.alreadyPersonList.push(person)
})
},
// 从已中奖移动到未中奖
moveAlreadyToNot(person: IPersonConfig) {
if (person.id === undefined || person.id == null) {
return
}
const alreadyPersonListLength = this.personConfig.alreadyPersonList.length
for (let i = 0; i < this.personConfig.allPersonList.length; i++) {
if (person.id === this.personConfig.allPersonList[i].id) {
this.personConfig.allPersonList[i].isWin = false
this.personConfig.allPersonList[i].prizeName = []
this.personConfig.allPersonList[i].prizeTime = []
this.personConfig.allPersonList[i].prizeId = []
break return data
})
// 获取已中奖人员名单
const getAlreadyPersonList = computed(() => {
return personConfig.value.allPersonList.filter((item: IPersonConfig) => {
return item.isWin === true
})
})
// 获取中奖人员详情
const getAlreadyPersonDetail = computed(() => personConfig.value.alreadyPersonList)
// 获取未中奖人员名单
const getNotPersonList = computed(() => personConfig.value.allPersonList.filter((item: IPersonConfig) => {
return item.isWin === false
}))
// NOTE: action
// 添加未中奖人员
function addNotPersonList(personList: IPersonConfig[]) {
if (personList.length <= 0) {
return
} }
} personList.forEach((item: IPersonConfig) => {
for (let i = 0; i < alreadyPersonListLength; i++) { personConfig.value.allPersonList.push(item)
this.personConfig.alreadyPersonList = this.personConfig.alreadyPersonList.filter((item: IPersonConfig) => })
item.id !== person.id, personDb.setAllData('allPersonList', personList)
) }
} // 添加已中奖人员
}, function addAlreadyPersonList(personList: IPersonConfig[], prize: IPrizeConfig | null) {
if (personList.length <= 0) {
return
}
personList.forEach((person: IPersonConfig) => {
personConfig.value.allPersonList.map((item: IPersonConfig) => {
if (item.id === person.id && prize != null) {
item.isWin = true
// person.isWin = true
item.prizeName.push(prize.name)
// person.prizeName += prize.name
item.prizeTime.push(dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss'))
// person.prizeTime = new Date().toString()
item.prizeId.push(prize.id as string)
}
return item
})
personConfig.value.alreadyPersonList.push(person)
personDb.updateData('allPersonList', toRaw(person))
personDb.setData('alreadyPersonList', toRaw(person))
})
}
// 从已中奖移动到未中奖
function moveAlreadyToNot(person: IPersonConfig) {
if (person.id === undefined || person.id == null) {
return
}
const alreadyPersonListLength = personConfig.value.alreadyPersonList.length
for (let i = 0; i < personConfig.value.allPersonList.length; i++) {
if (person.id === personConfig.value.allPersonList[i].id) {
personConfig.value.allPersonList[i].isWin = false
personConfig.value.allPersonList[i].prizeName = []
personConfig.value.allPersonList[i].prizeTime = []
personConfig.value.allPersonList[i].prizeId = []
personDb.updateData('allPersonList', toRaw(personConfig.value.allPersonList[i]))
break
}
}
const alreadyPersonListRaw = toRaw(personConfig.value.alreadyPersonList)
for (let i = 0; i < alreadyPersonListLength; i++) {
personConfig.value.alreadyPersonList = alreadyPersonListRaw.filter((item: IPersonConfig) =>
item.id !== person.id,
)
}
personDb.deleteData('alreadyPersonList', person)
}
// 删除指定人员 // 删除指定人员
deletePerson(person: IPersonConfig) { function deletePerson(person: IPersonConfig) {
if (person.id !== undefined || person.id != null) { if (person.id !== undefined || person.id != null) {
this.personConfig.allPersonList = this.personConfig.allPersonList.filter((item: IPersonConfig) => item.id !== person.id) const allPersonListRaw = toRaw(personConfig.value.allPersonList)
this.personConfig.alreadyPersonList = this.personConfig.alreadyPersonList.filter((item: IPersonConfig) => item.id !== person.id) const alreadyPersonListRaw = toRaw(personConfig.value.alreadyPersonList)
} personConfig.value.allPersonList = allPersonListRaw.filter((item: IPersonConfig) => item.id !== person.id)
}, personConfig.value.alreadyPersonList = alreadyPersonListRaw.filter((item: IPersonConfig) => item.id !== person.id)
personDb.deleteData('allPersonList', person)
personDb.deleteData('alreadyPersonList', person)
}
}
// 删除所有人员 // 删除所有人员
deleteAllPerson() { function deleteAllPerson() {
this.personConfig.allPersonList = [] personConfig.value.allPersonList = []
this.personConfig.alreadyPersonList = [] personConfig.value.alreadyPersonList = []
}, personDb.deleteAll('allPersonList')
personDb.deleteAll('alreadyPersonList')
}
// 删除所有人员 // 删除所有人员
resetPerson() { function resetPerson() {
this.personConfig.allPersonList = [] personConfig.value.allPersonList = []
this.personConfig.alreadyPersonList = [] personConfig.value.alreadyPersonList = []
}, personDb.deleteAll('allPersonList')
personDb.deleteAll('alreadyPersonList')
}
// 重置已中奖人员 // 重置已中奖人员
resetAlreadyPerson() { function resetAlreadyPerson() {
// 把已中奖人员合并到未中奖人员,要验证是否已存在 // 把已中奖人员合并到未中奖人员,要验证是否已存在
this.personConfig.allPersonList.forEach((item: IPersonConfig) => { personConfig.value.allPersonList.forEach((item: IPersonConfig) => {
item.isWin = false item.isWin = false
item.prizeName = [] item.prizeName = []
item.prizeTime = [] item.prizeTime = []
item.prizeId = [] item.prizeId = []
}) })
this.personConfig.alreadyPersonList = [] personConfig.value.alreadyPersonList = []
}, const allPersonListRaw = toRaw(personConfig.value.allPersonList)
setDefaultPersonList() { personDb.deleteAll('allPersonList')
this.personConfig.allPersonList = defaultPersonList personDb.setAllData('allPersonList', allPersonListRaw)
this.personConfig.alreadyPersonList = [] personDb.deleteAll('alreadyPersonList')
}, }
function setDefaultPersonList() {
personConfig.value.allPersonList = defaultPersonList
personConfig.value.alreadyPersonList = []
personDb.setAllData('allPersonList', defaultPersonList)
personDb.deleteAll('alreadyPersonList')
}
// 重置所有配置 // 重置所有配置
reset() { function reset() {
this.personConfig = { personConfig.value = {
allPersonList: [] as IPersonConfig[], allPersonList: [] as IPersonConfig[],
alreadyPersonList: [] as IPersonConfig[], alreadyPersonList: [] as IPersonConfig[],
} }
}, personDb.deleteAll('allPersonList')
}, personDb.deleteAll('alreadyPersonList')
persist: { }
enabled: true, return {
strategies: [ personConfig,
{ getPersonConfig,
// 如果要存储在localStorage中 getAllPersonList,
storage: localStorage, getNotThisPrizePersonList,
key: 'personConfig', getAlreadyPersonList,
}, getAlreadyPersonDetail,
], getNotPersonList,
}, addNotPersonList,
addAlreadyPersonList,
moveAlreadyToNot,
deletePerson,
deleteAllPerson,
resetPerson,
resetAlreadyPerson,
setDefaultPersonList,
reset,
}
}) })

View File

@@ -1,6 +1,15 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @plugin "daisyui" {
themes: all;
}
@utility w-option-xs {
max-width: calc(var(--container-xs) - 90px);
}
/* @plugin "@tailwindcss/typography" */
body, body,
html { html {

View File

@@ -1,118 +1,56 @@
@media (prefers-color-scheme: dark) { /* 定义--color-fg-default */
:root {
.markdown-body, --color-prettylights-syntax-comment: #8b949e;
[data-theme="dark"] { --color-prettylights-syntax-constant: #79c0ff;
/*dark*/ --color-prettylights-syntax-entity: #d2a8ff;
color-scheme: dark; --color-prettylights-syntax-storage-modifier-import: #c9d1d9;
--color-prettylights-syntax-comment: #8b949e; --color-prettylights-syntax-entity-tag: #7ee787;
--color-prettylights-syntax-constant: #79c0ff; --color-prettylights-syntax-keyword: #ff7b72;
--color-prettylights-syntax-entity: #d2a8ff; --color-prettylights-syntax-string: #a5d6ff;
--color-prettylights-syntax-storage-modifier-import: #c9d1d9; --color-prettylights-syntax-variable: #ffa657;
--color-prettylights-syntax-entity-tag: #7ee787; --color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
--color-prettylights-syntax-keyword: #ff7b72; --color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
--color-prettylights-syntax-string: #a5d6ff; --color-prettylights-syntax-invalid-illegal-bg: #8e1519;
--color-prettylights-syntax-variable: #ffa657; --color-prettylights-syntax-carriage-return-text: #f0f6fc;
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149; --color-prettylights-syntax-carriage-return-bg: #b62324;
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc; --color-prettylights-syntax-string-regexp: #7ee787;
--color-prettylights-syntax-invalid-illegal-bg: #8e1519; --color-prettylights-syntax-markup-list: #f2cc60;
--color-prettylights-syntax-carriage-return-text: #f0f6fc; --color-prettylights-syntax-markup-heading: #1f6feb;
--color-prettylights-syntax-carriage-return-bg: #b62324; --color-prettylights-syntax-markup-italic: #c9d1d9;
--color-prettylights-syntax-string-regexp: #7ee787; --color-prettylights-syntax-markup-bold: #c9d1d9;
--color-prettylights-syntax-markup-list: #f2cc60; --color-prettylights-syntax-markup-deleted-text: #ffdcd7;
--color-prettylights-syntax-markup-heading: #1f6feb; --color-prettylights-syntax-markup-deleted-bg: #67060c;
--color-prettylights-syntax-markup-italic: #c9d1d9; --color-prettylights-syntax-markup-inserted-text: #aff5b4;
--color-prettylights-syntax-markup-bold: #c9d1d9; --color-prettylights-syntax-markup-inserted-bg: #033a16;
--color-prettylights-syntax-markup-deleted-text: #ffdcd7; --color-prettylights-syntax-markup-changed-text: #ffdfb6;
--color-prettylights-syntax-markup-deleted-bg: #67060c; --color-prettylights-syntax-markup-changed-bg: #5a1e02;
--color-prettylights-syntax-markup-inserted-text: #aff5b4; --color-prettylights-syntax-markup-ignored-text: #c9d1d9;
--color-prettylights-syntax-markup-inserted-bg: #033a16; --color-prettylights-syntax-markup-ignored-bg: #1158c7;
--color-prettylights-syntax-markup-changed-text: #ffdfb6; --color-prettylights-syntax-meta-diff-range: #d2a8ff;
--color-prettylights-syntax-markup-changed-bg: #5a1e02; --color-prettylights-syntax-brackethighlighter-angle: #8b949e;
--color-prettylights-syntax-markup-ignored-text: #c9d1d9; --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
--color-prettylights-syntax-markup-ignored-bg: #1158c7; --color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
--color-prettylights-syntax-meta-diff-range: #d2a8ff; --color-fg-default: hsl(var(--bc) / 1);
--color-prettylights-syntax-brackethighlighter-angle: #8b949e; --color-fg-muted: #848d97;
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; --color-fg-subtle: #6e7681;
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff; --color-canvas-default: hsl(var(--a1) / 1);
--color-fg-default: #e6edf3; --color-canvas-subtle: hsl(var(--a1) / 1);
--color-fg-muted: #848d97; --color-border-default: #30363d;
--color-fg-subtle: #6e7681; --color-border-muted: #21262d;
--color-canvas-default: #0d1117; --color-neutral-muted: rgba(110, 118, 129, 0.4);
--color-canvas-subtle: #161b22; --color-accent-fg: #2f81f7;
--color-border-default: #30363d; --color-accent-emphasis: #1f6feb;
--color-border-muted: #21262d; --color-success-fg: #3fb950;
--color-neutral-muted: rgba(110, 118, 129, 0.4); --color-success-emphasis: #238636;
--color-accent-fg: #2f81f7; --color-attention-fg: #d29922;
--color-accent-emphasis: #1f6feb; --color-attention-emphasis: #9e6a03;
--color-success-fg: #3fb950; --color-attention-subtle: rgba(187, 128, 9, 0.15);
--color-success-emphasis: #238636; --color-danger-fg: #f85149;
--color-attention-fg: #d29922; --color-danger-emphasis: #da3633;
--color-attention-emphasis: #9e6a03; --color-done-fg: #a371f7;
--color-attention-subtle: rgba(187, 128, 9, 0.15); --color-done-emphasis: #8957e5;
--color-danger-fg: #f85149;
--color-danger-emphasis: #da3633;
--color-done-fg: #a371f7;
--color-done-emphasis: #8957e5;
}
} }
@media (prefers-color-scheme: light) {
.markdown-body,
[data-theme="light"] {
/*light*/
color-scheme: light;
--color-prettylights-syntax-comment: #57606a;
--color-prettylights-syntax-constant: #0550ae;
--color-prettylights-syntax-entity: #6639ba;
--color-prettylights-syntax-storage-modifier-import: #24292f;
--color-prettylights-syntax-entity-tag: #116329;
--color-prettylights-syntax-keyword: #cf222e;
--color-prettylights-syntax-string: #0a3069;
--color-prettylights-syntax-variable: #953800;
--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
--color-prettylights-syntax-invalid-illegal-bg: #82071e;
--color-prettylights-syntax-carriage-return-text: #f6f8fa;
--color-prettylights-syntax-carriage-return-bg: #cf222e;
--color-prettylights-syntax-string-regexp: #116329;
--color-prettylights-syntax-markup-list: #3b2300;
--color-prettylights-syntax-markup-heading: #0550ae;
--color-prettylights-syntax-markup-italic: #24292f;
--color-prettylights-syntax-markup-bold: #24292f;
--color-prettylights-syntax-markup-deleted-text: #82071e;
--color-prettylights-syntax-markup-deleted-bg: #ffebe9;
--color-prettylights-syntax-markup-inserted-text: #116329;
--color-prettylights-syntax-markup-inserted-bg: #dafbe1;
--color-prettylights-syntax-markup-changed-text: #953800;
--color-prettylights-syntax-markup-changed-bg: #ffd8b5;
--color-prettylights-syntax-markup-ignored-text: #eaeef2;
--color-prettylights-syntax-markup-ignored-bg: #0550ae;
--color-prettylights-syntax-meta-diff-range: #8250df;
--color-prettylights-syntax-brackethighlighter-angle: #57606a;
--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
--color-prettylights-syntax-constant-other-reference-link: #0a3069;
--color-fg-default: #1F2328;
--color-fg-muted: #656d76;
--color-fg-subtle: #6e7781;
--color-canvas-default: #ffffff;
--color-canvas-subtle: #f6f8fa;
--color-border-default: #d0d7de;
--color-border-muted: hsla(210, 18%, 87%, 1);
--color-neutral-muted: rgba(175, 184, 193, 0.2);
--color-accent-fg: #0969da;
--color-accent-emphasis: #0969da;
--color-success-fg: #1a7f37;
--color-success-emphasis: #1f883d;
--color-attention-fg: #9a6700;
--color-attention-emphasis: #9a6700;
--color-attention-subtle: #fff8c5;
--color-danger-fg: #d1242f;
--color-danger-emphasis: #cf222e;
--color-done-fg: #8250df;
--color-done-emphasis: #8250df;
}
}
.markdown-body { .markdown-body {
-ms-text-size-adjust: 100%; -ms-text-size-adjust: 100%;

View File

@@ -1,53 +1,54 @@
export interface IPersonConfig { export interface IPersonConfig {
id: number; id: number
uid: string; uid: string
name: string; uuid: string
department: string; name: string
identity: string; department: string
avatar: string; identity: string
isWin: boolean; avatar: string
x: number; isWin: boolean
x: number
y: number y: number
createTime: string; createTime: string
updateTime: string; updateTime: string
prizeName: string[]; prizeName: string[]
prizeId: string[]; prizeId: string[]
prizeTime: string[]; prizeTime: string[]
} }
export interface Separate { export interface Separate {
id: string id: string
count: number count: number
isUsedCount: number isUsedCount: number
} }
export interface IPrizeConfig { export interface IPrizeConfig {
id: number | string id: number | string
name: string
sort: number
isAll: boolean
count: number
isUsedCount: number
picture: {
id: string | number
name: string name: string
url: string sort: number
} isAll: boolean
separateCount: { count: number
enable: boolean isUsedCount: number
countList: Separate[] picture: {
} id: string | number
desc: string name: string
isShow: boolean url: string
isUsed: boolean }
frequency: number separateCount: {
enable: boolean
countList: Separate[]
}
desc: string
isShow: boolean
isUsed: boolean
frequency: number
} }
export interface IMusic { export interface IMusic {
id: string id: string
name: string name: string
url: string url: string
} }
export interface IImage { export interface IImage {
id: string id: string
name: string name: string
url: string url: string
} }

108
src/utils/dexie/index.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { EntityTable } from 'dexie'
import type { DbData } from './type'
import dayjs from 'dayjs'
import Dexie from 'dexie'
class IndexDb {
name: string
dbStore: any
version: number
dbKeys: string[]
tableNames: string[]
constructor(name: string, tableNames: string[], version = 1, dbKeys: string[] = []) {
this.name = name // 数据库名称
this.version = version // 数据库版本号
this.dbKeys = dbKeys // 数据库key
this.tableNames = tableNames
this.dbStore = new Dexie(name) as Dexie & { [key: string]: EntityTable<DbData, 'id'> }
// 获取存在的key
const stores: Record<string, string> = {}
for (const tableName of tableNames) {
stores[tableName] = 'id,dateTime,type,uid' // 根据需要调整字段
}
this.dbStore.version(this.version).stores(stores)
}
setAllData(tableName: string, data: DbData[]) {
this.dbStore[tableName].bulkAdd(data)
}
/**
* @param data
* @description 添加单条数据并为数据添加dataTime和type属性
*/
setData(tableName: string, data: Partial<DbData>) {
if (!data.dateTime) {
data.dateTime = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')
}
if (!data.type) {
data.type = 'info'
}
this.dbStore[tableName].add(data)
}
// 更新单条数据
updateData(tableName: string, data: Partial<DbData>) {
this.dbStore[tableName].update(data.id, data)
}
/**
* @returns 所有数据Array
* @description 删除所有数据并返回被删除的数据
*/
deleteAll(tableName: string) {
return this.dbStore[tableName].clear()
}
/**
* @param data
* @description 删除单条数据
*/
deleteData(tableName: string, data: Partial<DbData>) {
this.dbStore[tableName].delete(data.id)
}
/**
* @returns 所有数据Array
* @description 获取所有数据
*/
async getAllData(tableName: string, isAsc: boolean = true) {
const allData = await this.dbStore[tableName].toArray()
// return allData
return isAsc ? allData : allData.reverse()
}
// 分页获取数据
async getPageData(tableName: string, pageNum: number, pageSize: number, isAsc: boolean = true) {
const allData = await this.dbStore[tableName].toArray()
const start = (pageNum - 1) * pageSize
const end = pageNum * pageSize
return isAsc ? allData.slice(start, end) : allData.slice(end, start).reverse()
}
/**
* @returns 数据库总长度
* @description 获取所有数据的列表长度
*/
getAllLength(tableName: string) {
return this.dbStore[tableName].count()
}
/**
*
* @param filter 根据筛选条件返回数据
* @returns
*/
getFilterData(tableName: string, filter: string) {
return this.dbStore[tableName].filter((item: any) => {
return item.content.includes(filter)
}).toArray()
}
getKeys(tableName: string, key: string) {
// keys 方法获取所有主键
return this.dbStore[tableName].orderBy(key).keys()
}
}
export { IndexDb }

3
src/utils/dexie/type.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface DbData {
[key: string]: any
}

View File

@@ -1,50 +1,57 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
// 筛选人员数据 import { v4 as uuidv4 } from 'uuid'
/**
* @description: 处理表格数据添加x,y,id等信息
* @param tableData 表格数据
* @param localRowCount 每一行有多少个元素
* @returns 处理后的表格数据
*/
export function filterData(tableData: any[], localRowCount: number) { export function filterData(tableData: any[], localRowCount: number) {
const dataLength = tableData.length const dataLength = tableData.length
let j = 0 let j = 0
for (let i = 0; i < dataLength; i++) { for (let i = 0; i < dataLength; i++) {
if (i % localRowCount === 0) { if (i % localRowCount === 0) {
j++ j++
}
tableData[i].x = i % localRowCount + 1
tableData[i].y = j
tableData[i].id = i
// 是否中奖
} }
tableData[i].x = i % localRowCount + 1
tableData[i].y = j
tableData[i].id = i
// 是否中奖
}
return tableData return tableData
} }
export function addOtherInfo(personList: any[]) { export function addOtherInfo(personList: any[]) {
const len = personList.length const len = personList.length
for (let i = 0; i < len; i++) { for (let i = 0; i < len; i++) {
personList[i].id = i personList[i].id = i
personList[i].createTime = dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss') personList[i].createTime = dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss')
personList[i].updateTime = dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss') personList[i].updateTime = dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss')
personList[i].prizeName = [] as string[] personList[i].prizeName = [] as string[]
personList[i].prizeTime = [] as string[] personList[i].prizeTime = [] as string[]
personList[i].prizeId = [] personList[i].prizeId = []
personList[i].isWin = false personList[i].isWin = false
} personList[i].uuid = uuidv4()
}
return personList return personList
} }
export function selectCard(cardIndexArr: number[], tableLength: number, personId: number): number { export function selectCard(cardIndexArr: number[], tableLength: number, personId: number): number {
const cardIndex = Math.floor(Math.random() * (tableLength - 1)) const cardIndex = Math.floor(Math.random() * (tableLength - 1))
if (cardIndexArr.includes(cardIndex)) { if (cardIndexArr.includes(cardIndex)) {
return selectCard(cardIndexArr, tableLength, personId) return selectCard(cardIndexArr, tableLength, personId)
} }
return cardIndex return cardIndex
} }
export function themeChange(theme: string) { export function themeChange(theme: string) {
// 获取根html // 获取根html
const html = document.querySelectorAll('html') const html = document.querySelectorAll('html')
if (html) { if (html) {
html[0].setAttribute('data-theme', theme) html[0].setAttribute('data-theme', theme)
localStorage.setItem('theme', theme) localStorage.setItem('theme', theme)
} }
} }

View File

@@ -0,0 +1,22 @@
import localforage from 'localforage'
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
async function clearImageDbStore() {
await imageDbStore.clear()
}
async function clearAudioDbStore() {
await audioDbStore.clear()
}
export function clearAllDbStore() {
clearImageDbStore()
clearAudioDbStore()
}

View File

@@ -1,362 +0,0 @@
<script setup lang='ts'>
import i18n, { languageList } from '@/locales/i18n'
import useStore from '@/store'
import { themeChange } from '@/utils'
import { isHex, isRgbOrRgba } from '@/utils/color'
import daisyuiThemes from 'daisyui/src/theming/themes'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { ColorPicker } from 'vue3-colorpicker'
import { useI18n } from 'vue-i18n'
import zod from 'zod'
import PatternSetting from './components/PatternSetting.vue'
import 'vue3-colorpicker/style.css'
const { t } = useI18n()
const globalConfig = useStore().globalConfig
const personConfig = useStore().personConfig
const prizeConfig = useStore().prizeConfig
const { getTopTitle: topTitle, getTheme: localTheme, getPatterColor: patternColor, getPatternList: patternList, getCardColor: cardColor, getLuckyColor: luckyCardColor, getTextColor: textColor, getCardSize: cardSize, getTextSize: textSize, getRowCount: rowCount, getIsShowPrizeList: isShowPrizeList, getLanguage: userLanguage, getBackground: backgroundImage, getImageList: imageList, getIsShowAvatar: isShowAvatar
} = storeToRefs(globalConfig)
const { getAlreadyPersonList: alreadyPersonList, getNotPersonList: notPersonList } = storeToRefs(personConfig)
const colorPickerRef = ref()
const resetDataDialogRef = ref()
interface ThemeDaType {
[key: string]: any
}
const isRowCountChange = ref(0) // 0未改变1改变,2加载中
const themeValue = ref(localTheme.value.name)
const topTitleValue = ref(structuredClone(topTitle.value))
const cardColorValue = ref(structuredClone(cardColor.value))
const luckyCardColorValue = ref(structuredClone(luckyCardColor.value))
const textColorValue = ref(structuredClone(textColor.value))
const cardSizeValue = ref(structuredClone(cardSize.value))
const textSizeValue = ref(structuredClone(textSize.value))
const rowCountValue = ref(structuredClone(rowCount.value))
const languageValue = ref(structuredClone(userLanguage.value))
const isShowPrizeListValue = ref(structuredClone(isShowPrizeList.value))
const isShowAvatarValue = ref(structuredClone(isShowAvatar.value))
const patternColorValue = ref(structuredClone(patternColor.value))
const themeList = ref(Object.keys(daisyuiThemes))
const daisyuiThemeList = ref<ThemeDaType>(daisyuiThemes)
const backgroundImageValue = ref(backgroundImage.value)
const formData = ref({
rowCount: rowCountValue,
})
const formErr = ref({
rowCount: '',
})
const schema = zod.object({
rowCount: zod.number({
required_error: i18n.global.t('error.require'),
invalid_type_error: i18n.global.t('error.requireNumber'),
})
.min(1, i18n.global.t('error.minNumber1'))
.max(100, i18n.global.t('error.maxNumber100')),
// 格式化
})
type ValidatePayload = zod.infer<typeof schema>
const payload: ValidatePayload = {
rowCount: formData.value.rowCount,
}
function parseSchema(props: ValidatePayload) {
return schema.parseAsync(props)
}
function resetPersonLayout() {
isRowCountChange.value = 2
setTimeout(() => {
const alreadyLen = alreadyPersonList.value.length
const notLen = notPersonList.value.length
if (alreadyLen <= 0 && notLen <= 0) {
return
}
const allPersonList = alreadyPersonList.value.concat(notPersonList.value)
const newAlreadyPersonList = allPersonList.slice(0, alreadyLen)
const newNotPersonList = allPersonList.slice(alreadyLen, notLen + alreadyLen)
personConfig.deleteAllPerson()
personConfig.addNotPersonList(newNotPersonList)
personConfig.addAlreadyPersonList(newAlreadyPersonList, null)
isRowCountChange.value = 0
}, 1000)
}
function clearPattern() {
globalConfig.setPatternList([] as number[])
}
function resetPattern() {
globalConfig.resetPatternList()
}
function resetData() {
globalConfig.reset()
personConfig.reset()
prizeConfig.resetDefault()
// 刷新页面
window.location.reload()
}
// const handleChangeShowFields = (fieldItem: any) => {
// formData.value.showField.map((item) => {
// if (item.label === fieldItem.label) {
// item.value = !item.value
// }
// })
// }
watch(() => formData.value.rowCount, () => {
payload.rowCount = formData.value.rowCount
parseSchema(payload).then((res) => {
if (res.rowCount) {
isRowCountChange.value = 1
globalConfig.setRowCount(res.rowCount)
}
}).catch((err) => {
formErr.value.rowCount = err.issues[0].message
})
})
watch(topTitleValue, (val) => {
globalConfig.setTopTitle(val)
})
watch(themeValue, (val: any) => {
const selectedThemeDetail = daisyuiThemeList.value[val]
globalConfig.setTheme({ name: val, detail: selectedThemeDetail })
themeChange(val)
if (selectedThemeDetail.primary && (isHex(selectedThemeDetail.primary) || isRgbOrRgba(selectedThemeDetail.primary))) {
globalConfig.setCardColor(selectedThemeDetail.primary)
}
}, { deep: true })
watch(cardColorValue, (val: string) => {
globalConfig.setCardColor(val)
}, { deep: true })
watch(luckyCardColorValue, (val: string) => {
globalConfig.setLuckyCardColor(val)
}, { deep: true })
watch(patternColorValue, (val: string) => {
globalConfig.setPatterColor(val)
})
watch(textColorValue, (val: string) => {
globalConfig.setTextColor(val)
}, { deep: true })
watch(cardSizeValue, (val: { width: number, height: number }) => {
globalConfig.setCardSize(val)
}, { deep: true })
watch(isShowPrizeListValue, () => {
globalConfig.setIsShowPrizeList(isShowPrizeListValue.value)
})
watch(backgroundImageValue, (val) => {
globalConfig.setBackground(val)
})
watch(languageValue, (val: string) => {
globalConfig.setLanguage(val)
})
watch(isShowAvatarValue, () => {
globalConfig.setIsShowAvatar(isShowAvatarValue.value)
})
onMounted(() => {
})
</script>
<template>
<dialog id="my_modal_1" ref="resetDataDialogRef" class="border-none modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
{{ t('dialog.titleTip') }}
</h3>
<p class="py-4">
{{ t('dialog.dialogResetAllData') }}
</p>
<div class="modal-action">
<form method="dialog" class="flex gap-3">
<!-- if there is a button in form, it will close the modal -->
<button class="btn" @click="resetDataDialogRef.close()">
{{ t(`button.cancel`) }}
</button>
<button class="btn" @click="resetData">
{{ t('button.confirm') }}
</button>
</form>
</div>
</div>
</dialog>
<div>
<h2>{{ t('viewTitle.globalSetting') }}</h2>
<div class="mb-8">
<button class="btn btn-sm btn-primary" @click="resetDataDialogRef.showModal()">
{{ t('button.resetAllData') }}
</button>
</div>
<label class="flex flex-row items-center w-full gap-24 mb-10 form-control">
<div class="">
<div class="label">
<span class="label-text">{{ t('table.title') }}</span>
</div>
<input
v-model="topTitleValue" type="text" :placeholder="t('placeHolder.enterTitle')"
class="w-full max-w-xs input input-bordered"
>
</div>
</label>
<label class="flex flex-row items-center w-full gap-24 mb-10 form-control">
<div class="">
<div class="label">
<span class="label-text">{{ t('table.columnNumber') }}</span>
</div>
<input
v-model="formData.rowCount" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
<div class="help">
<span v-if="formErr.rowCount" class="text-sm text-red-400 help-text">
{{ formErr.rowCount }}
</span>
</div>
</div>
<div>
<div class="tooltip" :data-tip="t('tooltip.resetLayout')">
<button class="mt-5 btn btn-info btn-sm" :disabled="isRowCountChange !== 1" @click="resetPersonLayout">
<span>{{ t('button.setLayout') }}</span>
<span v-show="isRowCountChange === 2" class="loading loading-ring loading-md" />
</button>
</div>
</div>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.language') }}</span>
</div>
<select v-model="languageValue" data-choose-theme class="w-full max-w-xs border-solid select border-1">
<option disabled selected>{{ t('table.language') }}</option>
<option v-for="item in languageList" :key="item.key" :value="item.key">{{ item.name }}</option>
</select>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.theme') }}</span>
</div>
<select v-model="themeValue" data-choose-theme class="w-full max-w-xs border-solid select border-1">
<option disabled selected>{{ t('table.theme') }}</option>
<option v-for="(item, index) in themeList" :key="index" :value="item">{{ item }}</option>
</select>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.backgroundImage') }}</span>
</div>
<select
v-model="backgroundImageValue" data-choose-theme
class="w-full max-w-xs border-solid select border-1"
>
<option disabled selected>{{ t('table.backgroundImage') }}</option>
<option
v-for="(item, index) in [{ name: '❌', url: '', id: '' }, ...imageList]" :key="index"
:value="item"
>{{ item.name }}</option>
</select>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.cardColor') }}</span>
</div>
<ColorPicker ref="colorPickerRef" v-model="cardColorValue" v-model:pure-color="cardColorValue" />
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.winnerColor') }}</span>
</div>
<ColorPicker ref="colorPickerRef" v-model="luckyCardColorValue" v-model:pure-color="luckyCardColorValue" />
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.textColor') }}</span>
</div>
<ColorPicker ref="colorPickerRef" v-model="textColorValue" v-model:pure-color="textColorValue" />
</label>
<label class="flex flex-row w-full max-w-xs gap-10 mb-10 form-control">
<div>
<div class="label">
<span class="label-text">{{ t('table.cardWidth') }}</span>
</div>
<input
v-model="cardSizeValue.width" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</div>
<div>
<div class="label">
<span class="label-text">{{ t('table.cardHeight') }}</span>
</div>
<input
v-model="cardSizeValue.height" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</div>
</label>
<label class="w-full max-w-xs mb-10 form-control">
<div class="label">
<span class="label-text">{{ t('table.textSize') }}</span>
</div>
<input
v-model="textSizeValue" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.highlightColor') }}</span>
</div>
<ColorPicker ref="colorPickerRef" v-model="patternColorValue" v-model:pure-color="patternColorValue" />
</label>
<label class="flex flex-row items-center w-full gap-24 mb-0 form-control">
<div>
<div class="label">
<span class="label-text">{{ t('table.patternSetting') }}</span>
</div>
<div class="h-auto">
<PatternSetting
:row-count="rowCount" :card-color="cardColor" :pattern-color="patternColor"
:pattern-list="patternList"
/>
</div>
</div>
</label>
<div class="flex w-full h-24 gap-3 m-0">
<button class="mt-5 btn btn-info btn-sm" @click.stop="clearPattern">
<span>{{ t('button.clearPattern') }}</span>
</button>
<div class="tooltip" :data-tip="t('tooltip.defaultLayout')">
<button class="mt-5 btn btn-info btn-sm" @click="resetPattern">
<span>{{ t('button.DefaultPattern') }}</span>
</button>
</div>
</div>
<label class="w-full max-w-xs mb-10 form-control">
<div class="label">
<span class="label-text">{{ t('table.alwaysDisplay') }}</span>
</div>
<input
type="checkbox" :checked="isShowPrizeListValue" class="mt-2 border-solid checkbox checkbox-secondary border-1"
@change="isShowPrizeListValue = !isShowPrizeListValue"
>
</label>
<label class="w-full max-w-xs mb-10 form-control">
<div class="label">
<span class="label-text">{{ t('table.avatarDisplay') }}</span>
</div>
<input type="checkbox" :checked="isShowAvatarValue" @change="isShowAvatarValue = !isShowAvatarValue"
class="mt-2 border-solid checkbox checkbox-secondary border-1" />
</label>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -0,0 +1,405 @@
<script setup lang='ts'>
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { ColorPicker } from 'vue3-colorpicker'
import { useI18n } from 'vue-i18n'
import { z as zod } from 'zod'
import { daisyuiThemes } from '@/constant/theme'
import i18n, { languageList } from '@/locales/i18n'
import useStore from '@/store'
import { themeChange } from '@/utils'
import { clearAllDbStore } from '@/utils/localforage'
import PatternSetting from './components/PatternSetting.vue'
import 'vue3-colorpicker/style.css'
const { t } = useI18n()
const globalConfig = useStore().globalConfig
const personConfig = useStore().personConfig
const prizeConfig = useStore().prizeConfig
const { getTopTitle: topTitle, getTheme: localTheme, getPatterColor: patternColor, getPatternList: patternList, getCardColor: cardColor, getLuckyColor: luckyCardColor, getTextColor: textColor, getCardSize: cardSize, getTextSize: textSize, getRowCount: rowCount, getIsShowPrizeList: isShowPrizeList, getLanguage: userLanguage, getBackground: backgroundImage, getImageList: imageList, getIsShowAvatar: isShowAvatar,
} = storeToRefs(globalConfig)
const { getAlreadyPersonList: alreadyPersonList, getNotPersonList: notPersonList } = storeToRefs(personConfig)
const colorPickerRef = ref()
const resetDataDialogRef = ref()
interface ThemeDaType {
[key: string]: any
}
const isRowCountChange = ref(0) // 0未改变1改变,2加载中
const themeValue = ref(localTheme.value.name)
const topTitleValue = ref(structuredClone(topTitle.value))
const cardColorValue = ref(structuredClone(cardColor.value))
const luckyCardColorValue = ref(structuredClone(luckyCardColor.value))
const textColorValue = ref(structuredClone(textColor.value))
const cardSizeValue = ref(structuredClone(cardSize.value))
const textSizeValue = ref(structuredClone(textSize.value))
const rowCountValue = ref(structuredClone(rowCount.value))
const languageValue = ref(structuredClone(userLanguage.value))
const isShowPrizeListValue = ref(structuredClone(isShowPrizeList.value))
const isShowAvatarValue = ref(structuredClone(isShowAvatar.value))
const patternColorValue = ref(structuredClone(patternColor.value))
const themeList = ref(daisyuiThemes)
const daisyuiThemeList = ref<ThemeDaType>(daisyuiThemes)
const backgroundImageValue = ref(backgroundImage.value)
const formData = ref({
rowCount: rowCountValue,
})
const formErr = ref({
rowCount: '',
})
const schema = zod.object({
rowCount: zod.number({
error: i18n.global.t('error.require'),
// required_error: i18n.global.t('error.require'),
// invalid_type_error: i18n.global.t('error.requireNumber'),
})
.min(1, i18n.global.t('error.minNumber1'))
.max(100, i18n.global.t('error.maxNumber100')),
// 格式化
})
type ValidatePayload = zod.infer<typeof schema>
const payload: ValidatePayload = {
rowCount: formData.value.rowCount,
}
function parseSchema(props: ValidatePayload) {
return schema.parseAsync(props)
}
function resetPersonLayout() {
isRowCountChange.value = 2
setTimeout(() => {
const alreadyLen = alreadyPersonList.value.length
const notLen = notPersonList.value.length
if (alreadyLen <= 0 && notLen <= 0) {
return
}
const allPersonList = alreadyPersonList.value.concat(notPersonList.value)
const newAlreadyPersonList = allPersonList.slice(0, alreadyLen)
const newNotPersonList = allPersonList.slice(alreadyLen, notLen + alreadyLen)
personConfig.deleteAllPerson()
personConfig.addNotPersonList(newNotPersonList)
personConfig.addAlreadyPersonList(newAlreadyPersonList, null)
isRowCountChange.value = 0
}, 1000)
}
function clearPattern() {
globalConfig.setPatternList([] as number[])
}
function resetPattern() {
globalConfig.resetPatternList()
}
function resetData() {
globalConfig.reset()
personConfig.reset()
prizeConfig.resetDefault()
// 删除所有indexDb
clearAllDbStore()
// 刷新页面
window.location.reload()
}
watch(() => formData.value.rowCount, () => {
payload.rowCount = formData.value.rowCount
parseSchema(payload).then((res) => {
if (res.rowCount) {
isRowCountChange.value = 1
globalConfig.setRowCount(res.rowCount)
}
}).catch((err) => {
formErr.value.rowCount = err.issues[0].message
})
})
watch(topTitleValue, (val) => {
globalConfig.setTopTitle(val)
})
watch(themeValue, (val: any) => {
const selectedThemeDetail = daisyuiThemeList.value[val]
globalConfig.setTheme({ name: val, detail: selectedThemeDetail })
themeChange(val)
// if (selectedThemeDetail.primary && (isHex(selectedThemeDetail.primary) || isRgbOrRgba(selectedThemeDetail.primary))) {
// globalConfig.setCardColor(selectedThemeDetail.primary)
// }
}, { deep: true })
watch(cardColorValue, (val: string) => {
globalConfig.setCardColor(val)
}, { deep: true })
watch(luckyCardColorValue, (val: string) => {
globalConfig.setLuckyCardColor(val)
}, { deep: true })
watch(patternColorValue, (val: string) => {
globalConfig.setPatterColor(val)
})
watch(textColorValue, (val: string) => {
globalConfig.setTextColor(val)
}, { deep: true })
watch(cardSizeValue, (val: { width: number, height: number }) => {
globalConfig.setCardSize(val)
}, { deep: true })
watch(isShowPrizeListValue, () => {
globalConfig.setIsShowPrizeList(isShowPrizeListValue.value)
})
watch(backgroundImageValue, (val) => {
globalConfig.setBackground(val)
})
watch(languageValue, (val: string) => {
globalConfig.setLanguage(val)
})
watch(isShowAvatarValue, () => {
globalConfig.setIsShowAvatar(isShowAvatarValue.value)
})
onMounted(() => {
})
</script>
<template>
<dialog id="my_modal_1" ref="resetDataDialogRef" class="border-none modal">
<div class="modal-box">
<h3 class="text-lg font-bold">
{{ t('dialog.titleTip') }}
</h3>
<p class="py-4">
{{ t('dialog.dialogResetAllData') }}
</p>
<div class="modal-action">
<form method="dialog" class="flex gap-3">
<!-- if there is a button in form, it will close the modal -->
<button class="btn" @click="resetDataDialogRef.close()">
{{ t(`button.cancel`) }}
</button>
<button class="btn" @click="resetData">
{{ t('button.confirm') }}
</button>
</form>
</div>
</div>
</dialog>
<div class="flex flex-col gap-4">
<h2>{{ t('viewTitle.globalSetting') }}</h2>
<div class="mb-8">
<button class="btn btn-sm btn-primary" @click="resetDataDialogRef.showModal()">
{{ t('button.resetAllData') }}
</button>
</div>
<div class="flex flex-wrap w-full gap-6">
<!-- 文本设置主标题语言文字大小 -->
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs">
<legend class="fieldset-legend">
文本设置
</legend>
<label class="label">
<div class="label">
<span class="label-text">{{ t('table.title') }}</span>
</div>
</label>
<input
v-model="topTitleValue" type="text" :placeholder="t('placeHolder.enterTitle')"
class="w-full max-w-xs input input-bordered"
>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.language') }}</span>
</div>
<select v-model="languageValue" data-choose-theme class="w-full max-w-xs border-solid select border-1">
<option disabled selected>{{ t('table.language') }}</option>
<option v-for="item in languageList" :key="item.key" :value="item.key">{{ item.name }}</option>
</select>
</label>
<label class="w-full max-w-xs form-control">
<div class="label">
<span class="label-text">{{ t('table.textSize') }}</span>
</div>
<input
v-model="textSizeValue" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</label>
</fieldset>
<!-- 布局设置列数卡片宽度卡片高度 -->
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs">
<legend class="fieldset-legend">
布局设置
</legend>
<label class="flex flex-row items-center form-control">
<div class="">
<div class="label">
<span class="label-text">{{ t('table.columnNumber') }}</span>
<div class="help">
<span v-if="formErr.rowCount" class="text-xs text-red-400 help-text">
{{ formErr.rowCount }}
</span>
</div>
</div>
</div>
</label>
<div class="join">
<input
v-model="formData.rowCount" type="number" placeholder="Type here"
class="w-full input input-bordered join-item"
>
<div class="tooltip join-item" :data-tip="t('tooltip.resetLayout')">
<button class="btn btn-neutral w-[120px] join-item" :disabled="isRowCountChange !== 1" @click="resetPersonLayout">
<span>{{ t('button.setLayout') }}</span>
<span v-show="isRowCountChange === 2" class="loading loading-ring loading-md" />
</button>
</div>
</div>
<label class="flex flex-row w-full max-w-xs gap-10 form-control">
<div>
<div class="label">
<span class="label-text">{{ t('table.cardWidth') }}</span>
</div>
<input
v-model="cardSizeValue.width" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</div>
<div>
<div class="label">
<span class="label-text">{{ t('table.cardHeight') }}</span>
</div>
<input
v-model="cardSizeValue.height" type="number" placeholder="Type here"
class="w-full max-w-xs input input-bordered"
>
</div>
</label>
</fieldset>
<!-- 主题设置主题背景图片 -->
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs">
<legend class="fieldset-legend">
主题设置
</legend>
<div class="w-full max-w-xs form-control">
<label class="label">
<span class="label-text">{{ t('table.theme') }}</span>
</label>
<select v-model="themeValue" data-choose-theme class="w-full max-w-xs border-solid select border-1">
<option disabled selected>
{{ t('table.theme') }}
</option>
<option v-for="(item, index) in themeList" :key="index" :value="item">
{{ item }}
</option>
</select>
</div>
<div class="w-full max-w-xs form-control">
<label class="label">
<span class="label-text">{{ t('table.backgroundImage') }}</span>
</label>
<select
v-model="backgroundImageValue" data-choose-theme
class="box-border w-full max-w-xs truncate border-solid select border-1"
>
<option disabled selected class="w-full truncate">
{{ t('table.backgroundImage') }}
</option>
<option
v-for="(item, index) in [{ name: '❌', url: '', id: '' }, ...imageList]" :key="index"
:value="item"
:title="item.name"
class="box-border w-full truncate"
>
<span class="truncate w-option-xs">{{ item.name }}</span>
</option>
</select>
<span class="label">请先前往图片管理上传图片</span>
</div>
<div class="grid w-full grid-cols-2 gap-4">
<div class="flex flex-col items-center max-w-xs gap-1 form-control">
<label class="label">
<span class="label-text">{{ t('table.cardColor') }}</span>
</label>
<ColorPicker ref="colorPickerRef" v-model="cardColorValue" v-model:pure-color="cardColorValue" />
</div>
<div class="flex flex-col items-center max-w-xs gap-1 form-control">
<label class="label">
<span class="label-text">{{ t('table.winnerColor') }}</span>
</label>
<ColorPicker ref="colorPickerRef" v-model="luckyCardColorValue" v-model:pure-color="luckyCardColorValue" />
</div>
<div class="flex flex-col items-center max-w-xs gap-1 form-control">
<label class="label">
<span class="label-text">{{ t('table.textColor') }}</span>
</label>
<ColorPicker ref="colorPickerRef" v-model="textColorValue" v-model:pure-color="textColorValue" />
</div>
<div class="flex flex-col items-center max-w-xs gap-1 form-control">
<label class="label">
<span class="label-text">{{ t('table.highlightColor') }}</span>
</label>
<ColorPicker ref="colorPickerRef" v-model="patternColorValue" v-model:pure-color="patternColorValue" />
</div>
</div>
</fieldset>
<!-- 图案设置 -->
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs">
<legend class="fieldset-legend">
图案设置
</legend>
<div class="items-center gap-24 mb-0 form-control">
<div>
<label class="label">
<span class="label-text">{{ t('table.patternSetting') }}</span>
</label>
<div class="h-auto">
<PatternSetting
:row-count="rowCount" :card-color="cardColor" :pattern-color="patternColor"
:pattern-list="patternList"
/>
</div>
</div>
<div class="flex w-full gap-3 m-0">
<button class="mt-5 btn btn-info btn-sm" @click.stop="clearPattern">
<span>{{ t('button.clearPattern') }}</span>
</button>
<div class="tooltip" :data-tip="t('tooltip.defaultLayout')">
<button class="mt-5 btn btn-info btn-sm" @click="resetPattern">
<span>{{ t('button.DefaultPattern') }}</span>
</button>
</div>
</div>
</div>
</fieldset>
<!-- 其他设置是否常显奖项列表是否显示头像 -->
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs">
<legend class="fieldset-legend">
其他设置
</legend>
<div class="flex items-center justify-between w-full max-w-xs gap-2 mb-3 form-control">
<div class="label">
<span class="label-text">{{ t('table.alwaysDisplay') }}</span>
</div>
<input
type="checkbox" :checked="isShowPrizeListValue" class="border-solid checkbox checkbox-secondary border-1"
@change="isShowPrizeListValue = !isShowPrizeListValue"
>
</div>
<div class="flex items-center justify-between w-full max-w-xs gap-2 mb-3 form-control">
<div class="label">
<span class="label-text">{{ t('table.avatarDisplay') }}</span>
</div>
<input
type="checkbox" :checked="isShowAvatarValue" class="border-solid checkbox checkbox-secondary border-1"
@change="isShowAvatarValue = !isShowAvatarValue"
>
</div>
</fieldset>
</div>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -1,118 +0,0 @@
<script setup lang='ts'>
import type { IImage } from '@/types/storeType'
import ImageSync from '@/components/ImageSync/index.vue'
import useStore from '@/store'
import { readFileData } from '@/utils/file'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const globalConfig = useStore().globalConfig
const { getImageList: localImageList } = storeToRefs(globalConfig)
const limitType = ref('image/*')
const imgUploadToast = ref(0) // 0是不显示1是成功2是失败,3是不是图片
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
async function handleFileChange(e: Event) {
const isImage = /image*/.test(((e.target as HTMLInputElement).files as FileList)[0].type)
if (!isImage) {
imgUploadToast.value = 3
return
}
const { dataUrl, fileName } = await readFileData(((e.target as HTMLInputElement).files as FileList)[0])
imageDbStore.setItem(`${new Date().getTime().toString()}+${fileName}`, dataUrl)
.then(() => {
imgUploadToast.value = 1
getImageDbStore()
})
.catch(() => {
imgUploadToast.value = 2
})
}
async function getImageDbStore() {
const keys = await imageDbStore.keys()
if (keys.length > 0) {
imageDbStore.iterate((value, key) => {
globalConfig.addImage({
id: key,
name: key,
url: 'Storage',
})
})
}
}
function removeImage(item: IImage) {
if (item.url === 'Storage') {
imageDbStore.removeItem(item.id).then(() => {
globalConfig.removeImage(item.id)
})
}
globalConfig.removeImage(item.id)
}
onMounted(() => {
// getImageDbStore()
})
watch(() => imgUploadToast.value, (val) => {
if (val !== 0) {
setTimeout(() => {
imgUploadToast.value = 0
}, 2000)
}
})
</script>
<template>
<div class="toast toast-top toast-end">
<div v-if="imgUploadToast === 2" class="alert alert-error">
<span>{{ t('error.uploadFail') }}</span>
</div>
<div v-if="imgUploadToast === 1" class="alert alert-success">
<span>{{ t('error.uploadSuccess') }}</span>
</div>
<div v-if="imgUploadToast === 3" class="alert alert-error">
<span>{{ t('error.notImage') }}</span>
</div>
</div>
<div>
<div class="">
<label for="explore">
<input
id="explore" type="file" class="" style="display: none" :accept="limitType"
@change="handleFileChange"
>
<span class="btn btn-primary btn-sm">{{ t('button.upload') }}</span>
</label>
</div>
<ul class="p-0">
<li v-for="item in localImageList" :key="item.id" class="mb-3">
<div class="flex items-center gap-8">
<div class="avatar h-14">
<div class="w-12 h-12 mask mask-squircle hover:w-14 hover:h-14">
<!-- <img v-if="item.url!=='Storage'" :src="item.url" alt="Avatar Tailwind CSS Component" /> -->
<ImageSync :img-item="item" />
</div>
</div>
<div class="w-64">
<div class="overflow-hidden font-bold whitespace-nowrap text-ellipsis">
{{ item.name }}
</div>
</div>
<div>
<button class="btn btn-error btn-xs" @click="removeImage(item)">
{{ t('button.delete') }}
</button>
</div>
</div>
</li>
</ul>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -0,0 +1,115 @@
<script setup lang='ts'>
import type { IFileData } from '@/components/FileUpload/type'
import localforage from 'localforage'
import { v4 as uuidv4 } from 'uuid'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CustomDialog from '@/components/Dialog/index.vue'
import FileUpload from '@/components/FileUpload/index.vue'
import useStore from '@/store'
const { t } = useI18n()
const limitType = ref('image/*')
const imgUploadToast = ref(0) // 0是不显示1是成功2是失败,3是不是图片
const visible = defineModel('visible', {
type: Boolean,
required: true,
})
const globalConfig = useStore().globalConfig
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
const imageData = ref<IFileData | null>(null)
const fileName = computed({
get() {
return imageData.value?.fileName || null
},
set(value) {
if (imageData.value && value) {
imageData.value.fileName = value
}
},
})
const uploadDialogRef = ref()
async function uploadFile(fileData: IFileData | null) {
if (!fileData) {
imageData.value = null
return
}
const isImage = /image*/.test(fileData?.type || '')
if (!isImage) {
imgUploadToast.value = 3
return
}
imageData.value = fileData
}
async function getImageDbStore() {
const keys = await imageDbStore.keys()
if (keys.length > 0) {
imageDbStore.iterate((value: { fileName: string, dataUrl: string }, key: string) => {
console.log(value, key)
globalConfig.addImage({
id: key,
name: value.fileName,
url: 'Storage',
})
})
}
}
function submitUpload() {
if (imageData.value) {
const { dataUrl, fileName } = imageData.value
const uniqueId = uuidv4()
imageDbStore.setItem(uniqueId, {
dataUrl,
fileName,
})
.then(() => {
imgUploadToast.value = 1
getImageDbStore()
})
.catch(() => {
imgUploadToast.value = 2
})
}
}
watch(visible, (newVal) => {
if (newVal) {
uploadDialogRef.value.showDialog()
}
})
</script>
<template>
<div class="toast toast-top toast-end">
<div v-if="imgUploadToast === 2" class="alert alert-error">
<span>{{ t('error.uploadFail') }}</span>
</div>
<div v-if="imgUploadToast === 1" class="alert alert-success">
<span>{{ t('error.uploadSuccess') }}</span>
</div>
<div v-if="imgUploadToast === 3" class="alert alert-error">
<span>{{ t('error.notImage') }}</span>
</div>
</div>
<CustomDialog
ref="uploadDialogRef"
v-model:visible="visible"
title="图片上传"
:submit-func="submitUpload"
class=""
>
<template #content>
<div class="flex flex-col items-center gap-6 w-full px-12">
<FileUpload v-if="visible" :limit-type="limitType" @upload-file="uploadFile" />
<input v-model="fileName" :disabled="imageData === null" type="text" placeholder="图片名称" class="input w-full">
</div>
</template>
</CustomDialog>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang='ts'>
import type { IImage } from '@/types/storeType'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ImageSync from '@/components/ImageSync/index.vue'
import PageHeader from '@/components/PageHeader/index.vue'
import useStore from '@/store'
import UploadDialog from './components/UploadDialog.vue'
const { t } = useI18n()
const globalConfig = useStore().globalConfig
const { getImageList: localImageList } = storeToRefs(globalConfig)
const imgUploadToast = ref(0) // 0是不显示1是成功2是失败,3是不是图片
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
const uploadVisible = ref(false)
function removeImage(item: IImage) {
if (item.url === 'Storage') {
imageDbStore.removeItem(item.id).then(() => {
globalConfig.removeImage(item.id)
})
}
globalConfig.removeImage(item.id)
}
watch(() => imgUploadToast.value, (val) => {
if (val !== 0) {
setTimeout(() => {
imgUploadToast.value = 0
}, 2000)
}
})
</script>
<template>
<UploadDialog v-model:visible="uploadVisible" />
<div>
<PageHeader title="图片管理">
<template #buttons>
<div class="">
<label for="explore">
<span class="btn btn-primary btn-sm" @click="uploadVisible = true">{{ t('button.upload') }}</span>
</label>
</div>
</template>
</PageHeader>
<ul class="p-0">
<li v-for="item in localImageList" :key="item.id" class="mb-3">
<div class="flex items-center gap-8">
<div class="avatar h-14">
<div class="w-12 h-12 mask mask-squircle hover:w-14 hover:h-14">
<ImageSync :img-item="item" />
</div>
</div>
<div class="w-64">
<div class="overflow-hidden font-bold whitespace-nowrap text-ellipsis">
{{ item.name }}
</div>
</div>
<div>
<button class="btn btn-error btn-xs" @click="removeImage(item)">
{{ t('button.delete') }}
</button>
</div>
</div>
</li>
</ul>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -1,113 +0,0 @@
<script setup lang='ts'>
import type { IMusic } from '@/types/storeType'
import useStore from '@/store'
import { readFileData } from '@/utils/file'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const audioUploadToast = ref(0) // 0是不显示1是成功2是失败,3是不是图片
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
const globalConfig = useStore().globalConfig
const { getMusicList: localMusicList } = storeToRefs(globalConfig)
const limitType = ref('audio/*')
const localMusicListValue = ref(localMusicList)
async function play(item: IMusic) {
globalConfig.setCurrentMusic(item, false)
}
function deleteMusic(item: IMusic) {
globalConfig.removeMusic(item.id)
audioDbStore.removeItem(item.name)
// setTimeout(()=>{
// localMusicListValue.value=localMusicList
// },100)
}
function resetMusic() {
globalConfig.resetMusicList()
audioDbStore.clear()
}
function deleteAll() {
globalConfig.clearMusicList()
audioDbStore.clear()
}
async function getMusicDbStore() {
const keys = await audioDbStore.keys()
if (keys.length > 0) {
audioDbStore.iterate((value: string, key: string) => {
globalConfig.addMusic({
id: key + new Date().getTime().toString(),
name: key,
url: 'Storage',
})
})
}
}
async function handleFileChange(e: Event) {
const isAudio = /audio*/.test(((e.target as HTMLInputElement).files as FileList)[0].type)
if (!isAudio) {
audioUploadToast.value = 3
return
}
const { dataUrl, fileName } = await readFileData(((e.target as HTMLInputElement).files as FileList)[0])
audioDbStore.setItem(`${new Date().getTime().toString()}+${fileName}`, dataUrl)
.then(() => {
audioUploadToast.value = 1
getMusicDbStore()
})
.catch(() => {
audioUploadToast.value = 2
})
}
onMounted(() => {
getMusicDbStore()
})
</script>
<template>
<div>
<div class="flex gap-3">
<button class="btn btn-primary btn-sm" @click="resetMusic">
{{ t('button.reset') }}
</button>
<label for="explore">
<input
id="explore" type="file" class="" style="display: none" :accept="limitType"
@change="handleFileChange"
>
<span class="btn btn-primary btn-sm">{{ t('button.upload') }}</span>
</label>
<button class="btn btn-error btn-sm" @click="deleteAll">
{{ t('button.allDelete') }}
</button>
</div>
<div>
<ul class="p-0">
<li v-for="item in localMusicListValue" :key="item.id" class="flex items-center gap-6 pb-2 mb-3 divide-y">
<div class="mr-12 overflow-hidden w-72 whitespace-nowrap text-ellipsis">
<span>
{{ item.name }}</span>
</div>
<div class="flex gap-3">
<button class="btn btn-primary btn-xs" @click="play(item)">
{{ t('button.play') }}
</button>
<button class="btn btn-error btn-xs" @click="deleteMusic(item)">
{{ t('button.delete') }}
</button>
</div>
</li>
</ul>
</div>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -0,0 +1,116 @@
<script setup lang='ts'>
import type { IFileData } from '@/components/FileUpload/type'
import localforage from 'localforage'
import { v4 as uuidv4 } from 'uuid'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toast-notification'
import CustomDialog from '@/components/Dialog/index.vue'
import FileUpload from '@/components/FileUpload/index.vue'
import useStore from '@/store'
const toast = useToast()
const { t } = useI18n()
const limitType = ref('audio/*')
const visible = defineModel('visible', {
type: Boolean,
required: true,
})
const globalConfig = useStore().globalConfig
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
const audioData = ref<IFileData | null>(null)
const fileName = computed({
get() {
return audioData.value?.fileName || null
},
set(value) {
if (audioData.value && value) {
audioData.value.fileName = value
}
},
})
const uploadDialogRef = ref()
async function uploadFile(fileData: IFileData | null) {
if (!fileData) {
audioData.value = null
return
}
const isAudio = /audio*/.test(fileData?.type || '')
if (!isAudio) {
toast.open({
message: '不是音频文件',
type: 'error',
position: 'top-right',
})
return
}
audioData.value = fileData
}
async function getAudioDbStore() {
const keys = await audioDbStore.keys()
if (keys.length > 0) {
audioDbStore.iterate((value: { fileName: string, dataUrl: string }, key: string) => {
globalConfig.addMusic({
id: key,
name: value.fileName,
url: 'Storage',
})
})
}
}
function submitUpload() {
if (audioData.value) {
const { dataUrl, fileName } = audioData.value
const uniqueId = uuidv4()
audioDbStore.setItem(uniqueId, {
dataUrl,
fileName,
})
.then(() => {
toast.open({
message: '上传成功',
type: 'success',
position: 'top-right',
})
getAudioDbStore()
})
.catch(() => {
toast.open({
message: '上传失败',
type: 'error',
position: 'top-right',
})
})
}
}
watch(visible, (newVal) => {
if (newVal) {
uploadDialogRef.value.showDialog()
}
})
</script>
<template>
<CustomDialog
ref="uploadDialogRef"
v-model:visible="visible"
title="音乐上传"
:submit-func="submitUpload"
class=""
>
<template #content>
<div class="flex flex-col items-center gap-6 w-full px-12">
<FileUpload v-if="visible" :limit-type="limitType" @upload-file="uploadFile" />
<input v-model="fileName" :disabled="audioData === null" type="text" placeholder="图片名称" class="input w-full">
</div>
</template>
</CustomDialog>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,80 @@
<script setup lang='ts'>
import type { IMusic } from '@/types/storeType'
import localforage from 'localforage'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PageHeader from '@/components/PageHeader/index.vue'
import useStore from '@/store'
import UploadDialog from './components/UploadDialog.vue'
const { t } = useI18n()
const audioDbStore = localforage.createInstance({
name: 'audioStore',
})
const globalConfig = useStore().globalConfig
const { getMusicList: localMusicList } = storeToRefs(globalConfig)
const localMusicListValue = ref(localMusicList)
const uploadVisible = ref(false)
async function play(item: IMusic) {
globalConfig.setCurrentMusic(item, false)
}
function deleteMusic(item: IMusic) {
globalConfig.removeMusic(item.id)
audioDbStore.removeItem(item.name)
// setTimeout(()=>{
// localMusicListValue.value=localMusicList
// },100)
}
function resetMusic() {
globalConfig.resetMusicList()
audioDbStore.clear()
}
function deleteAll() {
globalConfig.clearMusicList()
audioDbStore.clear()
}
</script>
<template>
<UploadDialog v-model:visible="uploadVisible" />
<div>
<PageHeader title="音乐管理">
<template #buttons>
<div class="flex gap-3">
<button class="btn btn-primary btn-sm" @click="resetMusic">
{{ t('button.reset') }}
</button>
<label for="explore">
<span class="btn btn-primary btn-sm" @click="uploadVisible = true">{{ t('button.upload') }}</span>
</label>
<button class="btn btn-error btn-sm" @click="deleteAll">
{{ t('button.allDelete') }}
</button>
</div>
</template>
</PageHeader>
<div>
<ul class="p-0">
<li v-for="item in localMusicListValue" :key="item.id" class="flex items-center gap-6 pb-2 mb-3">
<div class="mr-12 overflow-hidden w-72 whitespace-nowrap text-ellipsis" :title="item.name">
<a class="link hover:text-primary">{{ item.name }}</a>
</div>
<div class="flex gap-3">
<button class="btn btn-primary btn-xs" @click="play(item)">
{{ t('button.play') }}
</button>
<button class="btn btn-error btn-xs" @click="deleteMusic(item)">
{{ t('button.delete') }}
</button>
</div>
</li>
</ul>
</div>
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -1,16 +1,17 @@
<!-- eslint-disable vue/no-parsing-error --> <!-- eslint-disable vue/no-parsing-error -->
<script setup lang='ts'> <script setup lang='ts'>
import type { IPersonConfig } from '@/types/storeType' import type { IPersonConfig } from '@/types/storeType'
import DaiysuiTable from '@/components/DaiysuiTable/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
import { addOtherInfo } from '@/utils'
import { readFileBinary } from '@/utils/file'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import DaiysuiTable from '@/components/DaiysuiTable/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
import { readFileBinary } from '@/utils/file'
import ImportExcelWorker from './importExcel.worker?worker'
const worker: Worker | null = new ImportExcelWorker()
const { t } = useI18n() const { t } = useI18n()
const personConfig = useStore().personConfig const personConfig = useStore().personConfig
const { getAllPersonList: allPersonList, getAlreadyPersonList: alreadyPersonList } = storeToRefs(personConfig) const { getAllPersonList: allPersonList, getAlreadyPersonList: alreadyPersonList } = storeToRefs(personConfig)
@@ -19,15 +20,27 @@ const limitType = '.xlsx,.xls'
const resetDataDialog = ref() const resetDataDialog = ref()
const delAllDataDialog = ref() const delAllDataDialog = ref()
function sendMessage(message: any) {
if (worker) {
worker.postMessage(message)
}
}
// 方法
function startWorker(data: Event) {
sendMessage({ type: 'start', data })
}
async function handleFileChange(e: Event) { 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)
}
}
}
const dataBinary = await readFileBinary(((e.target as HTMLInputElement).files as FileList)[0]!) const dataBinary = await readFileBinary(((e.target as HTMLInputElement).files as FileList)[0]!)
const workBook = XLSX.read(dataBinary, { type: 'binary', cellDates: true }) startWorker(dataBinary)
const workSheet = workBook.Sheets[workBook.SheetNames[0]]
const excelData = XLSX.utils.sheet_to_json(workSheet)
const allData = addOtherInfo(excelData)
personConfig.resetPerson()
personConfig.addNotPersonList(allData)
} }
function exportData() { function exportData() {
let data = JSON.parse(JSON.stringify(allPersonList.value)) let data = JSON.parse(JSON.stringify(allPersonList.value))
@@ -99,8 +112,8 @@ const tableColumns = [
label: i18n.global.t('data.avatar'), label: i18n.global.t('data.avatar'),
props: 'avatar', props: 'avatar',
formatValue(row: any) { formatValue(row: any) {
return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'; return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'
} },
}, },
{ {
label: i18n.global.t('data.identity'), label: i18n.global.t('data.identity'),

View File

@@ -0,0 +1,37 @@
import * as XLSX from 'xlsx'
import { addOtherInfo } from '@/utils'
// 定义消息类型
interface WorkerMessage {
type: 'start' | 'stop' | 'reset'
data: any
}
let allData: any[] = []
// 接收主线程消息
globalThis.onmessage = async (e: MessageEvent<WorkerMessage>) => {
switch (e.data.type) {
case 'start':
{
const fileData = e.data.data
// const dataBinary = await readFileBinary(((fileEvent.target as HTMLInputElement).files as FileList)[0]!)
const workBook = XLSX.read(fileData, { type: 'binary', cellDates: true })
const workSheet = workBook.Sheets[workBook.SheetNames[0]]
const excelData = XLSX.utils.sheet_to_json(workSheet)
allData = addOtherInfo(excelData)
globalThis.postMessage({
type: 'done',
data: allData,
message: '读取完成',
})
break
}
default:
globalThis.postMessage({
type: 'fail',
data: null,
message: '读取失败',
})
break
}
}

View File

@@ -0,0 +1,77 @@
<!-- eslint-disable vue/no-parsing-error -->
<script setup lang='ts'>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DaiysuiTable from '@/components/DaiysuiTable/index.vue'
import CustomDialog from '@/components/Dialog/index.vue'
import PageHeader from '@/components/PageHeader/index.vue'
import { useViewModel } from './useViewModel'
const resetDataDialogRef = ref()
const delAllDataDialogRef = ref()
const exportInputFileRef = ref()
const { resetData, deleteAll, handleFileChange, exportData, alreadyPersonList, allPersonList, tableColumns } = useViewModel({ exportInputFileRef })
const { t } = useI18n()
const limitType = '.xlsx,.xls'
</script>
<template>
<CustomDialog
ref="resetDataDialogRef"
:title="t('dialog.titleTip')"
:desc="t('dialog.dialogResetWinner')"
:submit-func="resetData"
/>
<CustomDialog
ref="delAllDataDialogRef"
:title="t('dialog.titleTip')"
:desc="t('dialog.dialogDelAllPerson')"
:submit-func="deleteAll"
/>
<div class="min-w-1000px">
<PageHeader :title="t('viewTitle.personManagement')">
<template #buttons>
<div class="flex gap-3">
<button class="btn btn-error btn-sm" @click="delAllDataDialogRef.showDialog()">
{{ t('button.allDelete') }}
</button>
<div class="tooltip tooltip-bottom" :data-tip="t('tooltip.downloadTemplateTip')">
<a
class="no-underline btn btn-secondary btn-sm" :download="t('data.xlsxName')" target="_blank"
:href="`/log-lottery/${t('data.xlsxName')}`"
>{{ t('button.downloadTemplate') }}</a>
</div>
<div class="">
<label for="explore">
<div class="tooltip tooltip-bottom" :data-tip="t('tooltip.uploadExcelTip')">
<input
id="explore" ref="exportInputFileRef" type="file" class="" style="display: none"
:accept="limitType" @change="handleFileChange"
>
<span class="btn btn-primary btn-sm">{{ t('button.importData') }}</span>
</div>
</label>
</div>
<button class="btn btn-error btn-sm" @click="resetDataDialogRef.showDialog()">
{{ t('button.resetData') }}
</button>
<button class="btn btn-accent btn-sm" @click="exportData">
{{ t('button.exportResult') }}
</button>
<div>
<span>{{ t('table.luckyPeopleNumber') }}:</span>
<span>{{ alreadyPersonList.length }}</span>
<span>&nbsp;/&nbsp;</span>
<span>{{ allPersonList.length }}</span>
</div>
</div>
</template>
</PageHeader>
<DaiysuiTable :table-columns="tableColumns" :data="allPersonList" />
</div>
</template>
<style lang='scss' scoped></style>

View File

@@ -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<HTMLInputElement> }) {
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 ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'
},
},
{
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,
}
}

View File

@@ -1,12 +1,13 @@
<!-- eslint-disable vue/no-parsing-error --> <!-- eslint-disable vue/no-parsing-error -->
<script setup lang='ts'> <script setup lang='ts'>
import type { IPersonConfig } from '@/types/storeType' import type { IPersonConfig } from '@/types/storeType'
import DaiysuiTable from '@/components/DaiysuiTable/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import DaiysuiTable from '@/components/DaiysuiTable/index.vue'
import PageHeader from '@/components/PageHeader/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
const { t } = useI18n() const { t } = useI18n()
const personConfig = useStore().personConfig const personConfig = useStore().personConfig
@@ -39,8 +40,8 @@ const tableColumnsList = [
label: i18n.global.t('data.avatar'), label: i18n.global.t('data.avatar'),
props: 'avatar', props: 'avatar',
formatValue(row: any) { formatValue(row: any) {
return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'; return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'
} },
}, },
{ {
label: i18n.global.t('data.department'), label: i18n.global.t('data.department'),
@@ -82,8 +83,8 @@ const tableColumnsDetail = [
label: i18n.global.t('data.avatar'), label: i18n.global.t('data.avatar'),
props: 'avatar', props: 'avatar',
formatValue(row: any) { formatValue(row: any) {
return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'; return row.avatar ? `<img src="${row.avatar}" alt="avatar" style="width: 50px; height: 50px;"/>` : '-'
} },
}, },
{ {
label: i18n.global.t('data.department'), label: i18n.global.t('data.department'),
@@ -121,21 +122,25 @@ const tableColumnsDetail = [
<template> <template>
<div class="overflow-y-auto"> <div class="overflow-y-auto">
<h2>{{ t('viewTitle.winnerManagement') }}</h2> <PageHeader :title="t('viewTitle.winnerManagement')">
<div class="flex items-center justify-start gap-10"> <template #buttons>
<div> <div class="flex items-center justify-start gap-10">
<span>{{ t('table.luckyPeopleNumber') }}</span> <div>
<span>{{ alreadyPersonList.length }}</span> <span>{{ t('table.luckyPeopleNumber') }}</span>
</div> <span>{{ alreadyPersonList.length }}</span>
<div class="flex flex-col"> </div>
<div class="form-control"> <div class="flex flex-col">
<label class="cursor-pointer label"> <div class="form-control">
<span class="label-text">{{ t('table.detail') }}:</span> <label class="cursor-pointer label">
<input v-model="isDetail" type="checkbox" class="border-solid toggle toggle-primary border-1"> <span class="label-text">{{ t('table.detail') }}:</span>
</label> <input v-model="isDetail" type="checkbox" class="border-solid toggle toggle-primary border-1">
</label>
</div>
</div>
</div> </div>
</div> </template>
</div> </PageHeader>
<DaiysuiTable v-if="!isDetail" :table-columns="tableColumnsList" :data="alreadyPersonList" /> <DaiysuiTable v-if="!isDetail" :table-columns="tableColumnsList" :data="alreadyPersonList" />
<DaiysuiTable v-if="isDetail" :table-columns="tableColumnsDetail" :data="alreadyPersonDetail" /> <DaiysuiTable v-if="isDetail" :table-columns="tableColumnsDetail" :data="alreadyPersonDetail" />

View File

@@ -0,0 +1,32 @@
import * as XLSX from 'xlsx'
import { addOtherInfo } from '@/utils'
// 定义消息类型
interface WorkerMessage {
type: 'start' | 'stop' | 'reset'
data: any
}
let allData: any[] = []
// 接收主线程消息
globalThis.onmessage = async (e: MessageEvent<WorkerMessage>) => {
switch (e.data.type) {
case 'start':
{
const fileData = e.data.data
// const dataBinary = await readFileBinary(((fileEvent.target as HTMLInputElement).files as FileList)[0]!)
const workBook = XLSX.read(fileData, { type: 'binary', cellDates: true })
const workSheet = workBook.Sheets[workBook.SheetNames[0]]
const excelData = XLSX.utils.sheet_to_json(workSheet)
allData = addOtherInfo(excelData)
globalThis.postMessage({
type: 'done',
data: allData,
message: '读取完成',
})
break
}
default:
break
}
}

View File

@@ -1,12 +1,16 @@
<script setup lang='ts'> <script setup lang='ts'>
import type { IPrizeConfig } from '@/types/storeType' import type { IPrizeConfig } from '@/types/storeType'
import EditSeparateDialog from '@/components/NumberSeparate/EditSeparateDialog.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
import localforage from 'localforage' import localforage from 'localforage'
import { cloneDeep } from 'lodash-es'
import { Grip } from 'lucide-vue-next'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import EditSeparateDialog from '@/components/NumberSeparate/EditSeparateDialog.vue'
import PageHeader from '@/components/PageHeader/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
const { t } = useI18n() const { t } = useI18n()
const imageDbStore = localforage.createInstance({ const imageDbStore = localforage.createInstance({
@@ -17,7 +21,7 @@ const globalConfig = useStore().globalConfig
const { getPrizeConfig: localPrizeList, getCurrentPrize: currentPrize } = storeToRefs(prizeConfig) const { getPrizeConfig: localPrizeList, getCurrentPrize: currentPrize } = storeToRefs(prizeConfig)
const { getImageList: localImageList } = storeToRefs(globalConfig) const { getImageList: localImageList } = storeToRefs(globalConfig)
const prizeList = ref(localPrizeList) const prizeList = ref(cloneDeep(localPrizeList.value))
const imgList = ref<any[]>([]) const imgList = ref<any[]>([])
const selectedPrize = ref<IPrizeConfig | null>() const selectedPrize = ref<IPrizeConfig | null>()
@@ -122,17 +126,6 @@ async function getImageDbStore() {
} }
} }
function sort(item: IPrizeConfig, isUp: number) {
const itemIndex = prizeList.value.indexOf(item)
if (isUp === 1) {
prizeList.value.splice(itemIndex, 1)
prizeList.value.splice(itemIndex - 1, 0, item)
}
else {
prizeList.value.splice(itemIndex, 1)
prizeList.value.splice(itemIndex + 1, 0, item)
}
}
function delItem(item: IPrizeConfig) { function delItem(item: IPrizeConfig) {
prizeConfig.deletePrizeConfig(item.id) prizeConfig.deletePrizeConfig(item.id)
} }
@@ -143,53 +136,53 @@ onMounted(() => {
getImageDbStore() getImageDbStore()
}) })
watch(() => prizeList.value, (val: IPrizeConfig[]) => { watch(() => prizeList.value, (val: IPrizeConfig[]) => {
console.log('prizeList', val)
prizeConfig.setPrizeConfig(val) prizeConfig.setPrizeConfig(val)
}, { deep: true }) }, { deep: true })
</script> </script>
<template> <template>
<div> <div>
<h2>{{ t('viewTitle.prizeManagement') }}</h2> <PageHeader :title="t('viewTitle.prizeManagement')">
<div class="flex w-full gap-3"> <template #buttons>
<button class="btn btn-info btn-sm" @click="addPrize"> <div class="flex w-full gap-3">
{{ t('button.add') }} <button class="btn btn-info btn-sm" @click="addPrize">
</button> {{ t('button.add') }}
<button class="btn btn-info btn-sm" @click="resetDefault"> </button>
{{ t('button.resetDefault') }} <button class="btn btn-info btn-sm" @click="resetDefault">
</button> {{ t('button.resetDefault') }}
<button class="btn btn-error btn-sm" @click="delAll"> </button>
{{ t('button.allDelete') }} <button class="btn btn-error btn-sm" @click="delAll">
</button> {{ t('button.allDelete') }}
</div> </button>
<div role="alert" class="w-full my-4 alert alert-info"> </div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-6 h-6 stroke-current shrink-0"> </template>
<path <template #alerts>
stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <div role="alert" class="w-full my-4 alert alert-info">
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-6 h-6 stroke-current shrink-0">
/> <path
</svg> stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<span>{{ t('dialog.tipResetPrize') }}</span> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
</div> />
<ul class="p-0 m-0"> </svg>
<li <span>{{ t('dialog.tipResetPrize') }}</span>
v-for="item in prizeList" :key="item.id" class="flex gap-10" </div>
</template>
</PageHeader>
<VueDraggable
v-model="prizeList"
:animation="150"
handle=".handle"
class="p-0 m-0"
>
<div
v-for="item in prizeList" :key="item.id" class="flex items-center justify-center gap-10 py-5"
:class="currentPrize.id === item.id ? 'border-1 border-dotted rounded-xl' : null" :class="currentPrize.id === item.id ? 'border-1 border-dotted rounded-xl' : null"
> >
<label class="max-w-xs mb-10 form-control"> <label class="flex items-center justify-center max-w-xs px-2 handle form-control">
<!-- 向上向下 --> <Grip class="w-10 h-10 cursor-move handle" />
<div class="flex flex-col items-center gap-2 pt-5">
<svg-icon
class="cursor-pointer hover:text-blue-400"
:class="prizeList.indexOf(item) === 0 ? 'opacity-0 cursor-default' : ''" name="up"
@click="sort(item, 1)"
/>
<svg-icon
class="cursor-pointer hover:text-blue-400" name="down" :class="prizeList.indexOf(item) === prizeList.length - 1 ? 'opacity-0 cursor-default' : ''"
@click="sort(item, 0)"
/>
</div>
</label> </label>
<label class="w-1/2 max-w-xs mb-10 form-control"> <label class="w-1/2 max-w-xs form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.prizeName') }}</span> <span class="label-text">{{ t('table.prizeName') }}</span>
</div> </div>
@@ -198,16 +191,16 @@ watch(() => prizeList.value, (val: IPrizeConfig[]) => {
class="w-full max-w-xs input-sm input input-bordered" class="w-full max-w-xs input-sm input input-bordered"
> >
</label> </label>
<label class="w-1/2 max-w-xs mb-10 form-control"> <label class="flex items-center w-1/2 max-w-xs gap-2 form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.fullParticipation') }}</span> <span class="label-text">{{ t('table.fullParticipation') }}</span>
</div> </div>
<input <input
type="checkbox" :checked="item.isAll" class="mt-2 border-solid checkbox checkbox-secondary border-1" type="checkbox" :checked="item.isAll" class="border-solid checkbox checkbox-secondary border-1"
@change="item.isAll = !item.isAll" @change="item.isAll = !item.isAll"
> >
</label> </label>
<label class="w-1/2 max-w-xs mb-10 form-control"> <label class="w-1/2 max-w-xs form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.numberParticipants') }}</span> <span class="label-text">{{ t('table.numberParticipants') }}</span>
</div> </div>
@@ -219,27 +212,28 @@ watch(() => prizeList.value, (val: IPrizeConfig[]) => {
<progress class="w-full progress" :value="item.isUsedCount" :max="item.count" /> <progress class="w-full progress" :value="item.isUsedCount" :max="item.count" />
</div> </div>
</label> </label>
<label class="w-1/2 max-w-xs mb-10 form-control"> <label class="flex items-center w-1/2 max-w-xs gap-2 form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.isDone') }}</span> <span class="label-text">{{ t('table.isDone') }}</span>
</div> </div>
<input <input
type="checkbox" :checked="item.isUsed" class="mt-2 border-solid checkbox checkbox-secondary border-1" type="checkbox" :checked="item.isUsed" class="border-solid checkbox checkbox-secondary border-1"
@change="changePrizeStatus(item)" @change="changePrizeStatus(item)"
> >
</label> </label>
<label class="w-full max-w-xs mb-10 form-control"> <label class="w-full max-w-xs form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.image') }}</span> <span class="label-text">{{ t('table.image') }}</span>
</div> </div>
<select v-model="item.picture" class="w-full max-w-xs select select-warning select-sm"> <select v-model="item.picture" class="truncate select select-warning select-sm">
<option v-if="item.picture.id" :value="{ id: '', name: '', url: '' }"></option> <option v-if="item.picture.id" :value="{ id: '', name: '', url: '' }"></option>
<option disabled selected>{{ t('table.selectPicture') }}</option> <option disabled selected>{{ t('table.selectPicture') }}</option>
<option v-for="picItem in localImageList" :key="picItem.id" :value="picItem">{{ picItem.name }} <option v-for="picItem in localImageList" :key="picItem.id" :title="picItem.name" class="w-full max-w-full" :value="picItem">
<span class="truncate w-option-xs">{{ picItem.name }}</span>
</option> </option>
</select> </select>
</label> </label>
<label v-if="item.separateCount" class="w-full max-w-xs mb-10 form-control"> <label v-if="item.separateCount" class="w-full max-w-xs form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.onceNumber') }}</span> <span class="label-text">{{ t('table.onceNumber') }}</span>
</div> </div>
@@ -267,16 +261,16 @@ watch(() => prizeList.value, (val: IPrizeConfig[]) => {
<button v-else class="btn btn-secondary btn-xs">{{ t('button.setting') }}</button> <button v-else class="btn btn-secondary btn-xs">{{ t('button.setting') }}</button>
</div> </div>
</label> </label>
<label class="w-full max-w-xs mb-10 form-control"> <label class="w-full max-w-xs form-control">
<div class="label"> <div class="label">
<span class="label-text">{{ t('table.operation') }}</span> <span class="label-text">{{ t('table.operation') }}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-error btn-sm" @click="delItem(item)">{{ t('button.delete') }}</button> <button class="btn btn-error btn-xs" @click="delItem(item)">{{ t('button.delete') }}</button>
</div> </div>
</label> </label>
</li> </div>
</ul> </VueDraggable>
<EditSeparateDialog <EditSeparateDialog
:total-number="selectedPrize?.count" :separated-number="selectedPrize?.separateCount.countList" :total-number="selectedPrize?.count" :separated-number="selectedPrize?.separateCount.countList"
@submit-data="submitData" @submit-data="submitData"

View File

@@ -0,0 +1,100 @@
import type { IPrizeConfig } from '@/types/storeType'
import localforage from 'localforage'
import { cloneDeep } from 'lodash-es'
import { Grip } from 'lucide-vue-next'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { useI18n } from 'vue-i18n'
import EditSeparateDialog from '@/components/NumberSeparate/EditSeparateDialog.vue'
import PageHeader from '@/components/PageHeader/index.vue'
import i18n from '@/locales/i18n'
import useStore from '@/store'
export function usePrizeConfig() {
const { t } = useI18n()
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
const prizeConfig = useStore().prizeConfig
const globalConfig = useStore().globalConfig
const { getPrizeConfig: localPrizeList, getCurrentPrize: currentPrize } = storeToRefs(prizeConfig)
const { getImageList: localImageList } = storeToRefs(globalConfig)
const imgList = ref<any[]>([])
const prizeList = ref(cloneDeep(localPrizeList.value))
const selectedPrize = ref<IPrizeConfig | null>()
function selectPrize(item: IPrizeConfig) {
selectedPrize.value = item
selectedPrize.value.isUsedCount = 0
selectedPrize.value.isUsed = false
if (selectedPrize.value.separateCount.countList.length > 1) {
return
}
selectedPrize.value.separateCount = {
enable: true,
countList: [
{
id: '0',
count: item.count,
isUsedCount: 0,
},
],
}
}
function changePrizeStatus(item: IPrizeConfig) {
item.isUsed ? item.isUsedCount = 0 : item.isUsedCount = item.count
item.separateCount.countList = []
item.isUsed = !item.isUsed
}
function changePrizePerson(item: IPrizeConfig) {
let indexPrize = -1
for (let i = 0; i < prizeList.value.length; i++) {
if (prizeList.value[i].id === item.id) {
indexPrize = i
break
}
}
if (indexPrize > -1) {
prizeList.value[indexPrize].separateCount.countList = []
prizeList.value[indexPrize].isUsed ? prizeList.value[indexPrize].isUsedCount = prizeList.value[indexPrize].count : prizeList.value[indexPrize].isUsedCount = 0
}
}
function submitData(value: any) {
selectedPrize.value!.separateCount.countList = value
selectedPrize.value = null
}
async function getImageDbStore() {
const keys = await imageDbStore.keys()
if (keys.length > 0) {
imageDbStore.iterate((value, key) => {
imgList.value.push({
key,
value,
})
})
}
}
function delItem(item: IPrizeConfig) {
prizeConfig.deletePrizeConfig(item.id)
}
onMounted(() => {
getImageDbStore()
})
watch(() => prizeList.value, (val: IPrizeConfig[]) => {
console.log('prizeList', val)
prizeConfig.setPrizeConfig(val)
}, { deep: true })
return {
currentPrize,
}
}

View File

@@ -1,7 +1,7 @@
<script setup lang='ts'> <script setup lang='ts'>
import i18n from '@/locales/i18n'
import markdownit from 'markdown-it' import markdownit from 'markdown-it'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import i18n from '@/locales/i18n'
const md = markdownit() const md = markdownit()
const readmeHtml = ref('') const readmeHtml = ref('')

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs'
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -8,6 +9,7 @@ const { t } = useI18n()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const menuList = ref<any[]>(configRoutes.children) const menuList = ref<any[]>(configRoutes.children)
const currentYear = dayjs().year()
function cleanMenuList(menu: any) { function cleanMenuList(menu: any) {
const newList = menu const newList = menu
@@ -67,7 +69,7 @@ function skip(path: string) {
</ul> </ul>
<router-view class="flex-1 mt-5" /> <router-view class="flex-1 mt-5" />
</div> </div>
<footer class="p-10 rounded footer footer-center bg-base-200 text-base-content"> <footer class="p-10 rounded footer footer-center bg-base-200 h-[280px] flex flex-col gap-4 text-base-content">
<nav class="grid grid-flow-col gap-4"> <nav class="grid grid-flow-col gap-4">
<a class="cursor-pointer link link-hover text-inherit" target="_blank" href="https://1kw20.fun">{{ t('footer.self-reflection') }}</a> <a class="cursor-pointer link link-hover text-inherit" target="_blank" href="https://1kw20.fun">{{ t('footer.self-reflection') }}</a>
</nav> </nav>
@@ -89,7 +91,7 @@ function skip(path: string) {
<p class="p-0 m-0"> <p class="p-0 m-0">
蜀ICP备2021028666号 蜀ICP备2021028666号
</p> </p>
<p>Copyright © 2024 - All right reserved by Log1997</p> <p>Copyright © {{ currentYear }} - All right reserved by <a class="link link-primary" href="https://github.com/LOG1997" target="_blank">log1997</a></p>
</aside> </aside>
</footer> </footer>
</template> </template>

View File

@@ -1,4 +1,29 @@
<script setup lang='ts'> <script setup lang='ts'>
import { Grip } from 'lucide-vue-next'
import { onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
const list = ref([
{
name: 'Joao',
id: '1',
},
{
name: 'Jean',
id: '2',
},
{
name: 'Johanna',
id: '3',
},
{
name: 'Juan',
id: '4',
},
])
onMounted(() => {
})
</script> </script>
<template> <template>
@@ -6,6 +31,21 @@
<button class="btn btn-error"> <button class="btn btn-error">
打印 打印
</button> </button>
<VueDraggable
v-model="list"
:animation="150"
handle=".handle"
class="flex flex-col gap-2 p-4 w-300px bg-gray-500/5 rounded"
>
<div
v-for="(item, index) in list"
:key="item.id"
class="h-50px bg-gray-500/5 px-2 rounded flex items-center justify-between"
>
<Grip class="handle cursor-move" />
<input v-model="item.name" type="text">
</div>
</VueDraggable>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,95 @@
<script setup lang='ts'>
import { toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
interface Props {
textSize: number
textColor: string
topTitle: string
tableData: any[]
setDefaultPersonList: () => void
isInitialDone: boolean
}
const props = defineProps<Props>()
const router = useRouter()
const { tableData, textSize, textColor, topTitle, setDefaultPersonList } = toRefs(props)
const { t } = useI18n()
</script>
<template>
<div class="absolute z-10 flex flex-col items-center justify-center -translate-x-1/2 left-1/2">
<h2
class="pt-12 m-0 mb-12 font-mono tracking-wide text-center leading-12 header-title"
:style="{ fontSize: `${textSize * 1.5}px`, color: textColor }"
>
{{ topTitle }}
</h2>
<div v-if="isInitialDone" class="flex gap-3">
<button
v-if="tableData.length <= 0" class="cursor-pointer btn btn-outline btn-secondary btn-lg"
@click="router.push('config')"
>
{{ t('button.noInfoAndImport') }}
</button>
<button
v-if="tableData.length <= 0" class="cursor-pointer btn btn-outline btn-secondary btn-lg"
@click="setDefaultPersonList"
>
{{ t('button.useDefault') }}
</button>
</div>
<!-- 加载中 -->
<div v-else class="flex gap-3 items-center">
<span class="loading loading-spinner loading-xl" />
<span>加载中</span>
</div>
</div>
</template>
<style scoped lang="scss">
.header-title {
-webkit-animation: tracking-in-expand-fwd 0.8s cubic-bezier(0.215, 0.610, 0.355, 1.000) both;
animation: tracking-in-expand-fwd 0.8s cubic-bezier(0.215, 0.610, 0.355, 1.000) both;
}
@-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;
}
}
</style>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,79 @@
<script setup lang='ts'>
import { toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import { LotteryStatus } from '@/views/Home/type'
interface Props {
currentStatus: LotteryStatus
tableData: any[]
enterLottery: () => void
startLottery: () => void
stopLottery: () => void
continueLottery: () => void
quitLottery: () => void
}
const props = defineProps<Props>()
const { currentStatus, tableData, enterLottery, startLottery, stopLottery, continueLottery, quitLottery } = toRefs(props)
const { t } = useI18n()
</script>
<template>
<div id="menu">
<button v-if="currentStatus === LotteryStatus.init && tableData.length > 0" class="btn-neon" @click="enterLottery">
{{ t('button.enterLottery') }}
</button>
<div v-if="currentStatus === LotteryStatus.ready" class="start">
<button class="btn-stars" @click="startLottery">
<strong>{{ t('button.start') }}</strong>
<div id="container-stars">
<div id="stars" />
</div>
<div id="glow">
<div class="circle" />
<div class="circle" />
</div>
</button>
</div>
<button v-if="currentStatus === LotteryStatus.running" class="btn-neon btn glass btn-lg" @click="stopLottery">
{{ t('button.selectLucky') }}
</button>
<div v-if="currentStatus === LotteryStatus.end" class="flex justify-center gap-6 enStop">
<div class="start">
<button class="btn-stars" @click="continueLottery">
<strong>{{ t('button.continue') }}</strong>
<div id="container-stars">
<div id="stars" />
</div>
<div id="glow">
<div class="circle" />
<div class="circle" />
</div>
</button>
</div>
<div class="start">
<button class="btn-stars btn-cancel" @click="quitLottery">
<strong>{{ t('button.cancel') }}</strong>
<div id="container-stars">
<div id="stars" />
</div>
<div id="glow">
<div class="circle" />
<div class="circle" />
</div>
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use './index.scss'
</style>

View File

@@ -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;
}
}

View File

@@ -1,17 +1,17 @@
<script setup lang='ts'> <script setup lang='ts'>
import type { IPrizeConfig } from '../../types/storeType' import type { IPrizeConfig } from '@/types/storeType'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import defaultPrizeImage from '@/assets/images/龙.png' import defaultPrizeImage from '@/assets/images/龙.png'
import ImageSync from '@/components/ImageSync/index.vue' import ImageSync from '@/components/ImageSync/index.vue'
import EditSeparateDialog from '@/components/NumberSeparate/EditSeparateDialog.vue' import EditSeparateDialog from '@/components/NumberSeparate/EditSeparateDialog.vue'
import i18n from '@/locales/i18n' import i18n from '@/locales/i18n'
import useStore from '@/store' import useStore from '@/store'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
const prizeConfig = useStore().prizeConfig const prizeConfig = useStore().prizeConfig
const globalConfig = useStore().globalConfig const globalConfig = useStore().globalConfig
@@ -248,7 +248,7 @@ onMounted(() => {
> >
<div <div
v-if="item.isShow" v-if="item.isShow"
class="relative flex flex-row items-center justify-between w-64 h-20 shadow-xl card bg-base-100" class="relative flex flex-row items-center justify-between w-64 h-20 px-3 gap-6 shadow-xl card bg-base-100"
> >
<div <div
v-if="item.isUsed" v-if="item.isUsed"
@@ -261,10 +261,10 @@ onMounted(() => {
class="object-cover h-full rounded-xl" class="object-cover h-full rounded-xl"
> >
</figure> </figure>
<div class="items-center p-0 text-center card-body"> <div class="items-center p-0 card-body">
<div class="tooltip tooltip-left" :data-tip="item.name"> <div class="tooltip tooltip-left w-full pl-1" :data-tip="item.name">
<h2 <h2
class="w-24 p-0 m-0 overflow-hidden text-center card-title whitespace-nowrap text-ellipsis" class="w-24 p-0 m-0 overflow-hidden card-title whitespace-nowrap text-ellipsis"
> >
{{ item.name }} {{ item.name }}
</h2> </h2>
@@ -274,7 +274,7 @@ onMounted(() => {
item.count }} item.count }}
</p> </p>
<progress <progress
class="w-3/4 h-6 progress progress-primary" :value="item.isUsedCount" class="w-full h-6 progress bg-[#52545b] progress-primary" :value="item.isUsedCount"
:max="item.count" :max="item.count"
/> />
<!-- <p class="p-0 m-0">{{ item.isUsedCount }}/{{ item.count }}</p> --> <!-- <p class="p-0 m-0">{{ item.isUsedCount }}/{{ item.count }}</p> -->
@@ -318,142 +318,5 @@ onMounted(() => {
</template> </template>
<style lang='scss' scoped> <style lang='scss' scoped>
.label { @use "./index.scss";
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;
}
}
</style> </style>

View File

@@ -0,0 +1,71 @@
<script setup lang='ts'>
import { useElementSize } from '@vueuse/core'
import localforage from 'localforage'
import Sparticles from 'sparticles'
import { onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
homeBackground: {
type: Object,
default: () => ({
id: '',
name: '',
url: '',
}),
},
})
const imageDbStore = localforage.createInstance({
name: 'imgStore',
})
const imgUrl = ref('')
const starRef = ref()
const { width, height } = useElementSize(starRef)
const options = ref({ shape: 'star', parallax: 1.2, rotate: true, twinkle: true, speed: 10, count: 200 })
function addSparticles(node: any, width: number, height: number) {
const sparticleInstance = new Sparticles(node, options.value, width, height)
return sparticleInstance
}
// 页面大小改变时
function listenWindowSize() {
window.addEventListener('resize', () => {
if (width.value && height.value) {
addSparticles(starRef.value, width.value, height.value)
}
})
}
async function getImageStoreItem(item: any): Promise<string> {
let image = ''
if (item.url === 'Storage') {
const key = item.id
image = await imageDbStore.getItem(key) as string
}
else {
image = item.url
}
return image
}
onMounted(() => {
getImageStoreItem(props.homeBackground).then((image) => {
imgUrl.value = image
})
addSparticles(starRef.value, width.value, height.value)
listenWindowSize()
})
onUnmounted(() => {
window.removeEventListener('resize', listenWindowSize)
})
</script>
<template>
<div v-if="homeBackground.url" class="home-background w-screen h-screen overflow-hidden">
<img :src="imgUrl" class="w-full h-full object-cover" alt="">
</div>
<div v-else ref="starRef" class="w-screen h-screen overflow-hidden" />
</template>
<style lang='scss' scoped>
</style>

File diff suppressed because it is too large Load Diff

12
src/views/Home/type.ts Normal file
View File

@@ -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[]
}

View File

@@ -0,0 +1,641 @@
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, getRandomElements, initTableData } from './utils'
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<HTMLElement>()
const canOperate = ref(true)
const cameraZ = ref(3000)
const scene = ref()
const camera = ref()
const renderer = ref()
const controls = ref()
const objects = ref<any[]>([])
const targets: TargetType = {
grid: [],
helix: [],
table: [],
sphere: [],
}
// 页面数据初始值
const currentStatus = ref<LotteryStatus>(LotteryStatus.init) // 0为初始状态 1为抽奖准备状态2为抽奖中状态3为抽奖结束状态
const tableData = ref<any[]>([])
const luckyTargets = ref<any[]>([])
const luckyCardList = ref<number[]>([])
const luckyCount = ref(10)
const personPool = ref<IPersonConfig[]>([])
const intervalTimer = ref<any>(null)
const isInitialDone = ref<boolean>(false)
function initThreeJs() {
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}<br/>${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 随机抽取球数据
*/
/// <IP_ADDRESS>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
// 重构抽奖函数
luckyTargets.value = getRandomElements(personPool.value, luckyCount.value)
luckyTargets.value.forEach((item) => {
const index = personPool.value.findIndex(person => person.id === item.id)
if (index > -1) {
personPool.value.splice(index, 1)
}
})
// 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()
}
const init = () => {
const startTime = Date.now()
const maxWaitTime = 2000 // 2秒
const checkAndInit = () => {
// 如果人员列表有数据或者等待时间超过2秒则执行初始化
if (allPersonList.value.length > 0 || (Date.now() - startTime) >= maxWaitTime) {
console.log('初始化完成')
tableData.value = initTableData({ allPersonList: allPersonList.value, rowCount: rowCount.value })
initThreeJs()
animation()
containerRef.value!.style.color = `${textColor}`
randomBallData()
window.addEventListener('keydown', listenKeyboard)
isInitialDone.value = true
}
else {
console.log('等待人员列表数据...')
// 继续等待
setTimeout(checkAndInit, 100) // 每100毫秒检查一次
}
}
checkAndInit()
}
onMounted(() => {
init()
})
onUnmounted(() => {
nextTick(() => {
cleanup()
})
clearInterval(intervalTimer.value)
intervalTimer.value = null
window.removeEventListener('keydown', listenKeyboard)
})
// watch(() => allPersonList.value, (newVal) => {
// if (newVal.length) {
// init()
// }
// })
return {
setDefaultPersonList,
startLottery,
continueLottery,
quitLottery,
containerRef,
stopLottery,
enterLottery,
tableData,
currentStatus,
isInitialDone,
}
}

131
src/views/Home/util.ts Normal file
View File

@@ -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),
})
}

View File

@@ -0,0 +1,2 @@
export * from './random'
export * from './table'

View File

@@ -0,0 +1,53 @@
/**
* 浏览器端加密安全洗牌(无需指定抽取数量)
* @param array 要洗牌的数组
* @returns 洗牌后的新数组
*/
function shuffleBrowserCrypto<T>(array: T[]): T[] {
const newArray = [...array]
if (newArray.length <= 1)
return newArray
// 遍历数组,每轮生成一个随机索引
for (let i = newArray.length - 1; i > 0; i--) {
// 步骤1生成一个 32 位无符号加密随机数仅需1个
const randomBuffer = new Uint32Array(1) // 长度1表示只生成1个随机数
crypto.getRandomValues(randomBuffer)
// 步骤2将随机数映射到 [0, i] 范围核心动态适配当前i的范围
const randomIndex = randomBuffer[0] % (i + 1);
// 步骤3交换元素
[newArray[i], newArray[randomIndex]] = [newArray[randomIndex], newArray[i]]
}
return newArray
}
/**
* @description 从源数组中随机获取指定数量的元素
* @param {Array} sourceArray 源数组
* @param {number} count 要获取的元素数量
* @returns {Array} 随机获取的元素
*/
export function getRandomElements<T>(sourceArray: T[], count: number): T[] {
if (count <= 0)
return []
if (count >= sourceArray.length) {
return shuffleBrowserCrypto([...sourceArray])
} // 抽全部=洗牌
const newArray = [...sourceArray]
const result: T[] = []
// 抽取 count 个元素,每轮选一个随机索引加入结果,然后从原数组移除
for (let i = 0; i < count; i++) {
const randomBuffer = new Uint32Array(1)
crypto.getRandomValues(randomBuffer)
const randomIndex = randomBuffer[0] % newArray.length
result.push(newArray[randomIndex])
}
return result
}

View File

@@ -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),
})
}

7
src/vite-env.d.ts vendored
View File

@@ -1,11 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module '*.vue' { declare module '*.vue' {
import type { DefineComponent } from 'vue' import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any> const component: DefineComponent<object, object, any>
export default component export default component
} }
declare module 'sparticles' declare module 'sparticles'
declare module 'three-trackballcontrols' declare module 'three-trackballcontrols'
declare module 'virtual:svg-icons-register'

View File

@@ -1,23 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
corePlugins: {
preflight: false
},
theme: {
},
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
plugins: [require('@tailwindcss/typography'), require('daisyui')],
daisyui: {
themes: true, // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
darkTheme: '', // name of one of the included themes for dark mode
base: true, // applies background color and foreground color for root element by default
styled: true, // include daisyUI colors and design decisions for all components
utils: true, // adds responsive and modifier utility classes
prefix: '', // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
logs: true, // Shows info about daisyUI version and used config in the console when building your CSS
themeRoot: ':root', // The element that receives theme color CSS variables
},
};

View File

@@ -2,6 +2,7 @@
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import path from 'node:path' import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import legacy from '@vitejs/plugin-legacy' import legacy from '@vitejs/plugin-legacy'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
@@ -26,6 +27,7 @@ export default defineConfig(({ mode }) => {
base: mode === 'file' ? './' : '/log-lottery/', base: mode === 'file' ? './' : '/log-lottery/',
plugins: [ plugins: [
vue(), vue(),
tailwindcss(),
mode === 'file' mode === 'file'
? legacy({ ? legacy({
additionalLegacyPolyfills: ['regenerator-runtime/runtime'], additionalLegacyPolyfills: ['regenerator-runtime/runtime'],