Merge branch 'dev' into jiez1812/issue187
This commit is contained in:
12
src/api/msg/index.ts
Normal file
12
src/api/msg/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from '../request'
|
||||
|
||||
export function api_sendMsg(userSignature: string, data: any) {
|
||||
return request<{ status: string }>({
|
||||
url: '/user-msg',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
userSignature,
|
||||
},
|
||||
data,
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import openModal from '@/components/ErrorModal'
|
||||
|
||||
class Request {
|
||||
private instance: AxiosInstance
|
||||
@@ -15,14 +16,10 @@ class Request {
|
||||
this.instance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 在发送请求之前做些什么
|
||||
console.log('请求拦截器被触发')
|
||||
|
||||
return config
|
||||
},
|
||||
(error: any) => {
|
||||
// 对请求错误做些什么
|
||||
console.error('请求拦截器发生错误:', error)
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
@@ -31,15 +28,16 @@ class Request {
|
||||
this.instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 对响应数据做些什么
|
||||
console.log('响应拦截器被触发')
|
||||
const responseData = response.data
|
||||
|
||||
return responseData
|
||||
return response
|
||||
},
|
||||
(error: any) => {
|
||||
// 对响应错误做些什么
|
||||
console.error('响应拦截器发生错误:', error)
|
||||
|
||||
if (error.response && error.response.data) {
|
||||
const { code, msg } = error.response.data
|
||||
openModal({ title: code, desc: msg })
|
||||
return Promise.reject(error.response.data)
|
||||
}
|
||||
openModal({ title: '请求错误', desc: error.message })
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,11 +9,15 @@ interface Props {
|
||||
submitText?: string
|
||||
submitFunc?: () => void
|
||||
cancelFunc?: () => void
|
||||
footer?: null | 'center' | 'left' | 'right'
|
||||
dialogClass?: string // 添加动态class属性
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
cancelText: i18n.global.t('button.cancel'),
|
||||
submitText: i18n.global.t('button.confirm'),
|
||||
cancelFunc: () => {},
|
||||
footer: 'right',
|
||||
dialogClass: '',
|
||||
})
|
||||
const visible = defineModel('visible', {
|
||||
type: Boolean,
|
||||
@@ -43,7 +47,7 @@ const { title, desc, cancelText, submitText, submitFunc, cancelFunc = defaultCan
|
||||
|
||||
<template>
|
||||
<dialog id="my_modal" ref="dialogRef" class="border-none modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-box" :class="[dialogClass]">
|
||||
<h3 v-if="title" class="text-lg font-bold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
@@ -53,7 +57,7 @@ const { title, desc, cancelText, submitText, submitFunc, cancelFunc = defaultCan
|
||||
<div>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<div class="modal-action" :class="{ 'flex justify-center': footer === 'center' }">
|
||||
<form method="dialog" class="flex gap-3">
|
||||
<!-- if there is a button in form, it will close the modal -->
|
||||
<button class="btn" @click="cancelFunc">
|
||||
|
||||
57
src/components/ErrorModal/index.ts
Normal file
57
src/components/ErrorModal/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createVNode, render } from 'vue'
|
||||
import ErrorModalVue from './index.vue'
|
||||
|
||||
// 定义弹窗调用函数
|
||||
function openModal(options = {}) {
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
title: '提示',
|
||||
desc: '',
|
||||
// 确认按钮回调
|
||||
onConfirm: () => {},
|
||||
// 关闭按钮回调
|
||||
onClose: () => {},
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const finalOptions = { ...defaultOptions, ...options }
|
||||
|
||||
// 创建容器
|
||||
const container = document.createElement('div')
|
||||
|
||||
// 创建虚拟节点
|
||||
const vnode = createVNode(ErrorModalVue, {
|
||||
'title': finalOptions.title,
|
||||
'desc': finalOptions.desc,
|
||||
'modelValue': true, // 默认打开
|
||||
'onUpdate:modelValue': (val: any) => {
|
||||
if (!val) {
|
||||
// 关闭时销毁组件
|
||||
render(null, container)
|
||||
document.body.removeChild(container)
|
||||
}
|
||||
},
|
||||
'onConfirm': () => {
|
||||
finalOptions.onConfirm()
|
||||
},
|
||||
'onClose': () => {
|
||||
finalOptions.onClose()
|
||||
},
|
||||
})
|
||||
|
||||
// 渲染组件到容器
|
||||
render(vnode, container)
|
||||
|
||||
// 将容器添加到body
|
||||
document.body.appendChild(container)
|
||||
|
||||
// 返回关闭方法(可选)
|
||||
return {
|
||||
close: () => {
|
||||
render(null, container)
|
||||
document.body.removeChild(container)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default openModal
|
||||
127
src/components/ErrorModal/index.vue
Normal file
127
src/components/ErrorModal/index.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { Dialog, DialogDescription, DialogPanel, DialogTitle } from '@headlessui/vue'
|
||||
import { CircleAlert } from 'lucide-vue-next'
|
||||
import { defineEmits, defineProps, ref, watch } from 'vue'
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '提示',
|
||||
},
|
||||
desc: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// 控制弹窗显隐
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 内部显隐状态
|
||||
const visible = ref(props.modelValue)
|
||||
|
||||
// 同步外部 modelValue 变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
})
|
||||
|
||||
// 关闭弹窗
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<Dialog :open="visible" class="relative z-50" @close="handleClose">
|
||||
<!-- The backdrop, rendered as a fixed sibling to the panel container -->
|
||||
<div class="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||
<!-- Full-screen container to center the panel -->
|
||||
<div class="fixed inset-0 flex w-screen items-center justify-center p-4">
|
||||
<DialogPanel class="max-w-sm rounded bg-base-100 w-9/10 p-6 shadow-md">
|
||||
<DialogTitle class="font-bold text-lg">
|
||||
<p class="w-full flex items-center gap-2">
|
||||
<CircleAlert class="text-red-500" />
|
||||
<span>
|
||||
{{ title || '提示' }}
|
||||
</span>
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription class="py-4">
|
||||
{{ desc }}
|
||||
</DialogDescription>
|
||||
<div class="mr-4 mt-4 flex justify-end">
|
||||
<button class="btn" @click="handleClose">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-header button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-footer button:first-child {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { IPersonConfig } from '@/types/storeType'
|
||||
import { rgba } from '@/utils/color'
|
||||
|
||||
export function useElementStyle(element: any, person: IPersonConfig, index: number, patternList: number[], patternColor: string, cardColor: string, cardSize: { width: number, height: number }, textSize: number, mod: 'default' | 'lucky' | 'sphere' = 'default', type: 'add' | 'change' = 'add') {
|
||||
if (patternList.includes(index + 1) && mod === 'default') {
|
||||
element.style.backgroundColor = rgba(patternColor, Math.random() * 0.2 + 0.8)
|
||||
}
|
||||
else if (mod === 'sphere' || mod === 'default') {
|
||||
element.style.backgroundColor = rgba(cardColor, Math.random() * 0.5 + 0.25)
|
||||
}
|
||||
else if (mod === 'lucky') {
|
||||
element.style.backgroundColor = rgba(cardColor, 0.8)
|
||||
}
|
||||
element.style.border = `1px solid ${rgba(cardColor, 0.25)}`
|
||||
element.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.5)}`
|
||||
element.style.width = `${cardSize.width}px`
|
||||
element.style.height = `${cardSize.height}px`
|
||||
if (mod === 'lucky') {
|
||||
element.className = 'lucky-element-card'
|
||||
}
|
||||
else {
|
||||
element.className = 'element-card'
|
||||
}
|
||||
if (type === 'add') {
|
||||
element.addEventListener('mouseenter', (ev: MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
target.style.border = `1px solid ${rgba(cardColor, 0.75)}`
|
||||
target.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.75)}`
|
||||
})
|
||||
element.addEventListener('mouseleave', (ev: MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
target.style.border = `1px solid ${rgba(cardColor, 0.25)}`
|
||||
target.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.5)}`
|
||||
})
|
||||
}
|
||||
element.children[0].style.fontSize = `${textSize * 0.5}px`
|
||||
if (person.uid) {
|
||||
element.children[0].textContent = person.uid
|
||||
}
|
||||
|
||||
element.children[1].style.fontSize = `${textSize}px`
|
||||
element.children[1].style.lineHeight = `${textSize * 3}px`
|
||||
element.children[1].style.textShadow = `0 0 12px ${rgba(cardColor, 0.95)}`
|
||||
if (person.name) {
|
||||
element.children[1].textContent = person.name
|
||||
}
|
||||
// element.children[2].style.fontSize = `${textSize * 0.5}px`
|
||||
// if (person.department || person.identity) {
|
||||
// element.children[2].innerHTML = `${person.department ? person.department : ''}<br/>${person.identity ? person.identity : ''}`
|
||||
// }
|
||||
|
||||
element.children[2].style.fontSize = `${textSize * 0.5}px`
|
||||
// 设置部门和身份的默认值
|
||||
element.children[2].innerHTML = ''
|
||||
if (person.department || person.identity) {
|
||||
element.children[2].innerHTML = `${person.department ? person.department : ''}<br/>${person.identity ? person.identity : ''}`
|
||||
}
|
||||
element.children[3].src = person.avatar
|
||||
return element
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 设置抽中卡片的位置
|
||||
* 最少一个,最大十个
|
||||
*/
|
||||
// TODO:不超过5个时:单行排列;超过5个时,6:上3下3;7:上3下4;8:上3下5;9:上4下5;10:上5下5
|
||||
export function useElementPosition(element: any, count: number, totalCount: number, cardSize: { width: number, height: number }, windowSize: { width: number, height: number }, cardIndex: number) {
|
||||
let xTable = 0
|
||||
let yTable = 0
|
||||
const centerPosition = {
|
||||
x: 0,
|
||||
y: windowSize.height / 2 - cardSize.height * 0.9,
|
||||
}
|
||||
// 有一行为偶数的特殊数量
|
||||
const specialPosition = [2, 4, 7, 9]
|
||||
// 不包含特殊值的 和 分两行中第一行为奇数值的
|
||||
if (!specialPosition.includes(totalCount) || (totalCount > 5 && cardIndex < 5)) {
|
||||
const index = cardIndex % 5
|
||||
if (index === 0) {
|
||||
xTable = centerPosition.x
|
||||
yTable = centerPosition.y - Math.floor(cardIndex / 5) * (cardSize.height + 60)
|
||||
}
|
||||
else {
|
||||
xTable = index % 2 === 0 ? Math.ceil(index / 2) * (cardSize.width + 100) : -Math.ceil(index / 2) * (cardSize.width + 100)
|
||||
yTable = centerPosition.y - Math.floor(cardIndex / 5) * (cardSize.height + 60)
|
||||
}
|
||||
}
|
||||
else {
|
||||
const index = cardIndex % 5
|
||||
if (index === 0) {
|
||||
xTable = centerPosition.x + (cardSize.width + 100) / 2
|
||||
yTable = centerPosition.y - Math.floor(cardIndex / 5) * (cardSize.height + 60)
|
||||
}
|
||||
else {
|
||||
xTable = index % 2 === 0 ? Math.ceil(index / 2) * (cardSize.width + 100) + (cardSize.width + 100) / 2 : -(Math.ceil(index / 2) * (cardSize.width + 100)) + (cardSize.width + 100) / 2
|
||||
yTable = centerPosition.y - Math.floor(cardIndex / 5) * (cardSize.height + 60)
|
||||
}
|
||||
}
|
||||
return { xTable, yTable }
|
||||
}
|
||||
323
src/hooks/useElement/index.ts
Normal file
323
src/hooks/useElement/index.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import type { IPersonConfig } from '@/types/storeType'
|
||||
import { rgba } from '@/utils/color'
|
||||
|
||||
interface IUseElementStyle {
|
||||
element: any
|
||||
person: IPersonConfig
|
||||
index: number
|
||||
patternList: number[]
|
||||
patternColor: string
|
||||
cardColor: string
|
||||
cardSize: { width: number, height: number }
|
||||
scale: number
|
||||
textSize: number
|
||||
mod: 'default' | 'lucky' | 'sphere'
|
||||
type?: 'add' | 'change'
|
||||
|
||||
}
|
||||
export function useElementStyle(props: IUseElementStyle) {
|
||||
const { element, person, index, patternList, patternColor, cardColor, cardSize, scale, textSize, mod, type } = props
|
||||
if (patternList.includes(index + 1) && mod === 'default') {
|
||||
element.style.backgroundColor = rgba(patternColor, Math.random() * 0.2 + 0.8)
|
||||
}
|
||||
else if (mod === 'sphere' || mod === 'default') {
|
||||
element.style.backgroundColor = rgba(cardColor, Math.random() * 0.5 + 0.25)
|
||||
}
|
||||
else if (mod === 'lucky') {
|
||||
element.style.backgroundColor = rgba(cardColor, 0.8)
|
||||
}
|
||||
element.style.border = `1px solid ${rgba(cardColor, 0.25)}`
|
||||
element.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.5)}`
|
||||
element.style.width = `${cardSize.width * scale}px`
|
||||
element.style.height = `${cardSize.height * scale}px`
|
||||
if (mod === 'lucky') {
|
||||
element.className = 'lucky-element-card'
|
||||
}
|
||||
else {
|
||||
element.className = 'element-card'
|
||||
}
|
||||
if (type === 'add') {
|
||||
element.addEventListener('mouseenter', (ev: MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
target.style.border = `1px solid ${rgba(cardColor, 0.75)}`
|
||||
target.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.75)}`
|
||||
})
|
||||
element.addEventListener('mouseleave', (ev: MouseEvent) => {
|
||||
const target = ev.target as HTMLElement
|
||||
target.style.border = `1px solid ${rgba(cardColor, 0.25)}`
|
||||
target.style.boxShadow = `0 0 12px ${rgba(cardColor, 0.5)}`
|
||||
})
|
||||
}
|
||||
element.children[0].style.fontSize = `${textSize * scale * 0.5}px`
|
||||
if (person.uid) {
|
||||
element.children[0].textContent = person.uid
|
||||
}
|
||||
|
||||
element.children[1].style.fontSize = `${textSize * scale}px`
|
||||
element.children[1].style.lineHeight = `${textSize * scale * 3}px`
|
||||
element.children[1].style.textShadow = `0 0 12px ${rgba(cardColor, 0.95)}`
|
||||
if (person.name) {
|
||||
element.children[1].textContent = person.name
|
||||
}
|
||||
|
||||
element.children[2].style.fontSize = `${textSize * scale * 0.5}px`
|
||||
// 设置部门和身份的默认值
|
||||
element.children[2].innerHTML = ''
|
||||
if (person.department || person.identity) {
|
||||
element.children[2].innerHTML = `${person.department ? person.department : ''}<br/>${person.identity ? person.identity : ''}`
|
||||
}
|
||||
element.children[3].src = person.avatar
|
||||
return element
|
||||
}
|
||||
interface CardRule {
|
||||
[key: number]: {
|
||||
maxLine: number
|
||||
scale: number
|
||||
rule: number[]
|
||||
length: number
|
||||
}
|
||||
}
|
||||
const cardRule: CardRule = {
|
||||
1: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [1],
|
||||
length: 1,
|
||||
},
|
||||
2: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [2],
|
||||
length: 1,
|
||||
},
|
||||
3: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [3],
|
||||
length: 1,
|
||||
},
|
||||
4: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [4],
|
||||
length: 1,
|
||||
},
|
||||
5: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [5],
|
||||
length: 1,
|
||||
},
|
||||
6: {
|
||||
maxLine: 3,
|
||||
scale: 2,
|
||||
rule: [3, 3],
|
||||
length: 2,
|
||||
},
|
||||
7: {
|
||||
maxLine: 4,
|
||||
scale: 2,
|
||||
rule: [3, 4],
|
||||
length: 2,
|
||||
},
|
||||
8: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [3, 5],
|
||||
length: 2,
|
||||
},
|
||||
9: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [4, 5],
|
||||
length: 2,
|
||||
},
|
||||
10: {
|
||||
maxLine: 5,
|
||||
scale: 2,
|
||||
rule: [5, 5],
|
||||
length: 2,
|
||||
},
|
||||
11: {
|
||||
maxLine: 6,
|
||||
scale: 1.8,
|
||||
rule: [5, 6],
|
||||
length: 2,
|
||||
},
|
||||
12: {
|
||||
maxLine: 6,
|
||||
scale: 1.8,
|
||||
rule: [6, 6],
|
||||
length: 2,
|
||||
},
|
||||
13: {
|
||||
maxLine: 7,
|
||||
scale: 1.6,
|
||||
rule: [6, 7],
|
||||
length: 2,
|
||||
},
|
||||
14: {
|
||||
maxLine: 7,
|
||||
scale: 1.6,
|
||||
rule: [7, 7],
|
||||
length: 2,
|
||||
},
|
||||
15: {
|
||||
maxLine: 8,
|
||||
scale: 1.5,
|
||||
rule: [7, 8],
|
||||
length: 2,
|
||||
},
|
||||
16: {
|
||||
maxLine: 8,
|
||||
scale: 1.5,
|
||||
rule: [8, 8],
|
||||
length: 2,
|
||||
},
|
||||
17: {
|
||||
maxLine: 6,
|
||||
scale: 1.8,
|
||||
rule: [5, 6, 6],
|
||||
length: 3,
|
||||
},
|
||||
18: {
|
||||
maxLine: 6,
|
||||
scale: 1.8,
|
||||
rule: [6, 6, 6],
|
||||
length: 3,
|
||||
},
|
||||
19: {
|
||||
maxLine: 7,
|
||||
scale: 1.6,
|
||||
rule: [6, 6, 7],
|
||||
length: 3,
|
||||
},
|
||||
20: {
|
||||
maxLine: 5,
|
||||
scale: 1.6,
|
||||
rule: [6, 7, 7],
|
||||
length: 3,
|
||||
},
|
||||
21: {
|
||||
maxLine: 7,
|
||||
scale: 1.6,
|
||||
rule: [7, 7, 7],
|
||||
length: 3,
|
||||
},
|
||||
22: {
|
||||
maxLine: 8,
|
||||
scale: 1.5,
|
||||
rule: [7, 7, 8],
|
||||
length: 3,
|
||||
},
|
||||
23: {
|
||||
maxLine: 8,
|
||||
scale: 1.5,
|
||||
rule: [7, 8, 8],
|
||||
length: 3,
|
||||
},
|
||||
24: {
|
||||
maxLine: 8,
|
||||
scale: 1.5,
|
||||
rule: [8, 8, 8],
|
||||
length: 3,
|
||||
},
|
||||
25: {
|
||||
maxLine: 9,
|
||||
scale: 1.3,
|
||||
rule: [8, 8, 9],
|
||||
length: 3,
|
||||
},
|
||||
26: {
|
||||
maxLine: 9,
|
||||
scale: 1.3,
|
||||
rule: [8, 9, 9],
|
||||
length: 3,
|
||||
},
|
||||
27: {
|
||||
maxLine: 9,
|
||||
scale: 1.3,
|
||||
rule: [9, 9, 9],
|
||||
length: 3,
|
||||
},
|
||||
28: {
|
||||
maxLine: 10,
|
||||
scale: 1.2,
|
||||
rule: [9, 9, 10],
|
||||
length: 3,
|
||||
},
|
||||
29: {
|
||||
maxLine: 10,
|
||||
scale: 1.2,
|
||||
rule: [9, 10, 10],
|
||||
length: 3,
|
||||
},
|
||||
30: {
|
||||
maxLine: 10,
|
||||
scale: 1.2,
|
||||
rule: [10, 10, 10],
|
||||
length: 3,
|
||||
},
|
||||
}
|
||||
/**
|
||||
* @description 设置抽中卡片的位置
|
||||
*/
|
||||
export function useElementPosition(
|
||||
element: any,
|
||||
count: number,
|
||||
totalCount: number,
|
||||
cardSize: { width: number, height: number },
|
||||
windowSize: { width: number, height: number },
|
||||
cardIndex: number,
|
||||
): {
|
||||
xTable: number
|
||||
yTable: number
|
||||
scale: number
|
||||
} {
|
||||
let xTable = 0
|
||||
let yTable = 0
|
||||
const centerPosition = {
|
||||
x: 0,
|
||||
y: windowSize.height / 2,
|
||||
}
|
||||
const { scale, rule, length } = cardRule[totalCount]
|
||||
// 计算缩放后的卡片尺寸
|
||||
const scaledCardWidth = cardSize.width * scale
|
||||
const scaledCardHeight = cardSize.height * scale
|
||||
// 计算当前卡片在第几行(从0开始)
|
||||
let currentRow = 0
|
||||
let cardIndexInRow = cardIndex // 当前卡片在其所在行中的索引
|
||||
|
||||
// 根据规则确定卡片在哪一行及行内索引
|
||||
let cumulativeCount = 0
|
||||
for (let i = 0; i < rule.length; i++) {
|
||||
if (cardIndex < cumulativeCount + rule[i]) {
|
||||
currentRow = i
|
||||
cardIndexInRow = cardIndex - cumulativeCount
|
||||
break
|
||||
}
|
||||
cumulativeCount += rule[i]
|
||||
}
|
||||
|
||||
// 计算当前行的卡片数量
|
||||
const cardsInCurrentRow = rule[currentRow]
|
||||
|
||||
// 计算每行的垂直中心位置
|
||||
const verticalSpacing = scaledCardHeight * 1.1 // 垂直间距基于缩放后的高度
|
||||
// 计算整体高度并调整居中
|
||||
const totalHeight = (length - 1) * verticalSpacing + scaledCardHeight // 包含卡片本身的高度
|
||||
const centerYOffset = -totalHeight / 2
|
||||
|
||||
// 修改此处逻辑,确保当length=2时,两行围绕中心点对称分布
|
||||
centerPosition.y = windowSize.height / 2 - totalHeight / 2
|
||||
|
||||
yTable = centerPosition.y + currentRow * verticalSpacing + centerYOffset + scaledCardHeight / 2 // 添加卡片高度的一半作为修正
|
||||
// 计算当前行的水平居中偏移
|
||||
const horizontalSpacing = scaledCardWidth * 1.2 // 水平间距基于缩放后的宽度
|
||||
const rowWidth = (cardsInCurrentRow - 1) * horizontalSpacing
|
||||
const offsetX = -rowWidth / 2 // 行内水平居中
|
||||
|
||||
xTable = centerPosition.x + offsetX + cardIndexInRow * horizontalSpacing
|
||||
|
||||
return { xTable, yTable, scale }
|
||||
}
|
||||
28
src/hooks/useTimerWorker/index.ts
Normal file
28
src/hooks/useTimerWorker/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
import TimerWorker from './timerWorker.ts?worker'
|
||||
|
||||
export function useTimerWorker(interval: number) {
|
||||
let timerWorker: Worker | null = null
|
||||
const init = (callback: () => void) => {
|
||||
close()
|
||||
timerWorker = new TimerWorker()
|
||||
timerWorker.postMessage({ interval })
|
||||
if (timerWorker.onmessage)
|
||||
return
|
||||
timerWorker.addEventListener('message', () => callback())
|
||||
}
|
||||
|
||||
function close() {
|
||||
timerWorker?.terminate()
|
||||
timerWorker = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
close()
|
||||
})
|
||||
|
||||
return {
|
||||
init,
|
||||
close,
|
||||
}
|
||||
}
|
||||
12
src/hooks/useTimerWorker/timerworker.ts
Normal file
12
src/hooks/useTimerWorker/timerworker.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
let intervalId: number | null = null
|
||||
self.addEventListener('message', (e) => {
|
||||
const { interval } = e.data
|
||||
if (!interval)
|
||||
throw new Error('invalid params')
|
||||
if (intervalId)
|
||||
clearInterval(intervalId)
|
||||
intervalId = setInterval(() => {
|
||||
self.postMessage(true)
|
||||
}, interval)
|
||||
})
|
||||
114
src/hooks/useWebsocket.ts
Normal file
114
src/hooks/useWebsocket.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { IMsgType } from '@/types/msgType'
|
||||
import type { WsMsgData } from '@/types/storeType'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useTimerWorker } from './useTimerWorker'
|
||||
|
||||
export function useWebsocket() {
|
||||
const { init: initWorker, close: closeWorker } = useTimerWorker(30 * 1000)
|
||||
const status = ref<{ status: WebSocket['readyState'], connected: boolean }>()
|
||||
const data = ref<WsMsgData>()
|
||||
const registration = ref<ServiceWorkerRegistration | null>(null)
|
||||
async function registerSW() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
registration.value = await navigator.serviceWorker.register('/log-lottery/sw.js')
|
||||
console.log('Service Worker 注册成功:', registration)
|
||||
listenSWMessage()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Service Worker 注册失败:', error)
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error('浏览器不支持 Service Worker')
|
||||
}
|
||||
}
|
||||
|
||||
function open(url: string) {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.active?.postMessage({
|
||||
type: 'CONNECT_WS',
|
||||
payload: { url },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
closeWorker()
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.active?.postMessage({
|
||||
type: 'DISCONNECT_WS',
|
||||
})
|
||||
})
|
||||
}
|
||||
function send(message: string) {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.active?.postMessage({
|
||||
type: 'SEND_WS_MESSAGE',
|
||||
payload: { message, id: uuidv4() },
|
||||
})
|
||||
})
|
||||
}
|
||||
function getStatus() {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.active?.postMessage({
|
||||
type: 'GET_WS_STATUS',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 监听service worker消息
|
||||
function listenSWMessage() {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const msgType = event.data.type
|
||||
switch (msgType) {
|
||||
case 'WS_STATUS':
|
||||
status.value = event.data.payload
|
||||
break
|
||||
case 'WS_MESSAGE':{
|
||||
const receivedMsg: IMsgType = event.data.payload as IMsgType
|
||||
data.value = {
|
||||
...receivedMsg,
|
||||
id: uuidv4(),
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'WS_ERROR':
|
||||
console.error('ws error:', event.data.payload)
|
||||
status.value = {
|
||||
status: WebSocket.CLOSED,
|
||||
connected: false,
|
||||
}
|
||||
closeWorker()
|
||||
break
|
||||
case 'WS_CLOSE':
|
||||
status.value = {
|
||||
status: WebSocket.CLOSED,
|
||||
connected: false,
|
||||
}
|
||||
closeWorker()
|
||||
break
|
||||
case 'WS_OPEN':
|
||||
status.value = {
|
||||
status: WebSocket.OPEN,
|
||||
connected: true,
|
||||
}
|
||||
initWorker(getStatus)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerSW()
|
||||
getStatus()
|
||||
})
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
send,
|
||||
status,
|
||||
data,
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
<script setup lang='ts'>
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import { Maximize, Minimize } from 'lucide-vue-next'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useQRCode } from '@vueuse/integrations/useQRCode'
|
||||
import { Maximize, Minimize, TabletSmartphone } from 'lucide-vue-next'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import CustomDialog from '@/components/Dialog/index.vue'
|
||||
import useStore from '@/store'
|
||||
import { getOriginUrl, getUniqueSignature } from '@/utils/auth'
|
||||
import { usePlayMusic } from './usePlayMusic'
|
||||
|
||||
const serverConfig = useStore().serverConfig
|
||||
const {
|
||||
getServerStatus: serverStatus,
|
||||
} = storeToRefs(serverConfig)
|
||||
const { playMusic, currentMusic, nextPlay } = usePlayMusic()
|
||||
const { isFullscreen, toggle: toggleScreen } = useFullscreen()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const customDialogRef = ref()
|
||||
const settingRef = ref()
|
||||
const fullScreenRef = ref()
|
||||
const mobileUrl = shallowRef<string>('')
|
||||
const qrCodeImg = useQRCode(mobileUrl)
|
||||
const visible = ref(true)
|
||||
|
||||
function enterConfig() {
|
||||
router.push('/log-lottery/config')
|
||||
@@ -21,7 +34,26 @@ function enterConfig() {
|
||||
function enterHome() {
|
||||
router.push('/log-lottery')
|
||||
}
|
||||
async function openMobileQrCode() {
|
||||
const originUrl = getOriginUrl()
|
||||
const userSignature = await getUniqueSignature()
|
||||
mobileUrl.value = `${originUrl}/log-lottery/mobile?userSignature=${userSignature}`
|
||||
customDialogRef.value.showDialog()
|
||||
}
|
||||
function handleSubmit() {
|
||||
|
||||
}
|
||||
|
||||
watch(() => route, (val) => {
|
||||
const { meta } = val
|
||||
if (meta && meta.isMobile) {
|
||||
visible.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
onMounted(() => {
|
||||
if (!settingRef.value) {
|
||||
return
|
||||
}
|
||||
settingRef.value.addEventListener('mouseenter', () => {
|
||||
fullScreenRef.value.style.display = 'block'
|
||||
})
|
||||
@@ -32,7 +64,20 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="settingRef" class="flex flex-col gap-3">
|
||||
<div v-if="visible" ref="settingRef" class="flex flex-col gap-3">
|
||||
<CustomDialog
|
||||
ref="customDialogRef"
|
||||
title=""
|
||||
:submit-func="handleSubmit"
|
||||
footer="center"
|
||||
dialog-class="h-120 p-6"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex w-full justify-center h-90">
|
||||
<img :src="qrCodeImg" alt="qr code">
|
||||
</div>
|
||||
</template>
|
||||
</CustomDialog>
|
||||
<div ref="fullScreenRef" class="tooltip tooltip-left hidden" @click="toggleScreen">
|
||||
<div
|
||||
v-if="isFullscreen"
|
||||
@@ -71,6 +116,11 @@ onMounted(() => {
|
||||
<svg-icon :name="currentMusic.paused ? 'play' : 'pause'" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="serverStatus" class="tooltip tooltip-left" data-tip="访问手机端">
|
||||
<div class="flex items-center justify-center w-10 h-10 p-0 m-0 cursor-pointer setting-container bg-slate-500/50 rounded-l-xl hover:bg-slate-500/80 hover:text-blue-400/90" @click="openMobileQrCode">
|
||||
<TabletSmartphone />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, provide, ref } from 'vue'
|
||||
import { onMounted, provide, ref, toRaw, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { loadingKey, loadingState } from '@/components/Loading'
|
||||
import { useWebsocket } from '@/hooks/useWebsocket'
|
||||
import useStore from '@/store'
|
||||
import { themeChange } from '@/utils'
|
||||
import { IndexDb } from '@/utils/dexie'
|
||||
|
||||
export function useMounted(tipDialog: Ref<any>) {
|
||||
provide(loadingKey, loadingState)
|
||||
@@ -15,6 +18,9 @@ export function useMounted(tipDialog: Ref<any>) {
|
||||
const { getPrizeConfig: prizeList, getTemporaryPrize: temporaryPrize } = storeToRefs(prizeConfig)
|
||||
const tipDesc = ref('')
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const { data } = useWebsocket()
|
||||
const msgListDb = new IndexDb('msgList', ['msgList'], 1, ['createTime'])
|
||||
// 设置当前奖列表
|
||||
function setCurrentPrize() {
|
||||
if (prizeList.value.length <= 0) {
|
||||
@@ -52,10 +58,26 @@ export function useMounted(tipDialog: Ref<any>) {
|
||||
|
||||
return isChrome || isEdge
|
||||
}
|
||||
const isShowMobileWarn = () => {
|
||||
const isMobilePage = judgeMobile()
|
||||
const { meta } = route
|
||||
let allowMobile = false
|
||||
if (meta && meta.isMobile) {
|
||||
allowMobile = true
|
||||
}
|
||||
return !allowMobile && isMobilePage
|
||||
}
|
||||
|
||||
watch(() => data.value, (newValue) => {
|
||||
if (!newValue) {
|
||||
return
|
||||
}
|
||||
msgListDb.setData('msgList', toRaw(newValue))
|
||||
}, { immediate: true, deep: true })
|
||||
onMounted(() => {
|
||||
themeChange(localTheme.value.name)
|
||||
setCurrentPrize()
|
||||
if (judgeMobile()) {
|
||||
if (isShowMobileWarn()) {
|
||||
tipDialog.value.showDialog()
|
||||
tipDesc.value = t('dialog.dialogPCWeb')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const sidebarEn = {
|
||||
imagesManagement: 'Images Management',
|
||||
musicManagement: 'Music Management',
|
||||
operatingInstructions: 'Operating Instructions',
|
||||
server: 'Server',
|
||||
}
|
||||
|
||||
export const sidebarZhCn = {
|
||||
@@ -20,6 +21,7 @@ export const sidebarZhCn = {
|
||||
imagesManagement: '图片管理',
|
||||
musicManagement: '音乐管理',
|
||||
operatingInstructions: '操作说明',
|
||||
server: '服务器',
|
||||
}
|
||||
|
||||
export const sidebar = {
|
||||
|
||||
@@ -51,4 +51,8 @@ pinia.use(piniaPluginPersist)
|
||||
|
||||
app.config.globalProperties.$THREE = THREE // 挂载到原型
|
||||
app.component('svg-icon', svgIcon)
|
||||
app.use(router).use(VueDOMPurifyHTML).use(pinia).use(i18n).mount('#app')
|
||||
app.use(router)
|
||||
app.use(VueDOMPurifyHTML)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -101,6 +101,15 @@ export const configRoutes = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/log-lottery/config/server',
|
||||
name: 'Server',
|
||||
component: () => import('@/views/Config/Server/index.vue'),
|
||||
meta: {
|
||||
title: i18n.global.t('sidebar.server'),
|
||||
icon: 'server',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/log-lottery/config/readme',
|
||||
name: 'Readme',
|
||||
@@ -132,6 +141,14 @@ const routes = [
|
||||
name: 'Demo',
|
||||
component: () => import('@/views/Demo/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/log-lottery/mobile',
|
||||
name: 'Mobile',
|
||||
meta: {
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/Mobile/index.vue'),
|
||||
},
|
||||
configRoutes,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IPersonConfig, IPrizeConfig } from '@/types/storeType'
|
||||
import { id } from 'zod/v4/locales'
|
||||
|
||||
const originUrl = 'https://to2026.xyz'
|
||||
type IPersonConfigWithoutUuid = Omit<IPersonConfig, 'uuid'>
|
||||
@@ -279,3 +280,24 @@ export const defaultImageList = [
|
||||
},
|
||||
]
|
||||
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]
|
||||
|
||||
export const defaultServerHostList = [
|
||||
{
|
||||
id: 'default',
|
||||
name: '默认服务器',
|
||||
value: 'default',
|
||||
host: 'https://to2026.xyz:8080',
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: '本地服务器',
|
||||
value: 'local',
|
||||
host: 'http://127.0.0.1:8080',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: '自定义服务器',
|
||||
value: 'custom',
|
||||
host: '',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGlobalConfig } from './globalConfig'
|
||||
import { usePersonConfig } from './personConfig'
|
||||
import { usePrizeConfig } from './prizeConfig'
|
||||
import { useServerConfig } from './serverConfig'
|
||||
import { useSystem } from './system'
|
||||
|
||||
export default function useStore() {
|
||||
@@ -9,5 +10,6 @@ export default function useStore() {
|
||||
prizeConfig: usePrizeConfig(),
|
||||
globalConfig: useGlobalConfig(),
|
||||
system: useSystem(),
|
||||
serverConfig: useServerConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
67
src/store/serverConfig.ts
Normal file
67
src/store/serverConfig.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ServerType } from '@/types/storeType'
|
||||
import { defineStore } from 'pinia'
|
||||
import { defaultServerHostList } from './data'
|
||||
|
||||
export const useServerConfig = defineStore('server', {
|
||||
state() {
|
||||
return {
|
||||
serverConfig: {
|
||||
serverList: defaultServerHostList,
|
||||
currentServer: defaultServerHostList[0],
|
||||
serverStatus: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
// 获取服务器列表
|
||||
getServerList(state) {
|
||||
return state.serverConfig.serverList
|
||||
},
|
||||
// 获取当前服务器
|
||||
getCurrentServer(state) {
|
||||
return state.serverConfig.currentServer
|
||||
},
|
||||
// 获取服务器状态
|
||||
getServerStatus(state) {
|
||||
return state.serverConfig.serverStatus
|
||||
},
|
||||
|
||||
},
|
||||
actions: {
|
||||
// 设置服务器列表地址
|
||||
updateServerList(userServer: ServerType) {
|
||||
this.serverConfig.serverList.map((item) => {
|
||||
if (item.id === userServer.id) {
|
||||
item.host = userServer.host
|
||||
}
|
||||
return item
|
||||
})
|
||||
},
|
||||
// 设置当前服务器
|
||||
setCurrentServer(userServer: ServerType) {
|
||||
this.serverConfig.currentServer = userServer
|
||||
},
|
||||
// 设置服务器状态
|
||||
setServerStatus(status: boolean) {
|
||||
this.serverConfig.serverStatus = status
|
||||
},
|
||||
// 重置所有配置
|
||||
resetDefault() {
|
||||
this.serverConfig = {
|
||||
serverList: defaultServerHostList,
|
||||
currentServer: defaultServerHostList[0],
|
||||
serverStatus: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
enabled: true,
|
||||
strategies: [
|
||||
{
|
||||
// 如果要存储在localStorage中
|
||||
storage: localStorage,
|
||||
key: 'serverConfig',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
cursor: default;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
|
||||
.card-id {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -21,7 +22,7 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-avatar-name{
|
||||
.card-avatar-name {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0px;
|
||||
@@ -37,7 +38,7 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-avatar{
|
||||
.card-avatar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -52,6 +53,8 @@
|
||||
|
||||
.lucky-element-card {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
&::before {
|
||||
background-color: linear-gradient(-45deg, #e81cff 0%, #40c9ff 100%);
|
||||
border: 1px solid linear-gradient(-45deg, #e81cff 0%, #40c9ff 100%);
|
||||
@@ -79,7 +82,8 @@
|
||||
// animation: tracking-in-expand-fwd 0.8s cubic-bezier(0.215, 0.61, 0.355, 1) both;
|
||||
// animation-delay: 0.6s;
|
||||
}
|
||||
.card-avatar-name{
|
||||
|
||||
.card-avatar-name {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0px;
|
||||
@@ -96,7 +100,7 @@
|
||||
bottom: 15px;
|
||||
}
|
||||
|
||||
.card-avatar{
|
||||
.card-avatar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -107,4 +111,4 @@
|
||||
height: 240px !important;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/types/msgType.ts
Normal file
6
src/types/msgType.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface IMsgType {
|
||||
data: string
|
||||
image?: string | Blob | ArrayBuffer // TODO
|
||||
dateTime: string
|
||||
user?: string // TODO
|
||||
}
|
||||
@@ -52,3 +52,11 @@ export interface IImage {
|
||||
name: string
|
||||
url: string | Blob | ArrayBuffer
|
||||
}
|
||||
|
||||
export interface WsMsgData { data: string, id: string, dateTime: string }
|
||||
export interface ServerType {
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
host: string
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs'
|
||||
|
||||
export function getToken() {
|
||||
return window.localStorage.getItem('userToken')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户浏览器唯一标识
|
||||
* @returns {Promise<string>} 唯一标识符
|
||||
*/
|
||||
export async function getUniqueSignature() {
|
||||
const fp = await FingerprintJS.load()
|
||||
const result = await fp.get()
|
||||
return result.visitorId
|
||||
}
|
||||
|
||||
// 获取origin url
|
||||
export function getOriginUrl() {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { EntityTable } from 'dexie'
|
||||
import type { DbData } from './type'
|
||||
import dayjs from 'dayjs'
|
||||
import Dexie from 'dexie'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
class IndexDb {
|
||||
name: string
|
||||
@@ -38,6 +39,10 @@ class IndexDb {
|
||||
if (!data.type) {
|
||||
data.type = 'info'
|
||||
}
|
||||
if (!data.id) {
|
||||
data.id = uuidv4()
|
||||
}
|
||||
|
||||
this.dbStore[tableName].add(data)
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useViewModel({ exportInputFileRef }: { exportInputFileRef: Ref<H
|
||||
}
|
||||
}
|
||||
function downloadTemplate() {
|
||||
// 下载
|
||||
// 下载
|
||||
const templateFileName = i18n.global.t('data.xlsxName')
|
||||
const fileUrl = `${baseUrl}${templateFileName}`
|
||||
fetch(fileUrl)
|
||||
@@ -160,6 +160,11 @@ export function useViewModel({ exportInputFileRef }: { exportInputFileRef: Ref<H
|
||||
const dataBinaryBinary = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(dataBinaryBinary, dataBinary, 'Sheet1')
|
||||
XLSX.writeFile(dataBinaryBinary, 'data.xlsx')
|
||||
toast.open({
|
||||
message: t('error.exportSuccess'),
|
||||
type: 'success',
|
||||
position: 'top-right',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/views/Config/Server/index.vue
Normal file
32
src/views/Config/Server/index.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang='ts'>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PageHeader from '@/components/PageHeader/index.vue'
|
||||
import MsgListContainer from './parts/MsgListContainer.vue'
|
||||
import ServerSetting from './parts/ServerSetting.vue'
|
||||
import { useViewModel } from './useViewModel'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { serverList, currentServerValue, wsStatus, handleConnectWs, closeWs, msgList } = useViewModel()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="t('sidebar.server')" />
|
||||
<div>
|
||||
<ServerSetting
|
||||
v-model:current-server="currentServerValue"
|
||||
:server-list="serverList"
|
||||
:ws-status="wsStatus"
|
||||
:open-ws="handleConnectWs"
|
||||
:close-ws="closeWs"
|
||||
/>
|
||||
<MsgListContainer
|
||||
:msg-list="msgList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
29
src/views/Config/Server/parts/MsgListContainer.vue
Normal file
29
src/views/Config/Server/parts/MsgListContainer.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang='ts'>
|
||||
import type { WsMsgData } from '@/types/storeType'
|
||||
|
||||
interface Props {
|
||||
msgList: WsMsgData[]
|
||||
}
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-1/2 h-1/2 border rounded-md shadow-lg">
|
||||
<ul>
|
||||
<li v-for="item in msgList" :key="item.id" class="mb-3">
|
||||
<div class="chat chat-end">
|
||||
<div class="chat-header">
|
||||
<time class="text-xs opacity-50">{{ item.dateTime }}</time>
|
||||
</div>
|
||||
<div class="chat-bubble break-all whitespace-normal">
|
||||
{{ item.data }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
107
src/views/Config/Server/parts/ServerSetting.vue
Normal file
107
src/views/Config/Server/parts/ServerSetting.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang='ts'>
|
||||
import type { ServerType } from '@/types/storeType'
|
||||
import { ref, watch } from 'vue'
|
||||
// import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
serverList: ServerType[]
|
||||
wsStatus: { status: WebSocket['readyState'], connected: boolean } | undefined
|
||||
openWs: () => void
|
||||
closeWs: () => void
|
||||
}
|
||||
defineProps<Props>()
|
||||
|
||||
const currentServer = defineModel<ServerType>('currentServer', { required: true })
|
||||
const hostValue = ref('')
|
||||
// const { t } = useI18n()
|
||||
// 监听 currentServer 的 id 变化,重置 hostValue
|
||||
watch(() => currentServer.value?.id, (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
hostValue.value = currentServer.value?.host || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 hostValue 变化,同步更新 currentServer.host
|
||||
watch(hostValue, (newHost) => {
|
||||
if (currentServer.value) {
|
||||
currentServer.value.host = newHost
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化 hostValue
|
||||
hostValue.value = currentServer.value?.host || ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs pb-10">
|
||||
<legend class="fieldset-legend">
|
||||
弹幕服务
|
||||
</legend>
|
||||
|
||||
<label class="flex flex-row items-center form-control">
|
||||
<div class="">
|
||||
<div class="label flex flex-col justify-start items-start">
|
||||
<label class="label">
|
||||
<span class="label-text text-left">弹幕服务地址</span>
|
||||
<div class="tooltip" data-tip="改变弹幕服务地址后会断开连接">
|
||||
<button class="btn btn-circle h-4 hover:bg-base-300">
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="radio-group flex gap-9">
|
||||
<ul class="flex gap-3">
|
||||
<li v-for="item in serverList" :key="item.id" class="flex flex-col">
|
||||
<label for="default-server">{{ item.name }}</label>
|
||||
<input id="default-server" type="radio" name="radio-1" class="radio" :checked="currentServer?.value === item.value" @change="currentServer = item">
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入服务地址"
|
||||
:disabled="currentServer.value === 'default'"
|
||||
class="w-full max-w-xs input input-bordered"
|
||||
:value="hostValue"
|
||||
@input="hostValue = ($event.target as HTMLInputElement).value"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-row items-center form-control">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="label">
|
||||
<span class="label-text">弹幕服务器连接状态</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="ws-status">
|
||||
<div v-if="wsStatus && wsStatus.connected">
|
||||
<div aria-label="success" class="status status-success" />
|
||||
<span>已连接</span>
|
||||
</div>
|
||||
<div v-else-if="wsStatus && wsStatus.connected === false">
|
||||
<div aria-label="error" class="status status-error" />
|
||||
<span>已断开</span>
|
||||
</div>
|
||||
<div v-else-if="wsStatus && wsStatus.status">
|
||||
<div aria-label="error" class="status status-error" />
|
||||
<span>操作中</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div aria-label="warning" class="status status-warning" />
|
||||
<span>未连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button v-if="wsStatus?.connected === true" class="btn btn-error btn-sm" @click="closeWs">断开</button>
|
||||
<button v-else class="btn btn-primary btn-sm" @click="openWs">连接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
68
src/views/Config/Server/useViewModel.ts
Normal file
68
src/views/Config/Server/useViewModel.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ServerType, WsMsgData } from '@/types/storeType'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useWebsocket } from '@/hooks/useWebsocket'
|
||||
import useStore from '@/store'
|
||||
import { getUniqueSignature } from '@/utils/auth'
|
||||
import { IndexDb } from '@/utils/dexie'
|
||||
|
||||
export function useViewModel() {
|
||||
const serverConfig = useStore().serverConfig
|
||||
const {
|
||||
getServerList: serverList,
|
||||
getCurrentServer: currentServer,
|
||||
} = storeToRefs(serverConfig)
|
||||
const currentServerValue = ref<ServerType>(cloneDeep(currentServer.value))
|
||||
const wsUrl = ref<string>('ws://localhost:8080/echo')
|
||||
const msgList = ref<WsMsgData[]>([])
|
||||
const { open: openWs, close: closeWs, status: wsStatus } = useWebsocket()
|
||||
const msgListDb = new IndexDb('msgList', ['msgList'], 1, ['createTime'])
|
||||
const handleConnectWs = async () => {
|
||||
const userSignature = await getUniqueSignature()
|
||||
wsUrl.value = `ws://localhost:8080/echo?userSignature=${userSignature}`
|
||||
openWs(wsUrl.value)
|
||||
}
|
||||
const getAllMsg = async () => {
|
||||
msgListDb.getDataSortedByDateTime('msgList', 'dateTime').then((data) => {
|
||||
msgList.value = data
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentServerValue.value.id,
|
||||
(newValue) => {
|
||||
serverList.value.forEach((item) => {
|
||||
if (item.id === newValue) {
|
||||
currentServerValue.value = item
|
||||
serverConfig.setCurrentServer(currentServerValue.value)
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
watch(() => currentServer.value.host, (newValue) => {
|
||||
currentServerValue.value.host = newValue
|
||||
serverConfig.updateServerList(currentServerValue.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => wsStatus.value,
|
||||
(newValue) => {
|
||||
if (newValue && (newValue.connected === true || newValue.connected === false)) {
|
||||
serverConfig.setServerStatus(newValue.connected)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
getAllMsg()
|
||||
})
|
||||
return {
|
||||
serverList,
|
||||
currentServerValue,
|
||||
wsStatus,
|
||||
handleConnectWs,
|
||||
closeWs,
|
||||
msgList,
|
||||
}
|
||||
}
|
||||
@@ -1,241 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { api_sendMsg } from '@/api/msg'
|
||||
import { getOriginUrl, getUniqueSignature } from '@/utils/auth'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
const toast = useToast()
|
||||
const mobileUrl = shallowRef<string>('')
|
||||
const wsQuery = ref<{ userSignature: string }>({
|
||||
userSignature: '',
|
||||
})
|
||||
|
||||
const list = ref<any[]>([])
|
||||
|
||||
list.value = [{
|
||||
label: 1,
|
||||
value: 1,
|
||||
color: 'red',
|
||||
}, {
|
||||
label: 2,
|
||||
value: 2,
|
||||
color: 'blue',
|
||||
}, {
|
||||
label: 3,
|
||||
value: 3,
|
||||
color: 'yellow',
|
||||
}, {
|
||||
label: 4,
|
||||
value: 4,
|
||||
color: 'green',
|
||||
}, {
|
||||
label: 5,
|
||||
value: 5,
|
||||
color: 'pink',
|
||||
}, {
|
||||
label: 6,
|
||||
value: 6,
|
||||
color: 'orange',
|
||||
}, {
|
||||
label: 7,
|
||||
value: 7,
|
||||
color: 'purple',
|
||||
}, {
|
||||
label: 8,
|
||||
value: 8,
|
||||
color: 'brown',
|
||||
}, {
|
||||
label: 9,
|
||||
value: 9,
|
||||
color: 'gray',
|
||||
}, {
|
||||
label: 10,
|
||||
value: 10,
|
||||
color: 'cyan',
|
||||
}, {
|
||||
label: 11,
|
||||
value: 11,
|
||||
color: 'white',
|
||||
}, {
|
||||
label: 12,
|
||||
value: 12,
|
||||
color: 'black',
|
||||
}, {
|
||||
label: 13,
|
||||
value: 13,
|
||||
color: 'orange',
|
||||
}, {
|
||||
label: 14,
|
||||
value: 14,
|
||||
color: 'yellow',
|
||||
}, {
|
||||
label: 15,
|
||||
value: 14,
|
||||
color: 'pink',
|
||||
}, {
|
||||
label: 15,
|
||||
value: 15,
|
||||
color: 'orange',
|
||||
}, {
|
||||
label: 16,
|
||||
value: 16,
|
||||
color: 'yellow',
|
||||
}, {
|
||||
label: 17,
|
||||
value: 17,
|
||||
color: 'green',
|
||||
}, {
|
||||
label: 18,
|
||||
value: 18,
|
||||
color: 'purple',
|
||||
}]
|
||||
|
||||
// 为每个 li 元素创建引用
|
||||
const liRefs = ref()
|
||||
const scrollContainerRef = ref()
|
||||
const ctx = ref()
|
||||
const showUpButton = ref(false)
|
||||
const showDownButton = ref(true)
|
||||
|
||||
function initGsapAnimation() {
|
||||
ctx.value = gsap.context(() => {
|
||||
liRefs.value.forEach((box: any) => {
|
||||
gsap.fromTo(box, { rotationX: -90, rotateZ: -20, opacity: 0 }, {
|
||||
rotationX: 0,
|
||||
rotateZ: 0,
|
||||
opacity: 1,
|
||||
scrollTrigger: {
|
||||
trigger: box,
|
||||
scroller: scrollContainerRef.value, // <- Specify the scroller!
|
||||
start: 'bottom 100%',
|
||||
end: 'top 70%',
|
||||
scrub: true,
|
||||
},
|
||||
})
|
||||
function connectUserMsg() {
|
||||
api_sendMsg(wsQuery.value.userSignature, `hello world ${wsQuery.value.userSignature}`).then((res: any) => {
|
||||
toast.open({
|
||||
message: res.msg || '发送成功',
|
||||
type: 'success',
|
||||
position: 'top-right',
|
||||
})
|
||||
}, scrollContainerRef.value) // <- Scope!
|
||||
})
|
||||
}
|
||||
|
||||
function disposeGsapAnimation() {
|
||||
ctx.value.revert() // <- Easy Cleanup!
|
||||
async function getFinger() {
|
||||
const userSignature = await getUniqueSignature()
|
||||
wsQuery.value.userSignature = userSignature
|
||||
return userSignature
|
||||
}
|
||||
function scrollHandler() {
|
||||
const scrollHeight = scrollContainerRef.value.scrollHeight
|
||||
const scrollTop = scrollContainerRef.value.scrollTop
|
||||
const containerHeight = scrollContainerRef.value.clientHeight
|
||||
// 滚动滑到底部
|
||||
if (scrollTop + containerHeight >= scrollHeight) {
|
||||
showDownButton.value = false
|
||||
showUpButton.value = true
|
||||
}
|
||||
// 在中间
|
||||
else if (scrollTop && scrollTop + containerHeight < scrollHeight) {
|
||||
showDownButton.value = true
|
||||
showUpButton.value = true
|
||||
}
|
||||
// 滚动滑到顶部
|
||||
else {
|
||||
showDownButton.value = true
|
||||
showUpButton.value = false
|
||||
}
|
||||
}
|
||||
function listenScrollContainer() {
|
||||
scrollContainerRef.value.addEventListener('scroll', scrollHandler)
|
||||
}
|
||||
function removeScrollContainer() {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.removeEventListener('scroll', scrollHandler)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll(h: number) {
|
||||
scrollContainerRef.value.scrollTop += h
|
||||
async function setMobileUrl() {
|
||||
const originUrl = getOriginUrl()
|
||||
const userSignature = await getFinger()
|
||||
mobileUrl.value = `${originUrl}/log-lottery/mobile?userSignature=${userSignature}`
|
||||
}
|
||||
onMounted(() => {
|
||||
initGsapAnimation()
|
||||
listenScrollContainer()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
removeScrollContainer()
|
||||
getFinger()
|
||||
setMobileUrl()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeGsapAnimation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-48 flex flex-col justify-center overflow-hidden relative">
|
||||
<div class="w-full h-16 flex justify-center scroll-button scroll-button-up">
|
||||
<SvgIcon v-show="showUpButton" name="chevron-up" size="64px" class="text-gray-200/80 cursor-pointer" @click="handleScroll(-100)" />
|
||||
</div>
|
||||
<div ref="scrollContainerRef" class="h-150 w-48 overflow-y-auto overflow-x-hidden relative scroll-smooth hide-scrollbar">
|
||||
<ul class="li-container relative bg-slate-500/50">
|
||||
<li
|
||||
v-for="item in list" :key="item.value" ref="liRefs" :style="{ backgroundColor: item.color }"
|
||||
class="w-full h-28 text-center leading-30 cursor-pointer duration-300"
|
||||
>
|
||||
{{ item.label }}
|
||||
</li>
|
||||
<li class="h-16" />
|
||||
</ul>
|
||||
</div>
|
||||
<div class="w-full h-16 flex justify-center scroll-button scroll-button-down">
|
||||
<SvgIcon v-show="showDownButton" name="chevron-down" size="64px" class="text-gray-200/80 cursor-pointer" @click="handleScroll(100)" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button class="btn btn-primary btn-sm w-32" @click="connectUserMsg">
|
||||
connectUserMsg
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-button::before,
|
||||
.scroll-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
transform: translate(12px 12px);
|
||||
}
|
||||
|
||||
.scroll-button::before {
|
||||
transform: translate(0, -6px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.scroll-button::after {
|
||||
transform: translate(0, 6px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 添加动画效果 */
|
||||
.scroll-button-down {
|
||||
animation: bounce-down 2s infinite;
|
||||
}
|
||||
/* 添加动画效果 */
|
||||
.scroll-button-up {
|
||||
animation: bounce-up 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce-down {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
@keyframes bounce-up {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-button:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -146,7 +146,19 @@ export function useViewModel() {
|
||||
element.appendChild(avatarEmpty)
|
||||
}
|
||||
|
||||
element = useElementStyle(element, tableData.value[i], i, patternList.value, patternColor.value, cardColor.value, cardSize.value, textSize.value)
|
||||
element = useElementStyle({
|
||||
element,
|
||||
person: tableData.value[i],
|
||||
index: i,
|
||||
patternList: patternList.value,
|
||||
patternColor: patternColor.value,
|
||||
cardColor: cardColor.value,
|
||||
cardSize: cardSize.value,
|
||||
scale: 1,
|
||||
textSize: textSize.value,
|
||||
mod: 'default',
|
||||
},
|
||||
)
|
||||
const object = new CSS3DObject(element)
|
||||
object.position.x = Math.random() * 4000 - 2000
|
||||
object.position.y = Math.random() * 4000 - 2000
|
||||
@@ -201,7 +213,18 @@ export function useViewModel() {
|
||||
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')
|
||||
useElementStyle({
|
||||
element: item.element,
|
||||
person: {} as any,
|
||||
index: i,
|
||||
patternList: patternList.value,
|
||||
patternColor: patternColor.value,
|
||||
cardColor: cardColor.value,
|
||||
cardSize: cardSize.value,
|
||||
scale: 1,
|
||||
textSize: textSize.value,
|
||||
mod: 'sphere',
|
||||
})
|
||||
})
|
||||
}
|
||||
luckyTargets.value = []
|
||||
@@ -485,7 +508,7 @@ export function useViewModel() {
|
||||
|
||||
return
|
||||
}
|
||||
luckyCount.value = 10
|
||||
// luckyCount.value = 10
|
||||
// 自定义抽奖个数
|
||||
|
||||
let leftover = currentPrize.value.count - currentPrize.value.isUsedCount
|
||||
@@ -498,7 +521,7 @@ export function useViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
luckyCount.value = leftover < luckyCount.value ? leftover : luckyCount.value
|
||||
luckyCount.value = leftover
|
||||
// 重构抽奖函数
|
||||
luckyTargets.value = getRandomElements(personPool.value, luckyCount.value)
|
||||
luckyTargets.value.forEach((item) => {
|
||||
@@ -553,7 +576,14 @@ export function useViewModel() {
|
||||
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)
|
||||
const { xTable, yTable, scale } = useElementPosition(
|
||||
item,
|
||||
rowCount.value,
|
||||
totalLuckyCount,
|
||||
{ width: cardSize.value.width, height: cardSize.value.height },
|
||||
windowSize,
|
||||
index,
|
||||
)
|
||||
new TWEEN.Tween(item.position)
|
||||
.to({
|
||||
x: xTable,
|
||||
@@ -562,7 +592,18 @@ export function useViewModel() {
|
||||
}, 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')
|
||||
item.element = useElementStyle({
|
||||
element: item.element,
|
||||
person,
|
||||
index: cardIndex,
|
||||
patternList: patternList.value,
|
||||
patternColor: patternColor.value,
|
||||
cardColor: luckyColor.value,
|
||||
cardSize: { width: cardSize.value.width, height: cardSize.value.height },
|
||||
scale,
|
||||
textSize: textSize.value,
|
||||
mod: 'lucky',
|
||||
})
|
||||
})
|
||||
.start()
|
||||
.onComplete(() => {
|
||||
@@ -691,7 +732,19 @@ export function useViewModel() {
|
||||
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')
|
||||
objects.value[cardRandomIndexArr[i]].element = useElementStyle({
|
||||
element: objects.value[cardRandomIndexArr[i]].element,
|
||||
person: allPersonList.value[personRandomIndexArr[i]],
|
||||
index: cardRandomIndexArr[i],
|
||||
patternList: patternList.value,
|
||||
patternColor: patternColor.value,
|
||||
cardColor: cardColor.value,
|
||||
cardSize: { width: cardSize.value.width, height: cardSize.value.height },
|
||||
textSize: textSize.value,
|
||||
scale: 1,
|
||||
mod,
|
||||
type: 'change',
|
||||
})
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
90
src/views/Mobile/index.vue
Normal file
90
src/views/Mobile/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang='ts'>
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useViewModel } from './useViewModel'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn') // 设置为中文
|
||||
|
||||
const textareaRef = ref()
|
||||
const messageArrayRef = ref()
|
||||
// 存储定时器ID
|
||||
const timer = ref()
|
||||
// 创建一个响应式的时间戳,用于触发更新
|
||||
const nowTimestamp = ref(Date.now())
|
||||
const { sendMsg, userInputMsg, userMsgArray } = useViewModel()
|
||||
async function handleEnterSend() {
|
||||
sendMsg(userInputMsg.value)
|
||||
textareaRef.value.blur()
|
||||
messageArrayRef.value.scrollTop = messageArrayRef.value.scrollHeight
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!messageArrayRef.value) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
messageArrayRef.value.scrollTop = messageArrayRef.value.scrollHeight
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 带有实时更新的时间显示
|
||||
const formattedMessages = computed(() => {
|
||||
const _ = nowTimestamp.value
|
||||
return userMsgArray.value.map(item => ({
|
||||
...item,
|
||||
formattedTime: dayjs(item.dateTime).fromNow(),
|
||||
}))
|
||||
})
|
||||
watch(() => userMsgArray.value.length, () => {
|
||||
scrollToBottom()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
timer.value = setInterval(() => {
|
||||
nowTimestamp.value = Date.now()
|
||||
}, 60000) // 每分钟更新一次
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-around py-4">
|
||||
<div class="h-12 drop-shadow-md shadow-lg">
|
||||
<h2 class="text-center text-lg font-bold">
|
||||
发送弹幕
|
||||
</h2>
|
||||
</div>
|
||||
<div ref="messageArrayRef" class="overflow-y-auto h-[calc(100vh-15rem)]">
|
||||
<ul>
|
||||
<li v-for="item in formattedMessages" :key="item.id" class="mb-3">
|
||||
<div class="chat chat-end">
|
||||
<div class="chat-header">
|
||||
<time class="text-xs opacity-50">{{ item.formattedTime }}</time>
|
||||
</div>
|
||||
<div class="chat-bubble break-all whitespace-normal">
|
||||
{{ item.msg }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-none rounded-2xl bg-base-200 mx-2 p-2 flex flex-col gap-3 items-center justify-center shadow-md mb-8 h-48">
|
||||
<textarea ref="textareaRef" v-model="userInputMsg" class="textarea w-full rounded-xl border-none bg-transparent focus:outline-none focus:ring-0" placeholder="发送弹幕 | 只展示您发送过的弹幕" rows="5" cols="50" @keydown.enter.prevent="handleEnterSend" />
|
||||
<div class="w-full flex justify-end">
|
||||
<button class="btn btn-primary w-24 mb-4" @click="handleEnterSend">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
50
src/views/Mobile/useViewModel.ts
Normal file
50
src/views/Mobile/useViewModel.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IMsgType } from '@/types/msgType'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { api_sendMsg } from '@/api/msg'
|
||||
import { IndexDb } from '@/utils/dexie'
|
||||
|
||||
export function useViewModel() {
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const routeSignature = ref<string>('')
|
||||
const userInputMsg = ref('')
|
||||
const userMsgArray = ref<any[]>([])
|
||||
const userMsgDb = new IndexDb('userMsg', ['userMsg'], 1, ['createTime'])
|
||||
const getRouteSignature = async () => {
|
||||
routeSignature.value = route.query.userSignature as string
|
||||
}
|
||||
|
||||
const getAllMsg = async () => {
|
||||
userMsgDb.getDataSortedByDateTime('userMsg', 'dateTime').then((data) => {
|
||||
userMsgArray.value = data
|
||||
})
|
||||
}
|
||||
|
||||
function sendMsg(msg: string) {
|
||||
const msgData: IMsgType = {
|
||||
data: msg,
|
||||
dateTime: new Date().toLocaleString(),
|
||||
}
|
||||
api_sendMsg(routeSignature.value, msgData).then((res: any) => {
|
||||
toast.open({
|
||||
message: res.msg || '发送成功',
|
||||
type: 'success',
|
||||
position: 'top-right',
|
||||
})
|
||||
userMsgDb.setData('userMsg', { msg })
|
||||
getAllMsg()
|
||||
userInputMsg.value = ''
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
getRouteSignature()
|
||||
getAllMsg()
|
||||
})
|
||||
return {
|
||||
sendMsg,
|
||||
userInputMsg,
|
||||
userMsgArray,
|
||||
}
|
||||
}
|
||||
3
src/vue-use.d.ts
vendored
Normal file
3
src/vue-use.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module '@vueuse/integrations/useQRCode' {
|
||||
export { useQRCode } from '@vueuse/integrations/useQRCode/dist/index.d.ts'
|
||||
}
|
||||
Reference in New Issue
Block a user