AI实验室前端
This commit is contained in:
@@ -46,6 +46,7 @@
|
|||||||
"axios": "catalog:",
|
"axios": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"echarts": "catalog:",
|
"echarts": "catalog:",
|
||||||
|
"ezuikit-flv": "^2.1.0",
|
||||||
"js-sha256": "^0.11.1",
|
"js-sha256": "^0.11.1",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markdown-it-table": "^4.1.1",
|
"markdown-it-table": "^4.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { pyRequestClient } from '#/api/request';
|
||||||
|
/**
|
||||||
|
* 获取所有年会互换数据
|
||||||
|
*/
|
||||||
|
export async function getExchangeListApi() {
|
||||||
|
return pyRequestClient.get<any>('/am/ExGetList');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有年会互换状态
|
||||||
|
*/
|
||||||
|
export async function resetAllExchangeStatusApi() {
|
||||||
|
return pyRequestClient.get<any>('/am/ExReset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置指定用户的年会互换状态
|
||||||
|
* @param targetUserId 指定用户的 UUID
|
||||||
|
*/
|
||||||
|
export async function resetUserStatusApi(targetUserId: string) {
|
||||||
|
return pyRequestClient.put<any>('/am/ExResetTargetStatus', null,{
|
||||||
|
params: { target_user_id: targetUserId },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './exchange';
|
||||||
|
export * from './lottery';
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { pyRequestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传
|
||||||
|
*/
|
||||||
|
export async function getLotteryUploadUrl(filename: string) {
|
||||||
|
return pyRequestClient.get('/am/Lottery/getUploadUrl', {
|
||||||
|
params: { filename },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取年会礼品列表
|
||||||
|
*/
|
||||||
|
export async function getLotteryList() {
|
||||||
|
return pyRequestClient.get('/am/Lottery/List');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 新增礼品(内部自动类型转换)
|
||||||
|
*/
|
||||||
|
export async function addLottery(
|
||||||
|
oss: string,
|
||||||
|
data: {
|
||||||
|
is_opened: boolean | number;
|
||||||
|
name: string;
|
||||||
|
oss?: string;
|
||||||
|
remark?: string;
|
||||||
|
sort: number | string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
delete data.oss_url;
|
||||||
|
// 自动类型转换
|
||||||
|
const payload = {
|
||||||
|
oss,
|
||||||
|
...data,
|
||||||
|
is_opened: Boolean(data.is_opened), // 0/1/true/false 都能转换成布尔
|
||||||
|
sort: Number(data.sort), // "1" / 1 都能转成数字
|
||||||
|
};
|
||||||
|
|
||||||
|
return pyRequestClient.post('/am/Lottery/Add', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改礼品(内部自动类型转换)
|
||||||
|
*/
|
||||||
|
export async function updateLottery(
|
||||||
|
id: string,
|
||||||
|
oss: string,
|
||||||
|
data: {
|
||||||
|
is_opened?: boolean | number;
|
||||||
|
name?: string;
|
||||||
|
oss?: string;
|
||||||
|
remark?: string;
|
||||||
|
sort?: number | string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
delete data.oss_url;
|
||||||
|
const payload: any = { id, oss, ...data };
|
||||||
|
|
||||||
|
// 仅对存在的字段做转换
|
||||||
|
if ('is_opened' in payload) payload.is_opened = Boolean(payload.is_opened);
|
||||||
|
if ('sort' in payload) payload.sort = Number(payload.sort);
|
||||||
|
|
||||||
|
return pyRequestClient.put('/am/Lottery/Update', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除礼品
|
||||||
|
*/
|
||||||
|
export async function deleteLottery(id: string) {
|
||||||
|
return pyRequestClient.delete('/am/Lottery/Delete', {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 标记某个奖品为已开启
|
||||||
|
*/
|
||||||
|
export async function openLotteryItem(id: string) {
|
||||||
|
return pyRequestClient.patch(`/am/Lottery/open/${id}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 重置所有奖品为未开启
|
||||||
|
*/
|
||||||
|
export async function resetAllLottery() {
|
||||||
|
return pyRequestClient.patch('/am/Lottery/resetAll');
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
export * from './annual_meeting';
|
||||||
export * from './core';
|
export * from './core';
|
||||||
export * from './cv';
|
export * from './cv';
|
||||||
export * from './iot';
|
export * from './iot';
|
||||||
export * from './llm';
|
export * from './llm';
|
||||||
export * from './manager';
|
export * from './manager';
|
||||||
export * from './sentinel';
|
export * from './sentinel';
|
||||||
|
export * from './ws';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './monitor';
|
||||||
export * from './record';
|
export * from './record';
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { pyRequestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取记录列表数据
|
||||||
|
*/
|
||||||
|
export async function getSentinelMonitorPromotionalList() {
|
||||||
|
return pyRequestClient.get<Array<SentinelApi.Record>>(
|
||||||
|
'/iot/sentinel/monitor/promotional/list',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取记录列表数据
|
||||||
|
*/
|
||||||
|
export async function getSentinelMonitorList() {
|
||||||
|
return pyRequestClient.get<Array<SentinelApi.Record>>(
|
||||||
|
'/iot/sentinel/monitor/list',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import { getActivePinia } from 'pinia';
|
||||||
|
|
||||||
|
function getAccessStore() {
|
||||||
|
const pinia = getActivePinia();
|
||||||
|
if (!pinia) return null;
|
||||||
|
return useAccessStore(pinia);
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsOptions = {
|
||||||
|
maxReconnect?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
onMessage?: (data: any) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
path: (() => string) | string;
|
||||||
|
reconnectInterval?: number;
|
||||||
|
};
|
||||||
|
const BASE_WS_URL = 'wss://ai.ronsunny.cn:8090/ai/';
|
||||||
|
// const BASE_WS_URL = 'ws://127.0.0.1:13011/';
|
||||||
|
export function createAutoReconnectWs(options: WsOptions) {
|
||||||
|
let ws: null | WebSocket = null;
|
||||||
|
let reconnectTimer: null | number = null;
|
||||||
|
let reconnectCount = 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
path,
|
||||||
|
maxReconnect = 5,
|
||||||
|
reconnectInterval = 2000,
|
||||||
|
onMessage,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onError,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
function getUrl() {
|
||||||
|
const p = typeof path === 'function' ? path() : path;
|
||||||
|
const token = getAccessStore().accessToken;
|
||||||
|
return `${BASE_WS_URL}${p}?token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws = new WebSocket(getUrl());
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
reconnectCount = 0;
|
||||||
|
onOpen?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
onMessage?.(data);
|
||||||
|
} catch {
|
||||||
|
console.warn('ws message parse error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
onError?.();
|
||||||
|
tryReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
onClose?.();
|
||||||
|
tryReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryReconnect() {
|
||||||
|
if (reconnectTimer) return;
|
||||||
|
if (reconnectCount >= maxReconnect) return;
|
||||||
|
|
||||||
|
reconnectCount++;
|
||||||
|
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, reconnectInterval * reconnectCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
reconnectTimer && clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
reconnectCount = 0;
|
||||||
|
ws?.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connect, close };
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export const overridesPreferences = defineOverridesPreferences({
|
|||||||
// overrides
|
// overrides
|
||||||
app: {
|
app: {
|
||||||
name: import.meta.env.VITE_APP_TITLE,
|
name: import.meta.env.VITE_APP_TITLE,
|
||||||
layout: 'header-sidebar-nav',
|
// layout: 'sidebar-mixed-nav',
|
||||||
defaultHomePath: '/workspace', // 默认首页路径
|
defaultHomePath: '/workspace', // 默认首页路径
|
||||||
enablePreferences: false, // 是否启用偏好设置
|
enablePreferences: false, // 是否启用偏好设置
|
||||||
enableRefreshToken: true, // 启动刷新token模式
|
enableRefreshToken: true, // 启动刷新token模式
|
||||||
@@ -53,4 +53,10 @@ export const overridesPreferences = defineOverridesPreferences({
|
|||||||
middleClickToClose: true,
|
middleClickToClose: true,
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
},
|
},
|
||||||
|
header: {
|
||||||
|
mode: 'static',
|
||||||
|
},
|
||||||
|
logo:{
|
||||||
|
enable: true,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { createImageTaskV2 } from '#/api';
|
||||||
|
|
||||||
|
/* ---------------- refs ---------------- */
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const devices = ref<MediaDeviceInfo[]>([]);
|
||||||
|
const selectedDeviceId = ref<string>('');
|
||||||
|
|
||||||
|
const stream = ref<MediaStream | null>(null);
|
||||||
|
const resultImageUrl = ref<string>('');
|
||||||
|
|
||||||
|
const resolutions = ref<{ height: number; width: number }[]>([]);
|
||||||
|
const frameRates = ref<number[]>([]);
|
||||||
|
|
||||||
|
const selectedResolution = ref('');
|
||||||
|
const selectedFrameRate = ref('');
|
||||||
|
|
||||||
|
/* -------- 分析信息 -------- */
|
||||||
|
const showInfoStr = ref<Record<string, string | number>>({});
|
||||||
|
|
||||||
|
/* ---------------- 摄像头控制 ---------------- */
|
||||||
|
const startCamera = async () => {
|
||||||
|
stopCamera();
|
||||||
|
|
||||||
|
stream.value = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { deviceId: { exact: selectedDeviceId.value } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const track = stream.value.getVideoTracks()[0];
|
||||||
|
const caps = track.getCapabilities();
|
||||||
|
|
||||||
|
// 分辨率
|
||||||
|
if (caps.width && caps.height) {
|
||||||
|
const common = [
|
||||||
|
[640, 480],
|
||||||
|
[1280, 720],
|
||||||
|
[1920, 1080],
|
||||||
|
[2560, 1440],
|
||||||
|
];
|
||||||
|
|
||||||
|
resolutions.value = common
|
||||||
|
.filter(
|
||||||
|
([w, h]) =>
|
||||||
|
w >= caps.width!.min &&
|
||||||
|
w <= caps.width!.max &&
|
||||||
|
h >= caps.height!.min &&
|
||||||
|
h <= caps.height!.max,
|
||||||
|
)
|
||||||
|
.map(([w, h]) => ({ width: w, height: h }));
|
||||||
|
|
||||||
|
selectedResolution.value = `${resolutions.value[0].width}x${resolutions.value[0].height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帧率
|
||||||
|
if (caps.frameRate) {
|
||||||
|
frameRates.value = [
|
||||||
|
caps.frameRate.min,
|
||||||
|
Math.round((caps.frameRate.min + caps.frameRate.max) / 2),
|
||||||
|
caps.frameRate.max,
|
||||||
|
];
|
||||||
|
selectedFrameRate.value = String(frameRates.value[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
videoRef.value!.srcObject = stream.value;
|
||||||
|
await videoRef.value!.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVideoConstraints = async () => {
|
||||||
|
if (!stream.value) return;
|
||||||
|
|
||||||
|
const track = stream.value.getVideoTracks()[0];
|
||||||
|
const [w, h] = selectedResolution.value.split('x').map(Number);
|
||||||
|
|
||||||
|
await track.applyConstraints({
|
||||||
|
width: { exact: w },
|
||||||
|
height: { exact: h },
|
||||||
|
frameRate: { exact: Number(selectedFrameRate.value) },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
stream.value?.getTracks().forEach((t) => t.stop());
|
||||||
|
stream.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 摄像头枚举 ---------------- */
|
||||||
|
const loadCameras = async () => {
|
||||||
|
const all = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
devices.value = all.filter((d) => d.kind === 'videoinput');
|
||||||
|
if (devices.value.length) {
|
||||||
|
selectedDeviceId.value = devices.value[0].deviceId;
|
||||||
|
await startCamera();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeviceChange = async () => {
|
||||||
|
resolutions.value = [];
|
||||||
|
frameRates.value = [];
|
||||||
|
await startCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 拍照 ---------------- */
|
||||||
|
const takePhoto = async () => {
|
||||||
|
const video = videoRef.value;
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
if (!video || !canvas) return;
|
||||||
|
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve) =>
|
||||||
|
canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.95),
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File([blob], 'capture.jpg', { type: 'image/jpeg' });
|
||||||
|
await uploadImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 调后端接口 ---------------- */
|
||||||
|
const uploadImage = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('projectName', 'silkworm-cocoon');
|
||||||
|
|
||||||
|
const res = await createImageTaskV2(formData);
|
||||||
|
|
||||||
|
resultImageUrl.value = res.imageUrl;
|
||||||
|
|
||||||
|
const data = res; // 假设后端直接返回这些字段
|
||||||
|
showInfoStr.value = {
|
||||||
|
文件大小: `${data.size} MB`,
|
||||||
|
分辨率: data.resolution,
|
||||||
|
最高置信度: data.max_confidence,
|
||||||
|
最低置信度: data.min_confidence,
|
||||||
|
平均置信度: data.average_confidence,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 生命周期 ---------------- */
|
||||||
|
onMounted(loadCameras);
|
||||||
|
onBeforeUnmount(stopCamera);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="camera-page">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="select-item">
|
||||||
|
<select v-model="selectedDeviceId" @change="onDeviceChange">
|
||||||
|
<option v-for="d in devices" :key="d.deviceId" :value="d.deviceId">
|
||||||
|
{{ d.label || 'Camera' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="select-item">
|
||||||
|
<select v-model="selectedResolution" @change="applyVideoConstraints">
|
||||||
|
<option
|
||||||
|
v-for="r in resolutions"
|
||||||
|
:key="`${r.width}x${r.height}`"
|
||||||
|
:value="`${r.width}x${r.height}`"
|
||||||
|
>
|
||||||
|
{{ r.width }} × {{ r.height }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="select-item">
|
||||||
|
<select v-model="selectedFrameRate" @change="applyVideoConstraints">
|
||||||
|
<option v-for="f in frameRates" :key="f" :value="f">
|
||||||
|
{{ f }} FPS
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="capture-btn" @click="takePhoto">拍照</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主体 -->
|
||||||
|
<div class="content">
|
||||||
|
<!-- 左:摄像头 -->
|
||||||
|
<div class="panel panel-video">
|
||||||
|
<video ref="videoRef" playsinline muted></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右:分析 -->
|
||||||
|
<div class="panel panel-result">
|
||||||
|
<div class="info-panel">
|
||||||
|
<div
|
||||||
|
v-for="(v, k) in showInfoStr"
|
||||||
|
:key="k"
|
||||||
|
class="info-row"
|
||||||
|
>
|
||||||
|
<span class="key">{{ k }}</span>
|
||||||
|
<span class="value">{{ v }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-panel">
|
||||||
|
<img v-if="resultImageUrl" :src="resultImageUrl" />
|
||||||
|
<div v-else class="placeholder">等待拍照结果</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas ref="canvasRef" style="display: none" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-page {
|
||||||
|
padding: 16px;
|
||||||
|
height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 工具栏 ---------- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-item {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-item select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: #e5e7eb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主体 ---------- */
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100% - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #111;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧视频 */
|
||||||
|
.panel-video {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-video video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧结果 */
|
||||||
|
.panel-result {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel {
|
||||||
|
padding: 12px;
|
||||||
|
background: #0b0b0b;
|
||||||
|
max-height: 40%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row .key {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-panel img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 按钮 ---------- */
|
||||||
|
.capture-btn {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -264,6 +264,7 @@ function changePage(newPage) {
|
|||||||
<div class="mb-4 flex justify-between space-x-2">
|
<div class="mb-4 flex justify-between space-x-2">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
:disabled="true"
|
||||||
@click="createTask"
|
@click="createTask"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from '#/adapter/vxe-table';
|
} from '#/adapter/vxe-table';
|
||||||
import type { SystemDeviceApi } from '#/api';
|
import type { SystemDeviceApi } from '#/api';
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted, reactive } from 'vue';
|
import { onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
|
||||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||||
import { Plus } from '@vben/icons';
|
import { Plus } from '@vben/icons';
|
||||||
@@ -21,10 +21,10 @@ import {
|
|||||||
iotSendCommand,
|
iotSendCommand,
|
||||||
updateDevicePatch,
|
updateDevicePatch,
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
|
import * as api from '#/api';
|
||||||
|
|
||||||
import { useColumns, useGridFormSchema } from './data';
|
import { useColumns, useGridFormSchema } from './data';
|
||||||
import Form from './form.vue';
|
import Form from './form.vue';
|
||||||
import { useAccessStore } from "@vben/stores";
|
|
||||||
|
|
||||||
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
const [FormDrawer, formDrawerApi] = useVbenDrawer({
|
||||||
connectedComponent: Form,
|
connectedComponent: Form,
|
||||||
@@ -165,7 +165,14 @@ function onEdit(row: SystemDeviceApi.SystemDevice) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onCommand(data: SystemDeviceApi.SystemDevice, command: string) {
|
async function onCommand(data: SystemDeviceApi.SystemDevice, command: string) {
|
||||||
await iotSendCommand(data.name, command, data.dept_id, data.device_type);
|
const resMsg = await iotSendCommand(
|
||||||
|
data.name,
|
||||||
|
command,
|
||||||
|
data.dept_id,
|
||||||
|
data.device_type,
|
||||||
|
);
|
||||||
|
|
||||||
|
message.success(resMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete(row: SystemDeviceApi.SystemDevice) {
|
function onDelete(row: SystemDeviceApi.SystemDevice) {
|
||||||
@@ -195,38 +202,24 @@ function onCreate() {
|
|||||||
formDrawerApi.setData({}).open();
|
formDrawerApi.setData({}).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsState = reactive({
|
// ---- WS(占位) ----
|
||||||
ws: null as null | WebSocket,
|
const wsClient = api.createAutoReconnectWs({
|
||||||
});
|
path: () => `iot/ws/device-status`,
|
||||||
let ws: null | WebSocket = null;
|
onMessage: async (msg) => {
|
||||||
|
|
||||||
function createWs(token: string) {
|
|
||||||
ws = new WebSocket(
|
|
||||||
`wss://ai.ronsunny.cn:8090/ai/iot/ws/device-status?token=${token}`,
|
|
||||||
// `ws://127.0.0.1:13011/iot/ws/device-status?token=${token}`,
|
|
||||||
);
|
|
||||||
wsState.ws = ws;
|
|
||||||
|
|
||||||
ws.onmessage = async (e) => {
|
|
||||||
const msg = JSON.parse(e.data);
|
|
||||||
console.log('WS 事件', msg);
|
|
||||||
if (msg.type === 'status') {
|
if (msg.type === 'status') {
|
||||||
await gridApi.query();
|
await gridApi.query();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 简单策略:任何设备上下线都刷新列表 后续改进针对单行
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const accessStore = useAccessStore();
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (accessStore.accessToken) {
|
wsClient.connect();
|
||||||
createWs(accessStore.accessToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (wsState.ws) wsState.ws.close();
|
wsClient.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Page auto-content-height>
|
<Page auto-content-height>
|
||||||
@@ -249,6 +242,9 @@ onBeforeUnmount(() => {
|
|||||||
<Tag v-else-if="row.device_type === 'server'" style="color: #ff9c00">
|
<Tag v-else-if="row.device_type === 'server'" style="color: #ff9c00">
|
||||||
服务器
|
服务器
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Tag v-else-if="row.device_type !== ''" style="color: #9049d3">
|
||||||
|
{{ row.device_type }}
|
||||||
|
</Tag>
|
||||||
<Tag v-else style="color: gray">其他</Tag>
|
<Tag v-else style="color: gray">其他</Tag>
|
||||||
</template>
|
</template>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export function useFormSchema(uploadState: {
|
|||||||
label: '更新包',
|
label: '更新包',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
maxCount: 1,
|
maxCount: 1,
|
||||||
accept: '.exe',
|
|
||||||
customRequest: async ({ file, onSuccess, onError }) => {
|
customRequest: async ({ file, onSuccess, onError }) => {
|
||||||
try {
|
try {
|
||||||
uploadState.uploading = true;
|
uploadState.uploading = true;
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getDrawerTitle = computed(() => {
|
const getDrawerTitle = computed(() => {
|
||||||
return formData.value?.id ? '修改设备' : '新增设备';
|
return formData.value?.id ? '修改' : '新增更新包';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
@@ -0,0 +1,643 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import EzuikitFlv from 'ezuikit-flv';
|
||||||
|
|
||||||
|
import * as api from '#/api';
|
||||||
|
import { getSentinelRecordList } from '#/api';
|
||||||
|
|
||||||
|
import 'ezuikit-flv/style.css';
|
||||||
|
|
||||||
|
// ----- 配置区(替换点已标注) -----
|
||||||
|
const videoList = ref([]);
|
||||||
|
|
||||||
|
// carousel images(基础信息页左侧)
|
||||||
|
const carouselImages = ref([]);
|
||||||
|
|
||||||
|
// ----- 响应式状态 -----
|
||||||
|
const currentTab = ref('monitor'); // 'info' | 'monitor'
|
||||||
|
const selectedVideoId = ref(null);
|
||||||
|
|
||||||
|
// 右侧记录(初始 10 条),后端逻辑:新连接会先推最近 10 条,这里按你的描述模拟
|
||||||
|
const records = ref([]);
|
||||||
|
|
||||||
|
// 顶部时间与农历
|
||||||
|
const timeStr = ref('');
|
||||||
|
const calendarStr = ref('');
|
||||||
|
const weekdayStr = ref('');
|
||||||
|
const lunarStr = ref('');
|
||||||
|
|
||||||
|
// 天气(固定数据,12h 刷新位点已留)
|
||||||
|
const weather = reactive({
|
||||||
|
condition: '暂无',
|
||||||
|
dayTemp: 26,
|
||||||
|
nightTemp: 18,
|
||||||
|
});
|
||||||
|
function onNewRecord(record) {
|
||||||
|
records.value.unshift(record);
|
||||||
|
|
||||||
|
// 高亮提示
|
||||||
|
nextTick(() => {
|
||||||
|
const el = listInner.value?.children[0];
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('new-record-highlight');
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.remove('new-record-highlight');
|
||||||
|
el.classList.add('new-record-highlight-removal');
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.remove('new-record-highlight-removal');
|
||||||
|
}, 500);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (records.value.length > 10) {
|
||||||
|
records.value.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮播控制
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
|
||||||
|
const autoSwitchInfo = ref(true);
|
||||||
|
const autoSwitchList = ref(true);
|
||||||
|
let interval = null;
|
||||||
|
|
||||||
|
const nextSlide = () => {
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % carouselImages.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevSlide = () => {
|
||||||
|
currentIndex.value =
|
||||||
|
(currentIndex.value - 1 + carouselImages.value.length) %
|
||||||
|
carouselImages.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSlide = (idx) => {
|
||||||
|
currentIndex.value = idx;
|
||||||
|
};
|
||||||
|
// 自动滚动(监控画面时右侧列表慢速滚动)
|
||||||
|
let autoScrollRAF = null;
|
||||||
|
const listWrapper = ref(null);
|
||||||
|
const listInner = ref(null);
|
||||||
|
let scrollPos = 0;
|
||||||
|
|
||||||
|
// ----- 页面生命周期 -----
|
||||||
|
onMounted(async () => {
|
||||||
|
// 获取轮播图片
|
||||||
|
carouselImages.value = await api.getSentinelMonitorPromotionalList();
|
||||||
|
// 获取直播地址
|
||||||
|
videoList.value = await api.getSentinelMonitorList();
|
||||||
|
// 初始化播放器(监控页)
|
||||||
|
await nextTick();
|
||||||
|
initPlayers();
|
||||||
|
const res = await getSentinelRecordList({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
records.value.unshift(...res.list);
|
||||||
|
|
||||||
|
// 时间显示
|
||||||
|
updateDateTime();
|
||||||
|
const timeTick = setInterval(updateDateTime, 1000);
|
||||||
|
|
||||||
|
// 自动刷新天气(12h):位置已留(这里用 setInterval,真实请替换 fetchWeather)
|
||||||
|
fetchWeather();
|
||||||
|
const weatherTimer = setInterval(fetchWeather, 12 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timeTick);
|
||||||
|
clearInterval(weatherTimer);
|
||||||
|
destroyPlayers();
|
||||||
|
stopAutoScroll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切 tab
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab.value = tab;
|
||||||
|
|
||||||
|
if (tab === 'monitor') {
|
||||||
|
nextTick(() => {
|
||||||
|
initPlayers(); // 确保播放器重新初始化
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
destroyPlayers(); // 切走就停止播放
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自动轮播
|
||||||
|
watch(
|
||||||
|
autoSwitchInfo,
|
||||||
|
async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await nextTick(); // 等 DOM
|
||||||
|
interval = setInterval(() => {
|
||||||
|
nextSlide();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
autoSwitchList,
|
||||||
|
async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await nextTick(); // 等 DOM
|
||||||
|
startAutoScroll();
|
||||||
|
} else {
|
||||||
|
stopAutoScroll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 播放器管理 ----
|
||||||
|
const players = ref([]); // ezuikit players
|
||||||
|
const playerContainers = reactive({});
|
||||||
|
|
||||||
|
function initPlayers() {
|
||||||
|
destroyPlayers();
|
||||||
|
players.value = [];
|
||||||
|
|
||||||
|
videoList.value.forEach((item) => {
|
||||||
|
const container = playerContainers[item.id];
|
||||||
|
if (container && item.url) {
|
||||||
|
const player = new EzuikitFlv({
|
||||||
|
container,
|
||||||
|
url: item.url,
|
||||||
|
hasAudio: false,
|
||||||
|
useMSE: true,
|
||||||
|
autoPlay: true,
|
||||||
|
muted:true,
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
|
players.value.push({ id: item.id, player });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyPlayers() {
|
||||||
|
players.value.forEach((p) => {
|
||||||
|
try {
|
||||||
|
p.player && p.player.stop && p.player.stop();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
players.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 时间与农历 ----
|
||||||
|
function updateDateTime() {
|
||||||
|
const d = new Date();
|
||||||
|
timeStr.value = d.toLocaleTimeString();
|
||||||
|
calendarStr.value = d.toLocaleDateString();
|
||||||
|
weekdayStr.value = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()];
|
||||||
|
|
||||||
|
// 使用 Intl 近似获取农历(现代浏览器支持)
|
||||||
|
try {
|
||||||
|
const lunar = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(d);
|
||||||
|
lunarStr.value = lunar;
|
||||||
|
} catch {
|
||||||
|
lunarStr.value = '农历数据不可用';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 获取天气信息
|
||||||
|
async function fetchWeather() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
'https://f8.api.dev.bbitcn.cn/api/Weather/GetWeatherInfoLives',
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.result && json.data) {
|
||||||
|
const data = json.data;
|
||||||
|
weather.city = data.city || '--';
|
||||||
|
weather.condition = data.weather || '--';
|
||||||
|
weather.icon = data.weatherpic || '';
|
||||||
|
|
||||||
|
// 如果接口只有当前温度,可以暂时用同一个值
|
||||||
|
const temp = Number.parseInt(data.temperature);
|
||||||
|
weather.dayTemp = temp;
|
||||||
|
weather.nightTemp = temp - 5; // 简单假设夜间温度比白天低 5 度
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('天气获取失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WS(占位) ----
|
||||||
|
const wsClient = api.createAutoReconnectWs({
|
||||||
|
path: () => `iot/ws/sentinel_record_notice`,
|
||||||
|
onMessage: (msg) => {
|
||||||
|
if (msg.type === 'vehicle_alert') {
|
||||||
|
onNewRecord(msg.content);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
wsClient.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
wsClient.close();
|
||||||
|
});
|
||||||
|
let state = 'scrolling';
|
||||||
|
let pauseStart = 0;
|
||||||
|
|
||||||
|
function startAutoScroll() {
|
||||||
|
stopAutoScroll();
|
||||||
|
|
||||||
|
const wrapper = listWrapper.value;
|
||||||
|
const inner = listInner.value;
|
||||||
|
if (!wrapper || !inner) return;
|
||||||
|
|
||||||
|
const speed = 20 / 1000; // px/ms
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
scrollPos = 0;
|
||||||
|
state = 'scrolling';
|
||||||
|
inner.style.transform = `translateY(0px)`;
|
||||||
|
|
||||||
|
function step(now) {
|
||||||
|
const delta = now - last;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
const innerH = inner.scrollHeight;
|
||||||
|
const wrapH = wrapper.clientHeight;
|
||||||
|
|
||||||
|
if (innerH <= wrapH) {
|
||||||
|
inner.style.transform = `translateY(0px)`;
|
||||||
|
autoScrollRAF = requestAnimationFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScroll = innerH - wrapH;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'pausing': {
|
||||||
|
if (now - pauseStart >= 2000) {
|
||||||
|
state = 'resetting';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'resetting': {
|
||||||
|
scrollPos = 0;
|
||||||
|
state = 'scrolling';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'scrolling': {
|
||||||
|
scrollPos += speed * delta;
|
||||||
|
if (scrollPos >= maxScroll) {
|
||||||
|
scrollPos = maxScroll;
|
||||||
|
state = 'pausing';
|
||||||
|
pauseStart = now;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.style.transform = `translateY(${-scrollPos}px)`;
|
||||||
|
autoScrollRAF = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScrollRAF = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoScroll() {
|
||||||
|
if (autoScrollRAF) {
|
||||||
|
cancelAnimationFrame(autoScrollRAF);
|
||||||
|
autoScrollRAF = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 列表项点击:聚焦视频(高亮) ----
|
||||||
|
function focusVideo(item) {
|
||||||
|
// 简单策略:轮流选择视频格子(或你可以按 item.id 映射)
|
||||||
|
const target = videoList.value[(item.id - 5000) % videoList.value.length];
|
||||||
|
if (target) {
|
||||||
|
selectedVideoId.value = target.id;
|
||||||
|
// 视觉高亮 3s 后取消
|
||||||
|
setTimeout(() => {
|
||||||
|
selectedVideoId.value = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen w-screen flex-col bg-slate-50 text-slate-900">
|
||||||
|
<!-- 顶部 -->
|
||||||
|
<header class="flex items-center justify-between px-8 py-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-5xl font-extrabold text-sky-800">中国</span>
|
||||||
|
<img src="../logo.png" alt="logo" class="h-10 w-10 object-contain" />
|
||||||
|
<span class="text-5xl font-extrabold text-sky-800">动监</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-6 flex flex-col items-start">
|
||||||
|
<span class="text-xl font-medium text-slate-700">
|
||||||
|
牧安云哨·指定通道畜牧车辆监管预警平台
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 宁南站标签 -->
|
||||||
|
<span
|
||||||
|
class="mt-1 inline-block rounded-full bg-gradient-to-r from-sky-100 to-sky-200 px-4 py-1 text-sm font-medium text-sky-800 shadow-md"
|
||||||
|
>
|
||||||
|
宁南站
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中右 按钮 -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="mr-6 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-md border px-4 py-2"
|
||||||
|
:class="[
|
||||||
|
currentTab === 'info'
|
||||||
|
? 'border-sky-700 bg-sky-700 text-white'
|
||||||
|
: 'border-slate-300 bg-white text-slate-700',
|
||||||
|
]"
|
||||||
|
@click="switchTab('info')"
|
||||||
|
>
|
||||||
|
规章制度
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md border px-4 py-2"
|
||||||
|
:class="[
|
||||||
|
currentTab === 'monitor'
|
||||||
|
? 'border-sky-700 bg-sky-700 text-white'
|
||||||
|
: 'border-slate-300 bg-white text-slate-700',
|
||||||
|
]"
|
||||||
|
@click="switchTab('monitor')"
|
||||||
|
>
|
||||||
|
监控画面
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间、农历、星期、天气 -->
|
||||||
|
<div class="flex flex-col items-end text-right">
|
||||||
|
<div class="text-sm text-slate-600">{{ calendarStr }}</div>
|
||||||
|
<div class="text-lg font-medium">{{ timeStr }}</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">
|
||||||
|
星期{{ weekdayStr }} ·
|
||||||
|
<span class="mr-2">农历{{ lunarStr }}</span> 天气:{{
|
||||||
|
weather.condition
|
||||||
|
}}
|
||||||
|
· 白天 {{ weather.dayTemp }}℃ / 夜间 {{ weather.nightTemp }}℃
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主体分割线(细/粗)-->
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="h-[3px] bg-sky-200"></div>
|
||||||
|
<div class="h-[6px] bg-sky-800"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主体 -->
|
||||||
|
<main class="flex-1 overflow-hidden p-6">
|
||||||
|
<div class="flex h-full gap-6">
|
||||||
|
<!-- 左侧主区域 -->
|
||||||
|
<section class="flex flex-1 flex-col rounded-lg bg-white p-4 shadow-md">
|
||||||
|
<div
|
||||||
|
v-if="currentTab === 'info'"
|
||||||
|
class="flex flex-1 gap-4 bg-slate-50 p-4"
|
||||||
|
>
|
||||||
|
<!-- 左侧列表 -->
|
||||||
|
<div
|
||||||
|
class="flex w-1/3 flex-col overflow-hidden rounded bg-white shadow-md"
|
||||||
|
>
|
||||||
|
<!-- 顶部开关 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-slate-200 p-3"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-slate-700">自动轮播</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="autoSwitchInfo"
|
||||||
|
class="toggle-checkbox h-5 w-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片列表 -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
v-for="(data, idx) in carouselImages"
|
||||||
|
:key="idx"
|
||||||
|
class="flex cursor-pointer items-center gap-3 border-b border-slate-100 p-2 transition hover:bg-slate-100"
|
||||||
|
:class="currentIndex === idx ? 'bg-sky-50' : ''"
|
||||||
|
@click="setSlide(idx)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="data.url"
|
||||||
|
class="h-16 w-24 rounded border border-slate-200 object-cover"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 break-words text-sm text-slate-700">
|
||||||
|
{{ data.remark }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧大图 -->
|
||||||
|
<div
|
||||||
|
class="relative aspect-video flex-1 overflow-hidden rounded bg-slate-100"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="carouselImages[currentIndex].url"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="absolute bottom-3 right-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-white/80 px-3 py-1 text-sm shadow"
|
||||||
|
@click="prevSlide"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-white/80 px-3 py-1 text-sm shadow"
|
||||||
|
@click="nextSlide"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 监控画面:四宫格播放器 -->
|
||||||
|
<div v-else class="grid flex-1 grid-cols-2 grid-rows-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="item in videoList"
|
||||||
|
:key="item.id"
|
||||||
|
class="relative overflow-hidden rounded-lg border"
|
||||||
|
:class="[
|
||||||
|
selectedVideoId === item.id
|
||||||
|
? 'border-sky-600 ring-2 ring-sky-200'
|
||||||
|
: 'border-slate-200',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 左上角视频名称 -->
|
||||||
|
<div
|
||||||
|
class="absolute left-2 top-2 z-10 rounded bg-slate-800/60 px-2 py-1 text-xs text-white"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 播放器容器,绑定 ref -->
|
||||||
|
<div
|
||||||
|
class="h-full w-full bg-black"
|
||||||
|
:ref="(el) => (playerContainers[item.id] = el)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 右侧面板:根据 tab 不同显示不同交互(但都为车辆列表) -->
|
||||||
|
<aside
|
||||||
|
class="flex w-[420px] flex-col rounded-lg bg-white p-4 shadow-md"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-700">实时车辆检测</h3>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-slate-200 p-3"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-slate-700">自动滚动</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="autoSwitchList"
|
||||||
|
class="toggle-checkbox h-5 w-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="listWrapper" class="relative flex-1 overflow-hidden">
|
||||||
|
<!-- 滚动区域 -->
|
||||||
|
<div ref="listInner" class="absolute left-0 right-0 top-0">
|
||||||
|
<!-- 列表项动画 -->
|
||||||
|
<div
|
||||||
|
v-for="item in records"
|
||||||
|
:key="item.id"
|
||||||
|
class="relative mb-3 flex cursor-pointer gap-3 rounded border border-slate-200 bg-white p-3 transition hover:shadow-sm"
|
||||||
|
:class="{
|
||||||
|
'animate-new-record': item.isNew,
|
||||||
|
}"
|
||||||
|
@click="focusVideo(item)"
|
||||||
|
>
|
||||||
|
<!-- 左:车身图片 -->
|
||||||
|
<img
|
||||||
|
:src="item.vehicle_image"
|
||||||
|
class="h-20 w-32 rounded border border-slate-200 object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 右:信息 -->
|
||||||
|
<div class="flex flex-1 flex-col justify-between">
|
||||||
|
<!-- 第一行:车牌 + 车型 -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="text-sm font-semibold text-sky-700">
|
||||||
|
{{ item.license_plate }}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-slate-400">|</span>
|
||||||
|
<div class="text-xs text-slate-600">
|
||||||
|
{{ item.vehicle_type }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:动物 + 部门 -->
|
||||||
|
<div
|
||||||
|
class="mt-1 flex flex-wrap gap-x-4 text-xs text-slate-600"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
动物:
|
||||||
|
<span class="font-medium text-slate-700">
|
||||||
|
{{ item.livestock_type || '未知' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-[180px] truncate">
|
||||||
|
站点:
|
||||||
|
<span class="font-medium text-slate-700">
|
||||||
|
{{ item.dept_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第三行:时间 -->
|
||||||
|
<div class="mt-1 text-xs text-slate-400">
|
||||||
|
{{ item.created_at }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
/* 额外样式:让右侧列表平滑循环(配合 JS transform) */
|
||||||
|
[ref='listWrapper'] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.toggle-checkbox {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked {
|
||||||
|
background-color: #0ea5e9; /* sky-500 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 1.25rem; /* 20px */
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #fff;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked::before {
|
||||||
|
transform: translateX(1.25rem); /* 移动到右侧 */
|
||||||
|
}
|
||||||
|
.new-record-highlight {
|
||||||
|
background-color: #e0f2ff; /* 淡蓝色,高级感 */
|
||||||
|
transition: background-color 1.5s ease;
|
||||||
|
}
|
||||||
|
.new-record-highlight {
|
||||||
|
background-color: #e0f2ff;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0.8;
|
||||||
|
transition:
|
||||||
|
all 0.5s ease,
|
||||||
|
background-color 1.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-record-highlight-removal {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,13 +5,12 @@ import type {
|
|||||||
OnActionClickParams,
|
OnActionClickParams,
|
||||||
VxeTableGridOptions,
|
VxeTableGridOptions,
|
||||||
} from '#/adapter/vxe-table';
|
} from '#/adapter/vxe-table';
|
||||||
import type { SentinelApi } from '#/api';
|
import * as api from '#/api';
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||||
|
|
||||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||||
import { Plus } from '@vben/icons';
|
import { Plus } from '@vben/icons';
|
||||||
import { useAccessStore } from '@vben/stores';
|
|
||||||
|
|
||||||
import { Button, message, Modal } from 'ant-design-vue';
|
import { Button, message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -155,50 +154,25 @@ function onCreate() {
|
|||||||
formDrawerApi.setData({}).open();
|
formDrawerApi.setData({}).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsState = reactive({
|
|
||||||
ws: null as null | WebSocket,
|
|
||||||
});
|
|
||||||
const alertState = reactive({
|
const alertState = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
const wsClient = api.createAutoReconnectWs({
|
||||||
let ws: null | WebSocket = null;
|
path: () => `iot/ws/sentinel_record`,
|
||||||
|
onMessage: (msg) => {
|
||||||
function createWs(token: string) {
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws = new WebSocket(
|
|
||||||
// `ws://127.0.0.1:13011/iot/ws/sentinel_record?token=${token}`,
|
|
||||||
`wss://ai.ronsunny.cn:8090/ai/iot/ws/sentinel_record?token=${token}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
const msg = JSON.parse(e.data);
|
|
||||||
if (msg.type === 'vehicle_alert') {
|
if (msg.type === 'vehicle_alert') {
|
||||||
handleVehicleAlert(msg.content);
|
handleVehicleAlert(msg.content);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
console.warn('ws closed');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const accessStore = useAccessStore();
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (accessStore.accessToken) {
|
wsClient.connect();
|
||||||
createWs(accessStore.accessToken);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (ws) {
|
wsClient.close();
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleVehicleAlert(content: string) {
|
function handleVehicleAlert(content: string) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# push_docker.ps1
|
||||||
|
|
||||||
|
# Set version
|
||||||
|
$env:VERSION = "1.4.6"
|
||||||
|
|
||||||
|
# Docker registry/repository
|
||||||
|
$registry = "ai.ronsunny.cn:13011/bbit_ai/ce_vue"
|
||||||
|
|
||||||
|
# Dockerfile path
|
||||||
|
$dockerfilePath = ".\scripts\deploy\Dockerfile"
|
||||||
|
|
||||||
|
# --- Construct tags safely for older PowerShell ---
|
||||||
|
$tag = $registry + ":" + $env:VERSION
|
||||||
|
$latestTag = $registry + ":latest"
|
||||||
|
|
||||||
|
# Optional: check if Docker is available
|
||||||
|
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "Docker not found. Make sure Docker is installed and in PATH."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
Write-Host "Building image: $tag"
|
||||||
|
docker build -f $dockerfilePath -t $tag .
|
||||||
|
|
||||||
|
# Tag as latest
|
||||||
|
Write-Host "Tagging image as latest: $latestTag"
|
||||||
|
docker tag $tag $latestTag
|
||||||
|
|
||||||
|
# Push both images
|
||||||
|
Write-Host "Pushing image: $tag"
|
||||||
|
docker push $tag
|
||||||
|
|
||||||
|
Write-Host "Pushing image: $latestTag"
|
||||||
|
docker push $latestTag
|
||||||
|
|
||||||
|
Write-Host "Docker image build and push completed!"
|
||||||
Reference in New Issue
Block a user