AI实验室前端
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { SystemUserApi } from '#/api';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import * as api from '#/api';
|
||||
|
||||
/**
|
||||
* 新增/修改
|
||||
*/
|
||||
export function useFormSchema(uploadState: {
|
||||
sizeKb?: number;
|
||||
uploaded: boolean;
|
||||
uploadId?: string;
|
||||
uploading: boolean;
|
||||
}): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'sort',
|
||||
label: '顺序号',
|
||||
componentProps: {
|
||||
type: 'number',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'name',
|
||||
label: '礼品名',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'Upload',
|
||||
fieldName: 'oss_url', // 占位字段
|
||||
label: '礼品照',
|
||||
componentProps: {
|
||||
maxCount: 1,
|
||||
accept: 'image/*', // ⚠️ 关键:只允许图片
|
||||
customRequest: async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
uploadState.uploading = true;
|
||||
uploadState.uploaded = false;
|
||||
|
||||
// 记录文件大小(KiB,保留 2 位小数)
|
||||
uploadState.sizeKb = Number((file.size / 1024).toFixed(2));
|
||||
|
||||
// 获取上传地址
|
||||
const { uploadUrl, id } = await api.getLotteryUploadUrl(file.name);
|
||||
|
||||
// PUT 上传
|
||||
await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录状态
|
||||
uploadState.uploadId = id;
|
||||
uploadState.uploaded = true;
|
||||
|
||||
onSuccess?.({}, file);
|
||||
} catch (error) {
|
||||
uploadState.uploading = false;
|
||||
uploadState.uploaded = false;
|
||||
onError?.(error as Error);
|
||||
} finally {
|
||||
uploadState.uploading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
buttonStyle: 'solid',
|
||||
options: [
|
||||
{ label: '已开启', value: 1 },
|
||||
{ label: '未开启', value: 0 },
|
||||
],
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'is_opened',
|
||||
label: '礼品状态',
|
||||
},
|
||||
{
|
||||
component: 'Textarea',
|
||||
fieldName: 'remark',
|
||||
label: '礼品备注',
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表展示
|
||||
* @param onActionClick
|
||||
* @param onStatusChange
|
||||
*/
|
||||
export function useColumns<T = SystemUserApi.SystemUser>(
|
||||
onActionClick: OnActionClickFn<T>,
|
||||
onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
|
||||
): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'id',
|
||||
title: '编号',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '序号',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '奖品名',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
cellRender: { name: 'CellImage' },
|
||||
field: 'oss_url',
|
||||
title: '奖品照',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
field: 'is_opened',
|
||||
title: '当前状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellSwitch',
|
||||
props: {
|
||||
checkedChildren: '已开启',
|
||||
unCheckedChildren: '未开启',
|
||||
},
|
||||
attrs: {
|
||||
beforeChange: onStatusChange,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'remark',
|
||||
title: '备注',
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
title: '录入时间',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
cellRender: {
|
||||
attrs: {
|
||||
nameField: 'name',
|
||||
nameTitle: '奖品名称',
|
||||
onClick: onActionClick,
|
||||
},
|
||||
name: 'CellOperation',
|
||||
},
|
||||
field: 'operation',
|
||||
fixed: 'right',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DataNode } from 'ant-design-vue/es/tree';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SentinelApi } from '#/api';
|
||||
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
|
||||
import { Tree, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { message, Spin } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { addLottery, updateLottery } from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from './data';
|
||||
|
||||
const emits = defineEmits(['success']);
|
||||
|
||||
const formData = ref<SentinelApi.Record>();
|
||||
const uploadState = reactive<{
|
||||
sizeKb?: number;
|
||||
uploaded: boolean;
|
||||
uploadId?: string;
|
||||
uploading: boolean;
|
||||
}>({
|
||||
uploading: false,
|
||||
uploaded: false,
|
||||
});
|
||||
const [Form, formApi] = useVbenForm({
|
||||
schema: useFormSchema(uploadState),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const roles = ref<DataNode[]>([]);
|
||||
const loadingRoles = ref(false);
|
||||
|
||||
const id = ref();
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
if (uploadState.uploading) {
|
||||
message.warning('文件正在上传,请稍后');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!uploadState.uploaded || !uploadState.uploadId) {
|
||||
message.warning('礼品照片尚未上传');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
drawerApi.lock();
|
||||
(id.value
|
||||
? updateLottery(id.value, uploadState.uploadId, values)
|
||||
: addLottery(uploadState.uploadId, values)
|
||||
)
|
||||
.then(() => {
|
||||
emits('success');
|
||||
drawerApi.close();
|
||||
})
|
||||
.catch(() => {
|
||||
drawerApi.unlock();
|
||||
});
|
||||
},
|
||||
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SentinelApi.Record>();
|
||||
formApi.resetForm();
|
||||
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
id.value = data.id;
|
||||
} else {
|
||||
id.value = undefined;
|
||||
}
|
||||
|
||||
// Wait for Vue to flush DOM updates (form fields mounted)
|
||||
await nextTick();
|
||||
if (data) {
|
||||
formApi.setValues(data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDrawerTitle = computed(() => {
|
||||
return formData.value?.id ? '修改' : '新增';
|
||||
});
|
||||
|
||||
function getNodeClass(node: Recordable<any>) {
|
||||
const classes: string[] = [];
|
||||
if (node.value?.type === 'button') {
|
||||
classes.push('inline-flex');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Drawer :title="getDrawerTitle">
|
||||
<Form>
|
||||
<template #roles="slotProps">
|
||||
<Spin :spinning="loadingRoles" wrapper-class-name="w-full">
|
||||
<Tree
|
||||
:tree-data="roles"
|
||||
multiple
|
||||
bordered
|
||||
:default-expanded-level="2"
|
||||
:get-node-class="getNodeClass"
|
||||
v-bind="slotProps"
|
||||
value-field="id"
|
||||
label-field="title"
|
||||
>
|
||||
<template #node="{ value }">
|
||||
{{ $t(value.id) }}
|
||||
</template>
|
||||
</Tree>
|
||||
</Spin>
|
||||
</template>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
:deep(.ant-tree-title) {
|
||||
.tree-actions {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tree-title:hover) {
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Recordable } from '@vben-core/typings';
|
||||
|
||||
import type {
|
||||
OnActionClickParams,
|
||||
VxeTableGridOptions,
|
||||
} from '#/adapter/vxe-table';
|
||||
import type { SentinelApi } from '#/api';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
import { Plus } from '@vben/icons';
|
||||
|
||||
import { Button, message, Modal } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteLottery, getLotteryList } from '#/api';
|
||||
import * as api from '#/api';
|
||||
import VehicleAlertOverlay from '#/views/sentinel/record/VehicleAlertOverlay.vue';
|
||||
|
||||
import { useColumns } from './data';
|
||||
import Form from './form.vue';
|
||||
|
||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 状态开关即将改变
|
||||
* @param newStatus 期望改变的状态值
|
||||
* @param row 行数据
|
||||
* @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
|
||||
*/
|
||||
async function onStatusChange(newStatus: number, row: SentinelApi.Record) {
|
||||
const status: Recordable<string> = {
|
||||
0: '未开启',
|
||||
1: '已开启',
|
||||
};
|
||||
try {
|
||||
await confirm(
|
||||
`你要切换礼品名为<${row.name}>的状态为${status[newStatus.toString()]} 吗?`,
|
||||
`切换礼品状态`,
|
||||
);
|
||||
await api.updateLottery(row.id, null, { is_opened: newStatus });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
|
||||
* @param content 提示内容
|
||||
* @param title 提示标题
|
||||
*/
|
||||
function confirm(content: string, title: string) {
|
||||
return new Promise((reslove, reject) => {
|
||||
Modal.confirm({
|
||||
content,
|
||||
onCancel() {
|
||||
reject(new Error('已取消'));
|
||||
},
|
||||
onOk() {
|
||||
reslove(true);
|
||||
},
|
||||
title,
|
||||
});
|
||||
});
|
||||
}
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: useColumns(onActionClick, onStatusChange),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const res = await getLotteryList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
return {
|
||||
items: res,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
},
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
export: false,
|
||||
refresh: true,
|
||||
zoom: true,
|
||||
},
|
||||
} as VxeTableGridOptions<SentinelApi.Record>,
|
||||
});
|
||||
|
||||
function onActionClick(e: OnActionClickParams<SentinelApi.Record>) {
|
||||
switch (e.code) {
|
||||
case 'delete': {
|
||||
onDelete(e.row);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
onEdit(e.row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: SentinelApi.Record) {
|
||||
formDrawerApi.setData(row).open();
|
||||
}
|
||||
|
||||
function onDelete(row: SentinelApi.Record) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除记录:${[row.id]}`,
|
||||
duration: 0,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
deleteLottery(row.id)
|
||||
.then(() => {
|
||||
message.success({
|
||||
content: `已删除记录:${[row.id]}`,
|
||||
key: 'action_process_msg',
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
hideLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
function onCreate() {
|
||||
formDrawerApi.setData({}).open();
|
||||
}
|
||||
|
||||
const alertState = reactive({
|
||||
visible: false,
|
||||
content: '',
|
||||
});
|
||||
|
||||
function acknowledgeAlert() {
|
||||
alertState.visible = false;
|
||||
}
|
||||
const resetAllItems = async () => {
|
||||
// 弹出确认框
|
||||
const confirmed = window.confirm(
|
||||
'确定要将所有奖品重置为未开启吗?此操作不可撤销。',
|
||||
);
|
||||
if (!confirmed) return; // 用户取消
|
||||
|
||||
await api.resetAllLottery();
|
||||
gridApi.query();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<VehicleAlertOverlay
|
||||
:visible="alertState.visible"
|
||||
:content="alertState.content"
|
||||
@acknowledge="acknowledgeAlert"
|
||||
/>
|
||||
|
||||
<FormDrawer @success="onRefresh" />
|
||||
<Grid :table-title="设备列表">
|
||||
<template #toolbar-tools>
|
||||
<Button type="primary" @click="onCreate">
|
||||
<Plus class="size-5" />
|
||||
新增奖品
|
||||
</Button>
|
||||
<Button @click="resetAllItems"> 重置所有奖品状态 </Button>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
|
||||
import * as api from '#/api';
|
||||
|
||||
const items = reactive([]); // 用 reactive 包裹动态数组
|
||||
const nextIndex = ref(0);
|
||||
const rowSize = ref(9); // 默认每行 11 张牌,可以改
|
||||
|
||||
// 初始化 / 获取数据
|
||||
async function initExchangeCards() {
|
||||
const data = await api.getExchangeListApi();
|
||||
items.splice(0); // 清空旧数据
|
||||
let index = 1;
|
||||
let firstUnfinishedIndex = 0;
|
||||
|
||||
data.forEach((d, i) => {
|
||||
const card = {
|
||||
id: d.id,
|
||||
sort: index,
|
||||
name: d.name,
|
||||
gift_code: d.gift_code,
|
||||
is_finished: d.is_finished,
|
||||
position: d.position,
|
||||
};
|
||||
|
||||
items.push(card);
|
||||
|
||||
// 找到第一个未完成的
|
||||
if (!card.is_finished && firstUnfinishedIndex === 0) {
|
||||
firstUnfinishedIndex = i + 1;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
});
|
||||
|
||||
// 定位到第一个未完成的牌
|
||||
nextIndex.value = firstUnfinishedIndex;
|
||||
}
|
||||
|
||||
// 重置翻牌状态
|
||||
async function resetCards() {
|
||||
const confirmed = window.confirm(
|
||||
'确定要重置所有牌吗?这会清空所有已完成状态!',
|
||||
);
|
||||
if (!confirmed) return; // 用户取消,不执行重置
|
||||
|
||||
try {
|
||||
// 调用后端全量重置
|
||||
await api.resetAllExchangeStatusApi();
|
||||
|
||||
// 前端同步更新 items
|
||||
items.forEach((c) => (c.is_finished = false));
|
||||
nextIndex.value = 0;
|
||||
|
||||
// 可选择重新拉取最新数据
|
||||
await initExchangeCards();
|
||||
} catch (error) {
|
||||
console.error('重置失败', error);
|
||||
alert('重置失败,请稍后重试!');
|
||||
}
|
||||
}
|
||||
|
||||
const getRow = (rowIndex) => {
|
||||
const row = items.slice(
|
||||
rowIndex * rowSize.value,
|
||||
(rowIndex + 1) * rowSize.value,
|
||||
);
|
||||
return rowIndex % 2 === 1 ? [...row].reverse() : row;
|
||||
};
|
||||
|
||||
/* 礼花 */
|
||||
const confettiCanvas = ref(null);
|
||||
let confettiAnim = null;
|
||||
const startConfettiAt = (x, y) => {
|
||||
const canvas = confettiCanvas.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const colors = [
|
||||
'#ff4d4f',
|
||||
'#ff7a00',
|
||||
'#ffd400',
|
||||
'#36cfc9',
|
||||
'#597ef7',
|
||||
'#9254de',
|
||||
];
|
||||
const particles = [];
|
||||
for (let i = 0; i < 180; i++) {
|
||||
const angle = -Math.PI / 2 + ((Math.random() - 0.5) * Math.PI) / 3;
|
||||
const speed = Math.random() * 10 + 16;
|
||||
particles.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: Math.random() * 6 + 6,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
life: 1,
|
||||
});
|
||||
}
|
||||
const loop = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let alive = 0;
|
||||
particles.forEach((p) => {
|
||||
p.vx *= 0.98;
|
||||
p.vy = p.vy * 0.98 + 0.25;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= 0.01;
|
||||
if (p.life > 0) {
|
||||
alive++;
|
||||
ctx.globalAlpha = p.life;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(p.x, p.y, p.size, p.size * 0.6);
|
||||
}
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
if (alive) confettiAnim = requestAnimationFrame(loop);
|
||||
};
|
||||
cancelAnimationFrame(confettiAnim);
|
||||
confettiAnim = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
/* 翻牌 */
|
||||
const nextCard = async () => {
|
||||
if (nextIndex.value - 1 >= items.length) return;
|
||||
const item = items[nextIndex.value - 1];
|
||||
await api.resetUserStatusApi(item.id);
|
||||
items[nextIndex.value - 1].is_finished = true;
|
||||
nextIndex.value++;
|
||||
nextTick(() => {
|
||||
const btn = document.querySelector('.fortune-btn');
|
||||
const rect = btn.getBoundingClientRect();
|
||||
startConfettiAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
});
|
||||
};
|
||||
|
||||
const triggerButton = () => {
|
||||
const btn = document.querySelector('.fortune-btn');
|
||||
if (btn) btn.click();
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault(); // 阻止滚动或默认行为
|
||||
triggerButton();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await initExchangeCards();
|
||||
});
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown));
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeyDown));
|
||||
|
||||
const top = ref(240); // 初始位置
|
||||
const left = ref(100);
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
function startDrag(e) {
|
||||
offsetX = e.clientX - left.value;
|
||||
offsetY = e.clientY - top.value;
|
||||
window.addEventListener('mousemove', onDrag);
|
||||
window.addEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
function onDrag(e) {
|
||||
isDragging = true;
|
||||
left.value = e.clientX - offsetX;
|
||||
top.value = e.clientY - offsetY;
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
setTimeout(() => (isDragging = false), 0); // 解决 click 与 mouseup 顺序问题
|
||||
window.removeEventListener('mousemove', onDrag);
|
||||
window.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="snake-page">
|
||||
<div style="background-color: #e32c2c; width: 120%;margin-bottom: 20px; ">
|
||||
<header class="title">
|
||||
<h1 class="art-title">马年传统非遗活动:新年礼物交换</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<main class="grid-wrap">
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="rowIndex in Math.ceil(items.length / rowSize)"
|
||||
:key="rowIndex"
|
||||
class="grid-row"
|
||||
>
|
||||
<div
|
||||
v-for="(card, colIndex) in getRow(rowIndex - 1)"
|
||||
:key="card.id"
|
||||
class="card"
|
||||
:class="{ opened: card.is_finished }"
|
||||
:data-row="rowIndex - 1"
|
||||
:data-col="colIndex"
|
||||
:data-reverse="(rowIndex - 1) % 2 === 1"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<div class="card-front">{{ card.sort }}</div>
|
||||
<div class="card-back">
|
||||
<div class="card-back-line">{{ card.name }}</div>
|
||||
<div class="card-back-line">{{ card.gift_code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 横线 -->
|
||||
<div
|
||||
v-if="
|
||||
// 奇数行:左→右,右边有邻居才画
|
||||
((rowIndex - 1) % 2 === 0 && colIndex < rowSize - 1) ||
|
||||
// 偶数行:右→左,左边有邻居才画
|
||||
((rowIndex - 1) % 2 === 1 && colIndex > 0)
|
||||
"
|
||||
class="line horizontal"
|
||||
:class="{ reverse: (rowIndex - 1) % 2 === 1 }"
|
||||
></div>
|
||||
|
||||
<!-- 竖线 -->
|
||||
<div
|
||||
v-if="
|
||||
// 不在最后一行,并且当前牌是行尾(奇数行右侧、偶数行左侧)
|
||||
rowIndex - 1 < Math.ceil(items.length / rowSize) - 1 &&
|
||||
(((rowIndex - 1) % 2 === 0 && colIndex === rowSize - 1) ||
|
||||
((rowIndex - 1) % 2 === 1 && colIndex === 0))
|
||||
"
|
||||
class="line vertical"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
class="btn-wrap"
|
||||
:style="{ top: `${top}px`, left: `${left}px` }"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<button class="fortune-btn" @click="!isDragging && nextCard()">
|
||||
发财
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas ref="confettiCanvas" class="confetti-canvas"></canvas>
|
||||
<button class="reset-btn" @click="resetCards">🐎</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 页面整体背景:红黄渐变 */
|
||||
.snake-page {
|
||||
/* 主题变量 */
|
||||
--red-deep: #b21f2d;
|
||||
--red: #c8161d;
|
||||
--gold: #ffde00;
|
||||
--warm: #ffb94a;
|
||||
--bg1: #c8161d;
|
||||
--bg2: #ffd400;
|
||||
--bg3: #ff6a00;
|
||||
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* 多层混合渐变 */
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
var(--bg1),
|
||||
var(--bg3),
|
||||
var(--bg2),
|
||||
var(--bg1)
|
||||
);
|
||||
|
||||
background-size: 400% 400%;
|
||||
animation: luckyGradientFlow 25s ease-in-out infinite;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@keyframes luckyGradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.title h1 {
|
||||
font-size: 33px;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
}
|
||||
/* 大气艺术字体,金黄渐变动画 */
|
||||
.art-title {
|
||||
font-family: 'SimHei', 'Heiti SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; /* 中文黑体风格 */
|
||||
|
||||
font-size: 33px; /* 根据页面宽度可调 */
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ffd400,
|
||||
#ffb94a,
|
||||
#ffe29a,
|
||||
#ffde00,
|
||||
#ffd400
|
||||
);
|
||||
|
||||
background-size: 300% 300%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: gradientFlow 6s ease-in-out infinite;
|
||||
letter-spacing: 2px;
|
||||
line-height: 2.2;
|
||||
filter: drop-shadow(2px 2px 10px rgba(0, 0, 0, 1));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
aspect-ratio: 1;
|
||||
perspective: 600px;
|
||||
}
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.card.opened .card-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.card-front,
|
||||
.card-back {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.card-front {
|
||||
background: linear-gradient(135deg, #e32c2c, #b21f2d);
|
||||
color: #ffd400;
|
||||
}
|
||||
.card-back {
|
||||
background: #ffe29a;
|
||||
color: #b21f2d;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
background: rgba(255, 212, 0, 0.6);
|
||||
}
|
||||
.line.horizontal {
|
||||
top: 50%;
|
||||
height: 3px;
|
||||
width: 14px;
|
||||
right: -14px;
|
||||
}
|
||||
.line.horizontal.reverse {
|
||||
left: -14px;
|
||||
right: auto;
|
||||
}
|
||||
.line.vertical {
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
bottom: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.btn-wrap {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
cursor: move;
|
||||
}
|
||||
.confetti-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fortune-btn {
|
||||
width: 50px;
|
||||
height: 120px; /* 改成筒状 */
|
||||
border-radius: 20% / 20%; /* 圆角 + 压缩高度形成筒感 */
|
||||
border: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#ff4d4f,
|
||||
#ff7a00,
|
||||
#ffd400
|
||||
); /* 礼花色渐变 */
|
||||
box-shadow:
|
||||
0 6px 12px rgba(255, 212, 0, 0.6),
|
||||
/* 光晕 */ inset 0 -4px 6px rgba(0, 0, 0, 0.3); /* 内阴影增加深度 */
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* 点击缩放 + 光晕扩散 */
|
||||
.fortune-btn:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow:
|
||||
0 10px 20px rgba(255, 212, 0, 0.8),
|
||||
inset 0 -4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 按钮顶部小光圈(像礼花口) */
|
||||
.fortune-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 24px;
|
||||
height: 8px;
|
||||
background: radial-gradient(circle, #fff 0%, rgba(255, 255, 255, 0) 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ---------------- 重置按钮 ---------------- */
|
||||
.reset-btn {
|
||||
position: fixed;
|
||||
bottom: 20px; /* 离底部距离 */
|
||||
right: 20px; /* 离右边距离 */
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #ffde00, #ff7a00);
|
||||
color: #b21f2d;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 12px rgba(255, 222, 0, 0.6),
|
||||
0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* hover 发光动画 */
|
||||
.reset-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow:
|
||||
0 0 20px rgba(255, 222, 0, 0.9),
|
||||
0 6px 18px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.card-back {
|
||||
display: flex;
|
||||
flex-direction: column; /* 垂直排列 */
|
||||
justify-content: center; /* 垂直居中 */
|
||||
align-items: center; /* 水平居中 */
|
||||
text-align: center; /* 文本居中 */
|
||||
}
|
||||
|
||||
.card-back-line {
|
||||
line-height: 1.2; /* 可调节行间距 */
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
@@ -0,0 +1,703 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import * as api from '#/api';
|
||||
|
||||
const items = reactive([]);
|
||||
const showModal = ref(false);
|
||||
const selected = ref(null);
|
||||
|
||||
// 画布 refs & confetti state
|
||||
const confettiCanvas = ref(null);
|
||||
let confettiAnim = null;
|
||||
|
||||
const openItem = async (item) => {
|
||||
try {
|
||||
// 调用接口更新数据库
|
||||
await api.openLotteryItem(item.id);
|
||||
|
||||
// 本地状态更新
|
||||
item.is_opened = true;
|
||||
selected.value = item;
|
||||
await nextTick();
|
||||
showModal.value = true;
|
||||
} catch (error) {
|
||||
console.error('标记奖品已开启失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (item) => {
|
||||
if (item.is_opened) {
|
||||
selected.value = item;
|
||||
showModal.value = true;
|
||||
nextTick(() => startConfetti());
|
||||
} else {
|
||||
openItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
stopConfetti();
|
||||
selected.value = null;
|
||||
};
|
||||
|
||||
const startConfetti = () => {
|
||||
const canvas = confettiCanvas.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const particles = [];
|
||||
const colors = ['#ffd400', '#ff4d4f', '#ff8a00', '#ffe29a', '#b21f2d'];
|
||||
|
||||
for (let i = 0; i < 120; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * W,
|
||||
y: Math.random() * H - H,
|
||||
vx: (Math.random() - 0.5) * 6,
|
||||
vy: Math.random() * 6 + 2,
|
||||
size: Math.random() * 6 + 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
tilt: Math.random() * 0.3,
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
|
||||
let last = performance.now();
|
||||
|
||||
const loop = (t) => {
|
||||
const dt = (t - last) / 1000;
|
||||
last = t;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
for (const p of particles) {
|
||||
p.x += p.vx * dt * 60;
|
||||
p.y += p.vy * dt * 60;
|
||||
p.angle += p.tilt * 0.2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.angle);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
|
||||
ctx.restore();
|
||||
|
||||
if (p.y > H + 50) {
|
||||
p.y = -20;
|
||||
p.x = Math.random() * W;
|
||||
}
|
||||
}
|
||||
|
||||
confettiAnim = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
if (confettiAnim) cancelAnimationFrame(confettiAnim);
|
||||
confettiAnim = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const stopConfetti = () => {
|
||||
if (confettiAnim) cancelAnimationFrame(confettiAnim);
|
||||
confettiAnim = null;
|
||||
const canvas = confettiCanvas.value;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
|
||||
watch(showModal, (v) => {
|
||||
if (v) setTimeout(() => startConfetti(), 200);
|
||||
else stopConfetti();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.getLotteryList();
|
||||
if (Array.isArray(res)) {
|
||||
items.splice(0, items.length, ...res);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取礼品列表失败', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="lucky-page">
|
||||
<!-- 顶部标题 -->
|
||||
<div style="background-color: #e32c2c; width: 120%; margin-bottom: 20px">
|
||||
<header class="title">
|
||||
<h1 class="art-title">2025奥立年会暨主干信息十周年庆典</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- 网格容器 -->
|
||||
<main class="grid-wrap">
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="cell"
|
||||
:class="{ opened: item.is_opened }"
|
||||
@click="handleClick(item)"
|
||||
role="button"
|
||||
:aria-pressed="item.is_opened"
|
||||
>
|
||||
<!-- 门面未开状态 -->
|
||||
<div class="red-envelope" v-if="!item.is_opened">
|
||||
<div class="envelope-top"></div>
|
||||
<div class="envelope-body"></div>
|
||||
<div class="label">
|
||||
<div class="id">{{ item.sort }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打开状态:显示奖品 -->
|
||||
<div class="prize" v-else>
|
||||
<img :src="item.oss_url" :alt="item.name" />
|
||||
<div class="prize-name">{{ item.name }}</div>
|
||||
<div class="tag">已开</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 自定义模态窗 -->
|
||||
<transition name="modal-fade">
|
||||
<div class="modal-overlay" v-if="showModal" @click.self="closeModal">
|
||||
<div class="modal celebratory">
|
||||
<div class="modal-body">
|
||||
<div class="modal-left">
|
||||
<img
|
||||
:src="selected?.oss_url"
|
||||
:alt="selected?.name"
|
||||
class="prize-img"
|
||||
/>
|
||||
<canvas ref="confettiCanvas" class="confetti-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="modal-right">
|
||||
<h2 class="modal-title">恭喜抽中</h2>
|
||||
<h3 class="prize-title">{{ selected?.name }}</h3>
|
||||
<p class="modal-msg">请上台领奖。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 礼花画布 -->
|
||||
<canvas ref="confettiCanvas" class="confetti-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 配色变量(传统中国喜庆) */
|
||||
:root {
|
||||
--red-deep: #b21f2d;
|
||||
--red: #c8161d;
|
||||
--gold: #ffde00;
|
||||
--warm: #ffb94a;
|
||||
--bg1: #c8161d;
|
||||
--bg2: #ffd400;
|
||||
--glass: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* 页面整体背景:红黄渐变 */
|
||||
.lucky-page {
|
||||
/* 主题变量 */
|
||||
--red-deep: #b21f2d;
|
||||
--red: #c8161d;
|
||||
--gold: #ffde00;
|
||||
--warm: #ffb94a;
|
||||
--bg1: #c8161d;
|
||||
--bg2: #ffd400;
|
||||
--bg3: #ff6a00;
|
||||
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
box-sizing: border-box;
|
||||
|
||||
/* 多层混合渐变 */
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
var(--bg1),
|
||||
var(--bg3),
|
||||
var(--bg2),
|
||||
var(--bg1)
|
||||
);
|
||||
|
||||
background-size: 400% 400%;
|
||||
animation: luckyGradientFlow 25s ease-in-out infinite;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@keyframes luckyGradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.title h1 {
|
||||
font-size: 33px;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* 内容主区容器(限制宽度使整体更高级) */
|
||||
.grid-wrap {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Grid:自动填充,方格自适应,保持方形(aspect-ratio) */
|
||||
.grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 单个方格样式 */
|
||||
.cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1),
|
||||
box-shadow 220ms;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(0, 0, 0, 0.03)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 点击反馈 */
|
||||
.cell:active {
|
||||
transform: translateY(1px) scale(0.997);
|
||||
}
|
||||
|
||||
/* 门面未开状态 */
|
||||
.door {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 左右门板 */
|
||||
.door-left,
|
||||
.door-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(0, 0, 0, 0.06)
|
||||
);
|
||||
border: 2px solid rgba(255, 255, 255, 0.06);
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform 700ms cubic-bezier(0.2, 0.9, 0.3, 1),
|
||||
box-shadow 700ms;
|
||||
transform-origin: left center;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* 右侧以右为原点 */
|
||||
.door-right {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
/* 给门板加装饰纹理(竖纹)和金边 */
|
||||
.door-left::after,
|
||||
.door-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.01) 0.5px,
|
||||
transparent 0.5px
|
||||
);
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
/* 门面标签 */
|
||||
.label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.label .id {
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.04),
|
||||
rgba(255, 255, 255, 0.02)
|
||||
);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) inset;
|
||||
}
|
||||
.label .hint {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
/* 打开状态:门板旋转(做成类似两扇门打开) */
|
||||
.cell.opened .door-left {
|
||||
transform: perspective(600px) rotateY(-100deg) translateZ(0);
|
||||
}
|
||||
.cell.opened .door-right {
|
||||
transform: perspective(600px) rotateY(100deg) translateZ(0);
|
||||
}
|
||||
|
||||
/* 奖品显示样式 */
|
||||
.prize {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 242, 225, 0.18),
|
||||
rgba(255, 232, 200, 0.06)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.prize img {
|
||||
width: 68%;
|
||||
height: auto;
|
||||
max-height: 58%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.prize-name {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tag {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
background: linear-gradient(90deg, var(--gold), #ffd77a);
|
||||
color: var(--red-deep);
|
||||
font-weight: 700;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 模态窗与动画 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 240ms ease;
|
||||
}
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 模态体(动态金黄渐变) */
|
||||
.modal {
|
||||
width: min(920px, 96%);
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#c8161d,
|
||||
/* 主红色 */ #e32c2c,
|
||||
/* 高光红 */ #b21f2d,
|
||||
/* 深红阴影 */ #ff4d4f,
|
||||
/* 活泼红点 */ #c8161d
|
||||
);
|
||||
background-size: 300% 300%;
|
||||
animation: modalGradientFlow 4s ease-in-out infinite;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
transform-origin: center;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); /* 金色微边框增强质感 */
|
||||
}
|
||||
|
||||
/* 动态渐变动画 */
|
||||
@keyframes modalGradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 内容网格 */
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 46% 54%;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-left {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(0, 0, 0, 0.02)
|
||||
);
|
||||
}
|
||||
.modal-left img {
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35) inset;
|
||||
}
|
||||
|
||||
.modal-right {
|
||||
padding: 6px 10px;
|
||||
color: #fff;
|
||||
}
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 35px;
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.prize-title {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
.modal-msg {
|
||||
margin: 8px 0 18px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.confirm {
|
||||
background: linear-gradient(90deg, var(--gold), #ffd77a);
|
||||
color: var(--red-deep);
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 18px rgba(178, 31, 45, 0.12);
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 礼花画布覆盖在模态内 */
|
||||
.confetti-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 响应处理:小屏时 modal 调整为单列 */
|
||||
@media (max-width: 720px) {
|
||||
.modal-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-left img {
|
||||
max-height: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大气艺术字体,金黄渐变动画 */
|
||||
.art-title {
|
||||
font-family:
|
||||
'SimHei', 'Heiti SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; /* 中文黑体风格 */
|
||||
|
||||
font-size: 33px; /* 根据页面宽度可调 */
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
#ffe29a,
|
||||
#ffb94a,
|
||||
#ffe29a,
|
||||
#ffde00,
|
||||
#ffd400
|
||||
);
|
||||
|
||||
background-size: 300% 300%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: gradientFlow 6s ease-in-out infinite;
|
||||
letter-spacing: 2px;
|
||||
line-height: 2.2;
|
||||
filter: drop-shadow(2px 2px 10px rgba(0, 0, 0, 1));
|
||||
}
|
||||
|
||||
/* 渐变流动动画 */
|
||||
@keyframes gradientFlow {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕适配 */
|
||||
@media (max-width: 720px) {
|
||||
.art-title {
|
||||
font-size: 35px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
/* 红包整体 */
|
||||
.red-envelope {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
perspective: 600px; /* 用于开合动画 */
|
||||
}
|
||||
|
||||
/* 红包封口 */
|
||||
.envelope-top {
|
||||
width: 80%;
|
||||
height: 20%;
|
||||
background: linear-gradient(135deg, #e32c2c, #b21f2d);
|
||||
border-radius: 8px 8px 0 0;
|
||||
transform-origin: top center;
|
||||
transition: transform 2s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
z-index: 2;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 红包主体 */
|
||||
.envelope-body {
|
||||
width: 80%;
|
||||
flex: 1;
|
||||
background: linear-gradient(180deg, #ff4d4f, #c8161d);
|
||||
border-radius: 0 0 10px 10px;
|
||||
box-shadow: inset 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 打开动画 */
|
||||
.cell.opened .envelope-top {
|
||||
transform: rotateX(-120deg) translateY(-10%);
|
||||
}
|
||||
|
||||
/* 红包上的数字标签 */
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 28%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.label .id {
|
||||
color: #ffd400;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user