第一次提交
This commit is contained in:
16
jeecgboot-vue3/src/views/dashboard/Analysis/api.ts
Normal file
16
jeecgboot-vue3/src/views/dashboard/Analysis/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
enum Api {
|
||||
loginfo = '/sys/loginfo',
|
||||
visitInfo = '/sys/visitInfo',
|
||||
}
|
||||
/**
|
||||
* 日志统计信息
|
||||
* @param params
|
||||
*/
|
||||
export const getLoginfo = (params) => defHttp.get({ url: Api.loginfo, params }, { isTransformResponse: false });
|
||||
/**
|
||||
* 访问量信息
|
||||
* @param params
|
||||
*/
|
||||
export const getVisitInfo = (params) => defHttp.get({ url: Api.visitInfo, params }, { isTransformResponse: false });
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<a-card :loading="loading" :bordered="false" :body-style="{ padding: '0' }">
|
||||
<div class="salesCard">
|
||||
<a-tabs default-active-key="1" size="large" :tab-bar-style="{ marginBottom: '24px', paddingLeft: '16px' }">
|
||||
<template #rightExtra>
|
||||
<div class="extra-wrapper">
|
||||
<div class="extra-item">
|
||||
<a>今日</a>
|
||||
<a>本周</a>
|
||||
<a>本月</a>
|
||||
<a>本年</a>
|
||||
</div>
|
||||
<a-range-picker :style="{ width: '256px' }" />
|
||||
</div>
|
||||
</template>
|
||||
<a-tab-pane loading="true" tab="受理监管" key="1">
|
||||
<a-row>
|
||||
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<Bar :chartData="barData" :option="{ title: { text: '', textStyle: { fontWeight: 'lighter' } } }" height="40vh" :seriesColor="seriesColor" />
|
||||
</a-col>
|
||||
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<QuickNav :loading="loading" class="enter-y" :bordered="false" :body-style="{ padding: 0 }" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="交互监管" key="2">
|
||||
<a-row>
|
||||
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<BarMulti
|
||||
:seriesColor="interactiveColor"
|
||||
:chartData="barMultiData"
|
||||
:option="{ title: { text: '', textStyle: { fontWeight: 'lighter' } } }"
|
||||
height="40vh"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<QuickNav :loading="loading" class="enter-y" :bordered="false" :body-style="{ padding: 0 }" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="存储监管" key="3">
|
||||
<a-row>
|
||||
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24" style="display: flex">
|
||||
<Gauge :seriesColor="seriesColor" :chartData="{ name: 'C盘', value: 70 }" height="30vh"></Gauge>
|
||||
<Gauge :seriesColor="seriesColor" :chartData="{ name: 'D盘', value: 50 }" height="30vh"></Gauge>
|
||||
</a-col>
|
||||
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<QuickNav :loading="loading" class="enter-y" :bordered="false" :body-style="{ padding: 0 }" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Bar from '/@/components/chart/Bar.vue';
|
||||
import BarMulti from '/@/components/chart/BarMulti.vue';
|
||||
import Gauge from '/@/components/chart/Gauge.vue';
|
||||
import QuickNav from './QuickNav.vue';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
const { getThemeColor } = useRootSetting();
|
||||
const interactiveColor = ref();
|
||||
const rankList = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
rankList.push({
|
||||
name: '白鹭岛 ' + (i + 1) + ' 号店',
|
||||
total: 1234.56 - i * 100,
|
||||
});
|
||||
}
|
||||
|
||||
const barData = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
barData.push({
|
||||
name: `${i + 1}月`,
|
||||
value: Math.floor(Math.random() * 1000) + 200,
|
||||
});
|
||||
}
|
||||
const barMultiData = [];
|
||||
for (let j = 0; j < 2; j++) {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
barMultiData.push({
|
||||
type: j == 0 ? 'jeecg' : 'jeebt',
|
||||
name: `${i + 1}月`,
|
||||
value: Math.floor(Math.random() * 1000) + 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const seriesColor = computed(() => {
|
||||
interactiveColor.value = [
|
||||
{ type: 'jeecg', color: getThemeColor.value },
|
||||
{ type: 'jeebt', color: getRandomColor() },
|
||||
];
|
||||
return getThemeColor.value;
|
||||
});
|
||||
function getRandomColor() {
|
||||
var letters = '0123456789ABCDEF';
|
||||
var color = '#';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.extra-wrapper {
|
||||
line-height: 55px;
|
||||
padding-right: 24px;
|
||||
|
||||
.extra-item {
|
||||
display: inline-block;
|
||||
margin-right: 24px;
|
||||
|
||||
a {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="md:flex">
|
||||
<template v-for="(item, index) in dataList" :key="item.title">
|
||||
<ChartCard
|
||||
:loading="loading"
|
||||
:title="item.title"
|
||||
:total="getTotal(item.total, index)"
|
||||
class="md:w-1/4 w-full !md:mt-0 !mt-4"
|
||||
:class="[index + 1 < 4 && '!md:mr-4']"
|
||||
>
|
||||
<template #action>
|
||||
<a-tooltip title="指标说明">
|
||||
<Icon :icon="item.icon" :size="20" />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<div v-if="type === 'chart'">
|
||||
<Trend term="周同比" :percentage="12" v-if="index === 0" />
|
||||
<Trend term="日同比" :percentage="11" v-if="index === 0" :type="false" />
|
||||
|
||||
<SingleLine v-if="index === 1" :option="option" :chartData="chartData" :seriesColor="seriesColor" height="50px"></SingleLine>
|
||||
|
||||
<Bar v-if="index === 2" :option="option" :chartData="chartData" :seriesColor="seriesColor" height="50px"></Bar>
|
||||
|
||||
<Progress v-if="index === 3" :percent="78" :show-info="false"></Progress>
|
||||
</div>
|
||||
<div v-else>
|
||||
<SingleLine :seriesColor="seriesColor" v-if="index === 0" :option="option" :chartData="chartData" height="50px"></SingleLine>
|
||||
|
||||
<SingleLine :seriesColor="seriesColor" v-if="index === 1" :option="option" :chartData="chartData" height="50px"></SingleLine>
|
||||
|
||||
<Bar :seriesColor="seriesColor" v-if="index === 2" :option="option" :chartData="chartData" height="50px"></Bar>
|
||||
|
||||
<Progress v-if="index === 3" :percent="78" :show-info="false"></Progress>
|
||||
|
||||
</div>
|
||||
<template #footer v-if="type === 'chart'">
|
||||
<span v-if="index !== 3"
|
||||
>{{ item.footer }}<span>{{ item.value }}</span></span
|
||||
>
|
||||
<Trend term="周同比" :percentage="12" v-if="index === 3" />
|
||||
<Trend term="日同比" :percentage="11" v-if="index === 3" :type="false" />
|
||||
</template>
|
||||
<template #footer v-else>
|
||||
<span
|
||||
>{{ item.footer }}<span>{{ item.value }}</span></span
|
||||
>
|
||||
</template>
|
||||
</ChartCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { Progress } from 'ant-design-vue';
|
||||
import ChartCard from '/@/components/chart/ChartCard.vue';
|
||||
import Trend from '/@/components/chart/Trend.vue';
|
||||
import Bar from '/@/components/chart/Bar.vue';
|
||||
import SingleLine from '/@/components/chart/SingleLine.vue';
|
||||
import { chartCardList, bdcCardList } from '../data';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
|
||||
const { getThemeColor } = useRootSetting();
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'chart',
|
||||
},
|
||||
});
|
||||
const option = ref({ xAxis: { show: false, boundaryGap: false }, yAxis: { show: false, boundaryGap: false, max: 220 } });
|
||||
|
||||
const chartData = ref([
|
||||
{
|
||||
name: '1月',
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
name: '2月',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
name: '3月',
|
||||
value: 150,
|
||||
},
|
||||
{
|
||||
name: '4月',
|
||||
value: 40,
|
||||
},
|
||||
{
|
||||
name: '5月',
|
||||
value: 110,
|
||||
},
|
||||
{
|
||||
name: '6月',
|
||||
value: 120,
|
||||
},
|
||||
]);
|
||||
const seriesColor = computed(() => {
|
||||
return getThemeColor.value;
|
||||
})
|
||||
const dataList = computed(() => (props.type === 'dbc' ? bdcCardList : chartCardList));
|
||||
|
||||
function getTotal(total, index) {
|
||||
return index === 0 ? `¥${total}` : index === 3 ? `${total}%` : total;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="md:flex">
|
||||
<template v-for="(item, index) in growCardList" :key="item.title">
|
||||
<Card
|
||||
size="small"
|
||||
:loading="loading"
|
||||
:title="item.title"
|
||||
class="md:w-1/4 w-full !md:mt-0 !mt-4"
|
||||
:class="[index + 1 < 4 && '!md:mr-4']"
|
||||
:canExpan="false"
|
||||
>
|
||||
<template #extra>
|
||||
<Tag :color="item.color">{{ item.action }}</Tag>
|
||||
</template>
|
||||
|
||||
<div class="py-4 px-4 flex justify-between">
|
||||
<CountTo prefix="$" :startVal="1" :endVal="item.value" class="text-2xl" />
|
||||
<Icon :icon="item.icon" :size="40" />
|
||||
</div>
|
||||
|
||||
<div class="p-2 px-4 flex justify-between">
|
||||
<span>总{{ item.title }}</span>
|
||||
<CountTo prefix="$" :startVal="1" :endVal="item.total" />
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { CountTo } from '/@/components/CountTo/index';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { Tag, Card } from 'ant-design-vue';
|
||||
import { growCardList } from '../data';
|
||||
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<Card title="快捷导航" v-bind="$attrs">
|
||||
<template v-for="item in navItems" :key="item">
|
||||
<CardGrid @click="goPage">
|
||||
<span class="flex flex-col items-center">
|
||||
<Icon :icon="item.icon" :color="item.color" size="20" />
|
||||
<span class="text-md mt-2">{{ item.title }}</span>
|
||||
</span>
|
||||
</CardGrid>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
const CardGrid = Card.Grid;
|
||||
const $message = useMessage();
|
||||
const navItems = [
|
||||
{
|
||||
title: '业务受理',
|
||||
icon: 'ion:home-outline',
|
||||
color: '#1fdaca',
|
||||
},
|
||||
{
|
||||
title: '业务管理',
|
||||
icon: 'ion:grid-outline',
|
||||
color: '#bf0c2c',
|
||||
},
|
||||
{
|
||||
title: '文件管理',
|
||||
icon: 'ion:layers-outline',
|
||||
color: '#e18525',
|
||||
},
|
||||
{
|
||||
title: '信息查询',
|
||||
icon: 'ion:settings-outline',
|
||||
color: '#3fb27f',
|
||||
},
|
||||
{
|
||||
title: '通知公告',
|
||||
icon: 'ion:notifications',
|
||||
color: '#13b0ff',
|
||||
},
|
||||
{
|
||||
title: '我的任务',
|
||||
icon: 'ion:person-stalker',
|
||||
color: '#b27315',
|
||||
}
|
||||
];
|
||||
|
||||
function goPage() {
|
||||
$message.createMessage.success('根据业务自行处理跳转页面!');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<a-card :loading="loading" :bordered="false" :body-style="{ padding: '0' }">
|
||||
<div class="salesCard">
|
||||
<a-tabs default-active-key="1" size="large" :tab-bar-style="{ marginBottom: '24px', paddingLeft: '16px' }">
|
||||
<template #rightExtra>
|
||||
<div class="extra-wrapper">
|
||||
<div class="extra-item">
|
||||
<a>今日</a>
|
||||
<a>本周</a>
|
||||
<a>本月</a>
|
||||
<a>本年</a>
|
||||
</div>
|
||||
<a-range-picker :style="{ width: '256px' }" />
|
||||
</div>
|
||||
</template>
|
||||
<a-tab-pane loading="true" tab="销售额" key="1">
|
||||
<a-row>
|
||||
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<Bar :chartData="barData" :option="{ title: { text: '', textStyle: { fontWeight: 'lighter' } } }" height="40vh" :seriesColor="seriesColor" />
|
||||
</a-col>
|
||||
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<RankList title="门店销售排行榜" :list="rankList" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="销售趋势" key="2">
|
||||
<a-row>
|
||||
<a-col :xl="16" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<Bar :chartData="barData.reverse()" :option="{ title: { text: '', textStyle: { fontWeight: 'lighter' } } }" height="40vh" :seriesColor="seriesColor" />
|
||||
</a-col>
|
||||
<a-col :xl="8" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<RankList title="门店销售排行榜" :list="rankList" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Bar from '/@/components/chart/Bar.vue';
|
||||
import RankList from '/@/components/chart/RankList.vue';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
|
||||
|
||||
defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
const { getThemeColor } = useRootSetting();
|
||||
const rankList = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
rankList.push({
|
||||
name: '白鹭岛 ' + (i + 1) + ' 号店',
|
||||
total: 1234.56 - i * 100,
|
||||
});
|
||||
}
|
||||
|
||||
const barData = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
barData.push({
|
||||
name: `${i + 1}月`,
|
||||
value: Math.floor(Math.random() * 1000) + 200,
|
||||
});
|
||||
}
|
||||
const seriesColor = computed(() => {
|
||||
return getThemeColor.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.extra-wrapper {
|
||||
line-height: 55px;
|
||||
padding-right: 24px;
|
||||
|
||||
.extra-item {
|
||||
display: inline-block;
|
||||
margin-right: 24px;
|
||||
|
||||
a {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<Card title="成交占比" :loading="loading">
|
||||
<div ref="chartRef" :style="{ width, height }"></div>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
width: {
|
||||
type: String as PropType<string>,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<string>,
|
||||
default: '300px',
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
watch(
|
||||
() => props.loading,
|
||||
() => {
|
||||
if (props.loading) {
|
||||
return;
|
||||
}
|
||||
setOptions({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
|
||||
series: [
|
||||
{
|
||||
name: '访问来源',
|
||||
type: 'pie',
|
||||
radius: '80%',
|
||||
center: ['50%', '50%'],
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||
data: [
|
||||
{ value: 500, name: '电子产品' },
|
||||
{ value: 310, name: '服装' },
|
||||
{ value: 274, name: '化妆品' },
|
||||
{ value: 400, name: '家居' },
|
||||
].sort(function (a, b) {
|
||||
return a.value - b.value;
|
||||
}),
|
||||
roseType: 'radius',
|
||||
animationType: 'scale',
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationDelay: function () {
|
||||
return Math.random() * 400;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<Card :tab-list="tabListTitle" v-bind="$attrs" :active-tab-key="activeKey" @tabChange="onTabChange">
|
||||
<p v-if="activeKey === 'tab1'">
|
||||
<VisitAnalysis />
|
||||
</p>
|
||||
<p v-if="activeKey === 'tab2'">
|
||||
<VisitAnalysisBar />
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import VisitAnalysis from './VisitAnalysis.vue';
|
||||
import VisitAnalysisBar from './VisitAnalysisBar.vue';
|
||||
|
||||
const activeKey = ref('tab1');
|
||||
|
||||
const tabListTitle = [
|
||||
{
|
||||
key: 'tab1',
|
||||
tab: '流量趋势',
|
||||
},
|
||||
{
|
||||
key: 'tab2',
|
||||
tab: '访问量',
|
||||
},
|
||||
];
|
||||
|
||||
function onTabChange(key) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ height, width }"></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, Ref, watchEffect } from 'vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
import { basicProps } from './props';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
defineProps({
|
||||
...basicProps,
|
||||
});
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
|
||||
const { getThemeColor } = useRootSetting();
|
||||
const init = () => {
|
||||
setOptions({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: getThemeColor.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: [
|
||||
'6:00',
|
||||
'7:00',
|
||||
'8:00',
|
||||
'9:00',
|
||||
'10:00',
|
||||
'11:00',
|
||||
'12:00',
|
||||
'13:00',
|
||||
'14:00',
|
||||
'15:00',
|
||||
'16:00',
|
||||
'17:00',
|
||||
'18:00',
|
||||
'19:00',
|
||||
'20:00',
|
||||
'21:00',
|
||||
'22:00',
|
||||
'23:00',
|
||||
],
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
type: 'solid',
|
||||
color: 'rgba(226,226,226,0.5)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
max: 80000,
|
||||
splitNumber: 4,
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
color: ['rgba(255,255,255,0.2)', 'rgba(226,226,226,0.2)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
grid: { left: '1%', right: '1%', top: '2 %', bottom: 0, containLabel: true },
|
||||
series: [
|
||||
{
|
||||
smooth: true,
|
||||
data: [111, 222, 4000, 18000, 33333, 55555, 66666, 33333, 14000, 36000, 66666, 44444, 22222, 11111, 4000, 2000, 500, 333, 222, 111],
|
||||
type: 'line',
|
||||
areaStyle: {},
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
},
|
||||
{
|
||||
smooth: true,
|
||||
data: [33, 66, 88, 333, 3333, 5000, 18000, 3000, 1200, 13000, 22000, 11000, 2221, 1201, 390, 198, 60, 30, 22, 11],
|
||||
type: 'line',
|
||||
areaStyle: {},
|
||||
itemStyle: {
|
||||
color: getThemeColor.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
watchEffect(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ height, width }"></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, Ref, watchEffect } from 'vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
import { basicProps } from './props';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
|
||||
defineProps({
|
||||
...basicProps,
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
const { getThemeColor } = useRootSetting();
|
||||
const init = () => {
|
||||
setOptions({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: getThemeColor.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: { left: '1%', right: '1%', top: '2 %', bottom: 0, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 8000,
|
||||
splitNumber: 4,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [3000, 2000, 3333, 5000, 3200, 4200, 3200, 2100, 3000, 5100, 6000, 3200, 4800],
|
||||
type: 'bar',
|
||||
barMaxWidth: 80,
|
||||
color: getThemeColor.value,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
watchEffect(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<Card title="转化率" :loading="loading">
|
||||
<div ref="chartRef" :style="{ width, height }"></div>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
width: {
|
||||
type: String as PropType<string>,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<string>,
|
||||
default: '300px',
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
watch(
|
||||
() => props.loading,
|
||||
() => {
|
||||
if (props.loading) {
|
||||
return;
|
||||
}
|
||||
setOptions({
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ['访问', '购买'],
|
||||
},
|
||||
tooltip: {},
|
||||
radar: {
|
||||
radius: '60%',
|
||||
splitNumber: 8,
|
||||
indicator: [
|
||||
{
|
||||
name: '电脑',
|
||||
},
|
||||
{
|
||||
name: '充电器',
|
||||
},
|
||||
{
|
||||
name: '耳机',
|
||||
},
|
||||
{
|
||||
name: '手机',
|
||||
},
|
||||
{
|
||||
name: 'Ipad',
|
||||
},
|
||||
{
|
||||
name: '耳机',
|
||||
},
|
||||
],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar' as 'custom',
|
||||
symbolSize: 0,
|
||||
areaStyle: {
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
opacity: 1,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: [90, 50, 86, 40, 50, 20],
|
||||
name: '访问',
|
||||
itemStyle: {
|
||||
color: '#b6a2de',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: [70, 75, 70, 76, 20, 85],
|
||||
name: '购买',
|
||||
itemStyle: {
|
||||
color: '#5ab1ef',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<Card title="访问来源" :loading="loading">
|
||||
<div ref="chartRef" :style="{ width, height }"></div>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
width: {
|
||||
type: String as PropType<string>,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<string>,
|
||||
default: '300px',
|
||||
},
|
||||
});
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
watch(
|
||||
() => props.loading,
|
||||
() => {
|
||||
if (props.loading) {
|
||||
return;
|
||||
}
|
||||
setOptions({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
bottom: '1%',
|
||||
left: 'center',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||
name: '访问来源',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center',
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '12',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{ value: 1048, name: '搜索引擎' },
|
||||
{ value: 735, name: '直接访问' },
|
||||
{ value: 580, name: '邮件营销' },
|
||||
{ value: 484, name: '联盟广告' },
|
||||
],
|
||||
animationType: 'scale',
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationDelay: function () {
|
||||
return Math.random() * 100;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PropType } from 'vue';
|
||||
|
||||
export interface BasicProps {
|
||||
width: string;
|
||||
height: string;
|
||||
}
|
||||
export const basicProps = {
|
||||
width: {
|
||||
type: String as PropType<string>,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<string>,
|
||||
default: '280px',
|
||||
},
|
||||
};
|
||||
219
jeecgboot-vue3/src/views/dashboard/Analysis/data.ts
Normal file
219
jeecgboot-vue3/src/views/dashboard/Analysis/data.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
export interface GrowCardItem {
|
||||
icon: string;
|
||||
title: string;
|
||||
value?: number;
|
||||
total: number;
|
||||
color?: string;
|
||||
action?: string;
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
export const growCardList: GrowCardItem[] = [
|
||||
{
|
||||
title: '访问数',
|
||||
icon: 'visit-count|svg',
|
||||
value: 2000,
|
||||
total: 120000,
|
||||
color: 'green',
|
||||
action: '月',
|
||||
},
|
||||
{
|
||||
title: '成交额',
|
||||
icon: 'total-sales|svg',
|
||||
value: 20000,
|
||||
total: 500000,
|
||||
color: 'blue',
|
||||
action: '月',
|
||||
},
|
||||
{
|
||||
title: '下载数',
|
||||
icon: 'download-count|svg',
|
||||
value: 8000,
|
||||
total: 120000,
|
||||
color: 'orange',
|
||||
action: '周',
|
||||
},
|
||||
{
|
||||
title: '成交数',
|
||||
icon: 'transaction|svg',
|
||||
value: 5000,
|
||||
total: 50000,
|
||||
color: 'purple',
|
||||
action: '年',
|
||||
},
|
||||
];
|
||||
|
||||
export const chartCardList: GrowCardItem[] = [
|
||||
{
|
||||
title: '总销售额',
|
||||
icon: 'visit-count|svg',
|
||||
total: 126560,
|
||||
value: 234.56,
|
||||
footer: '日均销售额',
|
||||
},
|
||||
{
|
||||
title: '订单量',
|
||||
icon: 'total-sales|svg',
|
||||
value: 1234,
|
||||
total: 8846,
|
||||
color: 'blue',
|
||||
footer: '日订单量',
|
||||
},
|
||||
{
|
||||
title: '支付笔数',
|
||||
icon: 'download-count|svg',
|
||||
value: 60,
|
||||
total: 6560,
|
||||
color: 'orange',
|
||||
footer: '转化率',
|
||||
},
|
||||
{
|
||||
title: '运营活动效果',
|
||||
icon: 'transaction|svg',
|
||||
total: 78,
|
||||
},
|
||||
];
|
||||
export const bdcCardList: GrowCardItem[] = [
|
||||
{
|
||||
title: '受理量',
|
||||
icon: 'ant-design:info-circle-outlined',
|
||||
total: 100,
|
||||
value: 60,
|
||||
footer: '今日受理量',
|
||||
},
|
||||
{
|
||||
title: '办结量',
|
||||
icon: 'ant-design:info-circle-outlined',
|
||||
value: 54,
|
||||
total: 87,
|
||||
color: 'blue',
|
||||
footer: '今日办结量',
|
||||
},
|
||||
{
|
||||
title: '用户受理量',
|
||||
icon: 'ant-design:info-circle-outlined',
|
||||
value: 13,
|
||||
total: 15,
|
||||
color: 'orange',
|
||||
footer: '用户今日受理量',
|
||||
},
|
||||
{
|
||||
title: '用户办结量',
|
||||
icon: 'ant-design:info-circle-outlined',
|
||||
total: 9,
|
||||
value: 7,
|
||||
footer: '用户今日办结量',
|
||||
},
|
||||
];
|
||||
|
||||
export const table = {
|
||||
dataSource: [
|
||||
{ reBizCode: '1', type: '转移登记', acceptBy: '张三', acceptDate: '2019-01-22', curNode: '任务分派', flowRate: 60 },
|
||||
{ reBizCode: '2', type: '抵押登记', acceptBy: '李四', acceptDate: '2019-01-23', curNode: '领导审核', flowRate: 30 },
|
||||
{ reBizCode: '3', type: '转移登记', acceptBy: '王武', acceptDate: '2019-01-25', curNode: '任务处理', flowRate: 20 },
|
||||
{ reBizCode: '4', type: '转移登记', acceptBy: '赵楼', acceptDate: '2019-11-22', curNode: '部门审核', flowRate: 80 },
|
||||
{ reBizCode: '5', type: '转移登记', acceptBy: '钱就', acceptDate: '2019-12-12', curNode: '任务分派', flowRate: 90 },
|
||||
{ reBizCode: '6', type: '转移登记', acceptBy: '孙吧', acceptDate: '2019-03-06', curNode: '任务处理', flowRate: 10 },
|
||||
{ reBizCode: '7', type: '抵押登记', acceptBy: '周大', acceptDate: '2019-04-13', curNode: '任务分派', flowRate: 100 },
|
||||
{ reBizCode: '8', type: '抵押登记', acceptBy: '吴二', acceptDate: '2019-05-09', curNode: '任务上报', flowRate: 50 },
|
||||
{ reBizCode: '9', type: '抵押登记', acceptBy: '郑爽', acceptDate: '2019-07-12', curNode: '任务处理', flowRate: 63 },
|
||||
{ reBizCode: '20', type: '抵押登记', acceptBy: '林有', acceptDate: '2019-12-12', curNode: '任务打回', flowRate: 59 },
|
||||
{ reBizCode: '11', type: '转移登记', acceptBy: '码云', acceptDate: '2019-09-10', curNode: '任务签收', flowRate: 87 },
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
title: '业务号',
|
||||
align: 'center',
|
||||
dataIndex: 'reBizCode',
|
||||
},
|
||||
{
|
||||
title: '业务类型',
|
||||
align: 'center',
|
||||
dataIndex: 'type',
|
||||
},
|
||||
{
|
||||
title: '受理人',
|
||||
align: 'center',
|
||||
dataIndex: 'acceptBy',
|
||||
},
|
||||
{
|
||||
title: '受理时间',
|
||||
align: 'center',
|
||||
dataIndex: 'acceptDate',
|
||||
},
|
||||
{
|
||||
title: '当前节点',
|
||||
align: 'center',
|
||||
dataIndex: 'curNode',
|
||||
},
|
||||
{
|
||||
title: '办理时长',
|
||||
align: 'center',
|
||||
dataIndex: 'flowRate',
|
||||
},
|
||||
],
|
||||
ipagination: {
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
pageSizeOptions: ['10', '20', '30'],
|
||||
showTotal: (total, range) => {
|
||||
return range[0] + '-' + range[1] + ' 共' + total + '条';
|
||||
},
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
export const table1 = {
|
||||
dataSource: [
|
||||
{ reBizCode: 'A001', type: '转移登记', acceptBy: '张四', acceptDate: '2019-01-22', curNode: '任务分派', flowRate: 12 },
|
||||
{ reBizCode: 'A002', type: '抵押登记', acceptBy: '李吧', acceptDate: '2019-01-23', curNode: '任务签收', flowRate: 3 },
|
||||
{ reBizCode: 'A003', type: '转移登记', acceptBy: '王三', acceptDate: '2019-01-25', curNode: '任务处理', flowRate: 24 },
|
||||
{ reBizCode: 'A004', type: '转移登记', acceptBy: '赵二', acceptDate: '2019-11-22', curNode: '部门审核', flowRate: 10 },
|
||||
{ reBizCode: 'A005', type: '转移登记', acceptBy: '钱大', acceptDate: '2019-12-12', curNode: '任务签收', flowRate: 8 },
|
||||
{ reBizCode: 'A006', type: '转移登记', acceptBy: '孙就', acceptDate: '2019-03-06', curNode: '任务处理', flowRate: 10 },
|
||||
{ reBizCode: 'A007', type: '抵押登记', acceptBy: '周晕', acceptDate: '2019-04-13', curNode: '部门审核', flowRate: 24 },
|
||||
{ reBizCode: 'A008', type: '抵押登记', acceptBy: '吴有', acceptDate: '2019-05-09', curNode: '部门审核', flowRate: 30 },
|
||||
{ reBizCode: 'A009', type: '抵押登记', acceptBy: '郑武', acceptDate: '2019-07-12', curNode: '任务分派', flowRate: 1 },
|
||||
{ reBizCode: 'A0010', type: '抵押登记', acceptBy: '林爽', acceptDate: '2019-12-12', curNode: '部门审核', flowRate: 16 },
|
||||
{ reBizCode: 'A0011', type: '转移登记', acceptBy: '码楼', acceptDate: '2019-09-10', curNode: '部门审核', flowRate: 7 },
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
title: '业务号',
|
||||
align: 'center',
|
||||
dataIndex: 'reBizCode',
|
||||
},
|
||||
{
|
||||
title: '受理人',
|
||||
align: 'center',
|
||||
dataIndex: 'acceptBy',
|
||||
},
|
||||
{
|
||||
title: '发起时间',
|
||||
align: 'center',
|
||||
dataIndex: 'acceptDate',
|
||||
},
|
||||
{
|
||||
title: '当前节点',
|
||||
align: 'center',
|
||||
dataIndex: 'curNode',
|
||||
},
|
||||
{
|
||||
title: '超时时间',
|
||||
align: 'center',
|
||||
dataIndex: 'flowRate',
|
||||
},
|
||||
],
|
||||
ipagination: {
|
||||
current: 1,
|
||||
pageSize: 5,
|
||||
pageSizeOptions: ['10', '20', '30'],
|
||||
showTotal: (total, range) => {
|
||||
return range[0] + '-' + range[1] + ' 共' + total + '条';
|
||||
},
|
||||
showQuickJumper: true,
|
||||
showSizeChanger: true,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<ChartGroupCard class="enter-y" :loading="loading" type="bdc" />
|
||||
<BdcTabCard class="!my-4 enter-y" :loading="loading" />
|
||||
<a-row>
|
||||
<a-col :span="24">
|
||||
<a-card :loading="loading" :class="{ 'anty-list-cust': true }" :bordered="false">
|
||||
<a-tabs v-model:activeKey="indexBottomTab" size="large" :tab-bar-style="{ marginBottom: '24px', paddingLeft: '16px' }">
|
||||
<template #rightExtra>
|
||||
<div class="extra-wrapper">
|
||||
<a-radio-group v-model:value="indexRegisterType" @change="changeRegisterType">
|
||||
<a-radio-button value="转移登记">转移登记</a-radio-button>
|
||||
<a-radio-button value="抵押登记">抵押登记</a-radio-button>
|
||||
<a-radio-button value="">所有</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-tab-pane tab="业务流程限时监管" key="1">
|
||||
<a-table
|
||||
:dataSource="dataSource"
|
||||
size="default"
|
||||
rowKey="reBizCode"
|
||||
:columns="table.columns"
|
||||
:pagination="ipagination"
|
||||
@change="tableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'flowRate'">
|
||||
<Progress
|
||||
:strokeColor="getPercentColor(record.flowRate)"
|
||||
:format="getPercentFormat"
|
||||
:percent="getFlowRateNumber(record.flowRate)"
|
||||
style="width: 80px"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane loading="true" tab="业务节点限时监管" key="2">
|
||||
<a-table
|
||||
:dataSource="dataSource1"
|
||||
size="default"
|
||||
rowKey="reBizCode"
|
||||
:columns="table1.columns"
|
||||
:pagination="ipagination1"
|
||||
@change="tableChange1"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'flowRate'">
|
||||
<span style="color: red">{{ record.flowRate }}小时</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, unref } from 'vue';
|
||||
import { Progress } from 'ant-design-vue';
|
||||
import ChartGroupCard from '../components/ChartGroupCard.vue';
|
||||
import BdcTabCard from '../components/BdcTabCard.vue';
|
||||
import LineMulti from '/@/components/chart/LineMulti.vue';
|
||||
import HeadInfo from '/@/components/chart/HeadInfo.vue';
|
||||
import { table, table1 } from '../data';
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
const indexBottomTab = ref('1');
|
||||
const indexRegisterType = ref('转移登记');
|
||||
const dataSource = ref([]);
|
||||
const dataSource1 = ref([]);
|
||||
const ipagination = ref(table.ipagination);
|
||||
const ipagination1 = ref(table1.ipagination);
|
||||
|
||||
function changeRegisterType(e) {
|
||||
indexRegisterType.value = e.target.value;
|
||||
if (unref(indexBottomTab) == '1') {
|
||||
loadDataSource();
|
||||
} else {
|
||||
loadDataSource1();
|
||||
}
|
||||
}
|
||||
|
||||
function tableChange(pagination) {
|
||||
ipagination.value.current = pagination.current;
|
||||
ipagination.value.pageSize = pagination.pageSize;
|
||||
loadDataSource();
|
||||
}
|
||||
|
||||
function tableChange1(pagination) {
|
||||
ipagination1.value.current = pagination.current;
|
||||
ipagination1.value.pageSize = pagination.pageSize;
|
||||
loadDataSource1();
|
||||
}
|
||||
|
||||
function getFlowRateNumber(value) {
|
||||
return +value;
|
||||
}
|
||||
|
||||
function getPercentFormat(value) {
|
||||
if (value == 100) {
|
||||
return '超时';
|
||||
} else {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function getPercentColor(value) {
|
||||
let p = +value;
|
||||
if (p >= 90 && p < 100) {
|
||||
return 'rgb(244, 240, 89)';
|
||||
} else if (p >= 100) {
|
||||
return 'red';
|
||||
} else {
|
||||
return 'rgb(16, 142, 233)';
|
||||
}
|
||||
}
|
||||
|
||||
function loadDataSource() {
|
||||
dataSource.value = table.dataSource.filter((item) => {
|
||||
if (!unref(indexRegisterType)) {
|
||||
return true;
|
||||
}
|
||||
return item.type == unref(indexRegisterType);
|
||||
});
|
||||
}
|
||||
|
||||
function loadDataSource1() {
|
||||
dataSource1.value = table1.dataSource.filter((item) => {
|
||||
if (!unref(indexRegisterType)) {
|
||||
return true;
|
||||
}
|
||||
return item.type == unref(indexRegisterType);
|
||||
});
|
||||
}
|
||||
|
||||
loadDataSource();
|
||||
loadDataSource1();
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ant-table-wrapper {
|
||||
:deep(.ant-table){
|
||||
td,th {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.extra-wrapper {
|
||||
line-height: 55px;
|
||||
padding-right: 24px;
|
||||
|
||||
.extra-item {
|
||||
display: inline-block;
|
||||
margin-right: 24px;
|
||||
|
||||
a {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-group {
|
||||
padding: 20px 0 8px 24px;
|
||||
font-size: 0;
|
||||
|
||||
a {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
margin-bottom: 13px;
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.item-group {
|
||||
.more-btn {
|
||||
margin-bottom: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content-item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1600px) {
|
||||
.list-content-item {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1300px) {
|
||||
.list-content-item {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.width-hidden4 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content-item {
|
||||
span {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content-item {
|
||||
p {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.anty-list-cust {
|
||||
.ant-list-item-meta {
|
||||
flex: 0.3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.anty-list-cust {
|
||||
.ant-list-item-content {
|
||||
flex: 1 !important;
|
||||
justify-content: flex-start !important;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<ChartGroupCard class="enter-y" :loading="loading" type="chart" />
|
||||
<SaleTabCard class="!my-4 enter-y" :loading="loading" />
|
||||
<a-row>
|
||||
<a-col :span="24">
|
||||
<a-card :loading="loading" :bordered="false" title="最近一周访问量统计">
|
||||
<div class="infoArea">
|
||||
<HeadInfo title="今日IP" :iconColor="ipColor" :content="loginfo.todayIp" icon="environment"></HeadInfo>
|
||||
<HeadInfo title="今日访问" :iconColor="visitColor" :content="loginfo.todayVisitCount" icon="team"></HeadInfo>
|
||||
<HeadInfo title="总访问量" :iconColor="seriesColor" :content="loginfo.totalVisitCount" icon="rise"></HeadInfo>
|
||||
</div>
|
||||
<LineMulti :chartData="lineMultiData" height="33vh" type="line" :option="{ legend: { top: 'bottom' } }"></LineMulti>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import ChartGroupCard from '../components/ChartGroupCard.vue';
|
||||
import SaleTabCard from '../components/SaleTabCard.vue';
|
||||
import LineMulti from '/@/components/chart/LineMulti.vue';
|
||||
import HeadInfo from '/@/components/chart/HeadInfo.vue';
|
||||
import { getLoginfo, getVisitInfo } from '../api.ts';
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting';
|
||||
|
||||
const loading = ref(true);
|
||||
const { getThemeColor } = useRootSetting();
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
const loginfo = ref({});
|
||||
const lineMultiData = ref([]);
|
||||
|
||||
function initLogInfo() {
|
||||
getLoginfo(null).then((res) => {
|
||||
if (res.success) {
|
||||
Object.keys(res.result).forEach((key) => {
|
||||
res.result[key] = res.result[key] + '';
|
||||
});
|
||||
loginfo.value = res.result;
|
||||
}
|
||||
});
|
||||
getVisitInfo(null).then((res) => {
|
||||
if (res.success) {
|
||||
lineMultiData.value = [];
|
||||
res.result.forEach((item) => {
|
||||
lineMultiData.value.push({ name: item.type, type: 'ip', value: item.ip, color: ipColor.value });
|
||||
lineMultiData.value.push({ name: item.type, type: 'visit', value: item.visit, color: visitColor.value });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const ipColor = ref();
|
||||
const visitColor = ref();
|
||||
const seriesColor = ref();
|
||||
watch(
|
||||
() => getThemeColor.value,
|
||||
() => {
|
||||
seriesColor.value = getThemeColor.value;
|
||||
visitColor.value = '#67B962';
|
||||
ipColor.value = getThemeColor.value;
|
||||
initLogInfo();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function getRandomColor() {
|
||||
var letters = '0123456789ABCDEF';
|
||||
var color = '#';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.infoArea {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 10%;
|
||||
.head-info.center {
|
||||
padding: 0;
|
||||
}
|
||||
.head-info {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
.circle-cust {
|
||||
position: relative;
|
||||
top: 28px;
|
||||
left: -100%;
|
||||
}
|
||||
|
||||
.extra-wrapper {
|
||||
line-height: 55px;
|
||||
padding-right: 24px;
|
||||
|
||||
.extra-item {
|
||||
display: inline-block;
|
||||
margin-right: 24px;
|
||||
|
||||
a {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页访问量统计 */
|
||||
.head-info {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 0 32px 0 0;
|
||||
min-width: 125px;
|
||||
|
||||
&.center {
|
||||
text-align: center;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
display: inline-block;
|
||||
font-size: 0.95rem;
|
||||
line-height: 42px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 42px;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-card {
|
||||
::v-deep(.ant-card-head-title) {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<GrowCard :loading="loading" class="enter-y" />
|
||||
<SiteAnalysis class="!my-4 enter-y" :loading="loading" />
|
||||
<div class="md:flex enter-y">
|
||||
<VisitRadar class="md:w-1/3 w-full" :loading="loading" />
|
||||
<VisitSource class="md:w-1/3 !md:mx-4 !md:my-0 !my-4 w-full" :loading="loading" />
|
||||
<SalesProductPie class="md:w-1/3 w-full" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import GrowCard from '../components/GrowCard.vue';
|
||||
import SiteAnalysis from '../components/SiteAnalysis.vue';
|
||||
import VisitSource from '../components/VisitSource.vue';
|
||||
import VisitRadar from '../components/VisitRadar.vue';
|
||||
import SalesProductPie from '../components/SalesProductPie.vue';
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
</script>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="index-container-ty">
|
||||
<a-row type="flex" justify="start" :gutter="3">
|
||||
<a-col :sm="24" :lg="12">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="index-md-title">
|
||||
<img src="../../../../assets/images/daiban.png" />
|
||||
我的待办【{{ dataSource1.length }}】
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="dataSource1 && dataSource1.length > 0" #extra>
|
||||
<a @click="goPage"
|
||||
>更多
|
||||
<Icon icon="ant-design:double-right-outlined" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:class="'my-index-table tytable1'"
|
||||
ref="table1"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource1"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #ellipsisText="{ text }">
|
||||
<JEllipsis :value="text" :length="textMaxLength"></JEllipsis>
|
||||
</template>
|
||||
|
||||
<template #dayWarnning="{ text, record }">
|
||||
<BellTwoTone style="font-size: 22px" :twoToneColor="getTipColor(record)" />
|
||||
</template>
|
||||
|
||||
<template #action="{ text, record }">
|
||||
<a @click="handleData">办理</a>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :sm="24" :lg="12">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="index-md-title">
|
||||
<img src="../../../../assets/images/zaiban.png" />
|
||||
我的在办【{{ dataSource2.length }}】
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="dataSource2 && dataSource2.length > 0" #extra>
|
||||
<a @click="goPage"
|
||||
>更多
|
||||
<Icon icon="ant-design:double-right-outlined" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:class="'my-index-table tytable2'"
|
||||
ref="table1"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource2"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #ellipsisText="{ text }">
|
||||
<JEllipsis :value="text" :length="textMaxLength"></JEllipsis>
|
||||
</template>
|
||||
|
||||
<template #dayWarnning="{ text, record }">
|
||||
<BellTwoTone style="font-size: 22px" :twoToneColor="getTipColor(record)" />
|
||||
</template>
|
||||
|
||||
<template #action="{ text, record }">
|
||||
<a @click="handleData">办理</a>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<div style="height: 5px"></div>
|
||||
</a-col>
|
||||
|
||||
<a-col :sm="24" :lg="12">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="index-md-title">
|
||||
<img src="../../../../assets/images/guaz.png" />
|
||||
我的挂账【{{ dataSource4.length }}】
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:class="'my-index-table tytable4'"
|
||||
ref="table1"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource4"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #ellipsisText="{ text }">
|
||||
<JEllipsis :value="text" :length="textMaxLength"></JEllipsis>
|
||||
</template>
|
||||
|
||||
<template #dayWarnning="{ text, record }">
|
||||
<BellTwoTone style="font-size: 22px" :twoToneColor="getTipColor(record)" />
|
||||
</template>
|
||||
|
||||
<template #action="{ text, record }">
|
||||
<a @click="handleData">办理</a>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<a-col :sm="24" :lg="12">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<div class="index-md-title">
|
||||
<img src="../../../../assets/images/duban.png" />
|
||||
我的督办【{{ dataSource3.length }}】
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:class="'my-index-table tytable3'"
|
||||
ref="table1"
|
||||
size="small"
|
||||
rowKey="id"
|
||||
:columns="columns"
|
||||
:dataSource="dataSource3"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #ellipsisText="{ text }">
|
||||
<JEllipsis :value="text" :length="textMaxLength"></JEllipsis>
|
||||
</template>
|
||||
|
||||
<template #dayWarnning="{ text, record }">
|
||||
<BellTwoTone style="font-size: 22px" :twoToneColor="getTipColor(record)" />
|
||||
</template>
|
||||
|
||||
<template #action="{ text, record }">
|
||||
<a @click="handleData">办理</a>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import noDataPng from '/@/assets/images/nodata.png';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import JEllipsis from '/@/components/Form/src/jeecg/components/JEllipsis.vue';
|
||||
import { BulbTwoTone, BellTwoTone } from '@ant-design/icons-vue';
|
||||
|
||||
const tempSs1 = [
|
||||
{
|
||||
id: '001',
|
||||
orderNo: '电[1]1267102',
|
||||
orderTitle: '药品出问题了',
|
||||
restDay: 1,
|
||||
},
|
||||
{
|
||||
id: '002',
|
||||
orderNo: '电[4]5967102',
|
||||
orderTitle: '吃了xxx医院的药,病情越来越严重',
|
||||
restDay: 0,
|
||||
},
|
||||
{
|
||||
id: '003',
|
||||
orderNo: '电[3]5988987',
|
||||
orderTitle: '今天去超市买鸡蛋,鸡蛋都是坏的',
|
||||
restDay: 7,
|
||||
},
|
||||
{
|
||||
id: '004',
|
||||
orderNo: '电[2]5213491',
|
||||
orderTitle: 'xx宝实体店高价售卖xx',
|
||||
restDay: 5,
|
||||
},
|
||||
{
|
||||
id: '005',
|
||||
orderNo: '电[1]1603491',
|
||||
orderTitle: '以红利相诱,答应退保后扣一年费用',
|
||||
restDay: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const tempSs2 = [
|
||||
{
|
||||
id: '001',
|
||||
orderTitle: '我要投诉这个大超市',
|
||||
orderNo: '电[1]10299456',
|
||||
restDay: 6,
|
||||
},
|
||||
{
|
||||
id: '002',
|
||||
orderTitle: 'xxx医院乱开药方,售卖假药',
|
||||
orderNo: '电[2]20235691',
|
||||
restDay: 0,
|
||||
},
|
||||
{
|
||||
id: '003',
|
||||
orderTitle: '我想问问这家店是干啥的',
|
||||
orderNo: '电[3]495867322',
|
||||
restDay: 7,
|
||||
},
|
||||
{
|
||||
id: '004',
|
||||
orderTitle: '我要举报朝阳区奥森公园酒店',
|
||||
orderNo: '电[2]1193849',
|
||||
restDay: 3,
|
||||
},
|
||||
{
|
||||
id: '005',
|
||||
orderTitle: '我今天吃饭吃到一个石头子',
|
||||
orderNo: '电[4]56782344',
|
||||
restDay: 9,
|
||||
},
|
||||
];
|
||||
|
||||
//4-7天
|
||||
const tip_green = 'rgba(0, 255, 0, 1)';
|
||||
//1-3天
|
||||
const tip_yellow = 'rgba(255, 255, 0, 1)';
|
||||
//超期
|
||||
const tip_red = 'rgba(255, 0, 0, 1)';
|
||||
|
||||
const textMaxLength = 8;
|
||||
const $message = useMessage();
|
||||
|
||||
const dataSource1 = ref([]);
|
||||
const dataSource2 = ref([]);
|
||||
const dataSource3 = ref([]);
|
||||
const dataSource4 = ref([]);
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'rowIndex',
|
||||
width: 50,
|
||||
fixed: 'left',
|
||||
align: 'center',
|
||||
slots: { customRender: 'dayWarnning' },
|
||||
},
|
||||
{
|
||||
title: '剩余天数',
|
||||
align: 'center',
|
||||
dataIndex: 'restDay',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '工单标题',
|
||||
align: 'center',
|
||||
dataIndex: 'orderTitle',
|
||||
slots: { customRender: 'ellipsisText' },
|
||||
},
|
||||
{
|
||||
title: '工单编号',
|
||||
align: 'center',
|
||||
dataIndex: 'orderNo',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
align: 'center',
|
||||
slots: { customRender: 'action' },
|
||||
},
|
||||
];
|
||||
|
||||
function getTipColor(rd) {
|
||||
let num = rd.restDay;
|
||||
if (num <= 0) {
|
||||
return tip_red;
|
||||
} else if (num >= 1 && num < 4) {
|
||||
return tip_yellow;
|
||||
} else if (num >= 4) {
|
||||
return tip_green;
|
||||
}
|
||||
}
|
||||
|
||||
function mock() {
|
||||
dataSource1.value = tempSs1;
|
||||
dataSource2.value = tempSs2;
|
||||
dataSource3.value = tempSs1;
|
||||
dataSource4.value = tempSs2;
|
||||
ifNullDataSource(dataSource4, '.tytable4');
|
||||
}
|
||||
|
||||
function ifNullDataSource(ds, tb) {
|
||||
if (!ds || ds.length == 0) {
|
||||
var tmp = document.createElement('img');
|
||||
tmp.src = noDataPng;
|
||||
tmp.width = 300;
|
||||
let tbclass = `${tb} .ant-table-placeholder`;
|
||||
document.querySelector(tbclass).innerHTML = '';
|
||||
document.querySelector(tbclass).appendChild(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
function handleData() {
|
||||
$message.createMessage.success('办理完成');
|
||||
}
|
||||
|
||||
function goPage() {
|
||||
$message.createMessage.success('请根据具体业务跳转页面');
|
||||
}
|
||||
|
||||
mock();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.my-index-table {
|
||||
height: 270px;
|
||||
|
||||
table {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.index-container-ty {
|
||||
margin: 12px 12px 0;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 10px 12px 0 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
line-height: 24px;
|
||||
min-height: 24px;
|
||||
background: #7196fb !important;
|
||||
|
||||
.ant-card-head-title {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.ant-card-extra {
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #152ede;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table-footer) {
|
||||
text-align: right;
|
||||
padding: 6px 12px 6px 6px;
|
||||
background: #fff;
|
||||
border-top: 2px solid #f7f1f1;
|
||||
}
|
||||
|
||||
.index-md-title {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
font-size: 21px;
|
||||
font-family: cursive;
|
||||
padding-left: 25px;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
height: 25px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #90aeff;
|
||||
}
|
||||
|
||||
:deep(
|
||||
.ant-table-small
|
||||
> .ant-table-content
|
||||
> .ant-table-fixed-left
|
||||
> .ant-table-body-outer
|
||||
> .ant-table-body-inner
|
||||
> table
|
||||
> .ant-table-thead
|
||||
> tr
|
||||
> th),
|
||||
:deep(
|
||||
.ant-table-small
|
||||
> .ant-table-content
|
||||
> .ant-table-fixed-right
|
||||
> .ant-table-body-outer
|
||||
> .ant-table-body-inner
|
||||
> table
|
||||
> .ant-table-thead
|
||||
> tr
|
||||
> th) {
|
||||
border-bottom: 1px solid #90aeff;
|
||||
}
|
||||
|
||||
:deep(.ant-table-small > .ant-table-content > .ant-table-scroll > .ant-table-body > table > .ant-table-thead > tr > th) {
|
||||
border-bottom: 1px solid #90aeff;
|
||||
}
|
||||
|
||||
:deep(.ant-table-small) {
|
||||
border: 1px solid #90aeff;
|
||||
}
|
||||
|
||||
:deep(.ant-table-placeholder) {
|
||||
padding: 0;
|
||||
height: 215px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
jeecgboot-vue3/src/views/dashboard/Analysis/index.vue
Normal file
24
jeecgboot-vue3/src/views/dashboard/Analysis/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<IndexChart v-if="indexStyle === 0"></IndexChart>
|
||||
<IndexDef v-if="indexStyle === 1"></IndexDef>
|
||||
<IndexBdc v-if="indexStyle == 2"></IndexBdc>
|
||||
<IndexTask v-if="indexStyle == 3"></IndexTask>
|
||||
<div style="width: 100%; text-align: right; margin-top: 20px">
|
||||
首页主题:
|
||||
<a-radio-group v-model:value="indexStyle">
|
||||
<a-radio :value="0">默认</a-radio>
|
||||
<a-radio :value="1">销量统计</a-radio>
|
||||
<a-radio :value="2">业务统计</a-radio>
|
||||
<a-radio :value="3">我的任务</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import IndexDef from './homePage/IndexDef.vue';
|
||||
import IndexChart from './homePage/IndexChart.vue';
|
||||
import IndexBdc from './homePage/IndexBdc.vue';
|
||||
import IndexTask from './homePage/IndexTask.vue';
|
||||
|
||||
const indexStyle = ref(0);
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div v-if="visible" ref="aideWrapRef" class="aide-wrap" @click="handleGo">
|
||||
<a-popconfirm
|
||||
:open="popconfirmVisible"
|
||||
placement="topRight"
|
||||
title="确定AI助手退出吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@cancel="handleCancel"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<img :src="aiImage" alt="ai助手" />
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { router } from '/@/router';
|
||||
import { AIDE_FLAG } from '/@/enums/cacheEnum';
|
||||
import { getToken } from '/@/utils/auth';
|
||||
import { getAuthCache, setAuthCache, removeAuthCache } from '/@/utils/auth';
|
||||
import aiImage from './images/ai.png';
|
||||
const visible = ref(1);
|
||||
const aideWrapRef = ref(null);
|
||||
const popconfirmVisible = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
const aideFlag = getAuthCache(AIDE_FLAG);
|
||||
if (aideFlag && aideFlag == getToken()) {
|
||||
visible.value = false;
|
||||
} else {
|
||||
visible.value = true;
|
||||
}
|
||||
if (visible.value) {
|
||||
aideWrapRef.value.addEventListener('contextmenu', (e) => {
|
||||
popconfirmVisible.value = true;
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
});
|
||||
const handleCancel = () => {
|
||||
popconfirmVisible.value = false;
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
popconfirmVisible.value = false;
|
||||
visible.value = false;
|
||||
setAuthCache(AIDE_FLAG, getToken());
|
||||
};
|
||||
const handleGo = (params) => {
|
||||
router.push({ path: '/ai' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.aide-wrap {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 5px;
|
||||
transform: translate(0, -50%);
|
||||
height: 33px;
|
||||
width: 33px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
.text {
|
||||
font-size: 12px;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
jeecgboot-vue3/src/views/dashboard/ai/index.vue
Normal file
24
jeecgboot-vue3/src/views/dashboard/ai/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<div class="content">
|
||||
<AiChat></AiChat>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AiChat from '/@/views/super/airag/aiapp/chat/AiChat.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
.content {
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Card title="最新动态" v-bind="$attrs">
|
||||
<template #extra>
|
||||
<a-button type="link" size="small">更多</a-button>
|
||||
</template>
|
||||
<List item-layout="horizontal" :data-source="dynamicInfoItems">
|
||||
<template #renderItem="{ item }">
|
||||
<ListItem>
|
||||
<ListItemMeta>
|
||||
<template #description>
|
||||
{{ item.date }}
|
||||
</template>
|
||||
<!-- eslint-disable-next-line -->
|
||||
<template #title> {{ item.name }} <span v-html="item.desc"> </span> </template>
|
||||
<template #avatar>
|
||||
<Icon :icon="item.avatar" :size="30" />
|
||||
</template>
|
||||
</ListItemMeta>
|
||||
</ListItem>
|
||||
</template>
|
||||
</List>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Card, List } from 'ant-design-vue';
|
||||
import { dynamicInfoItems } from './data';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
const ListItem = List.Item;
|
||||
const ListItemMeta = List.Item.Meta;
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Card title="项目" v-bind="$attrs">
|
||||
<template #extra>
|
||||
<a-button type="link" size="small">更多</a-button>
|
||||
</template>
|
||||
|
||||
<template v-for="item in items" :key="item">
|
||||
<CardGrid class="!md:w-1/3 !w-full">
|
||||
<span class="flex">
|
||||
<Icon :icon="item.icon" :color="item.color" size="30" />
|
||||
<span class="text-lg ml-4">{{ item.title }}</span>
|
||||
</span>
|
||||
<div class="flex mt-2 h-10 text-secondary"> {{ item.desc }} </div>
|
||||
<div class="flex justify-between text-secondary">
|
||||
<span>{{ item.group }}</span>
|
||||
<span>{{ item.date }}</span>
|
||||
</div>
|
||||
</CardGrid>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { groupItems } from './data';
|
||||
|
||||
export default defineComponent({
|
||||
components: { Card, CardGrid: Card.Grid, Icon },
|
||||
setup() {
|
||||
return { items: groupItems };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<Card title="快捷导航" v-bind="$attrs">
|
||||
<template v-for="item in navItems" :key="item">
|
||||
<CardGrid>
|
||||
<span class="flex flex-col items-center">
|
||||
<Icon :icon="item.icon" :color="item.color" size="20" />
|
||||
<span class="text-md mt-2">{{ item.title }}</span>
|
||||
</span>
|
||||
</CardGrid>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { navItems } from './data';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
|
||||
const CardGrid = Card.Grid;
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<Card title="销售统计" :loading="loading">
|
||||
<div ref="chartRef" :style="{ width, height }"></div>
|
||||
</Card>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref, watch } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { useECharts } from '/@/hooks/web/useECharts';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
width: {
|
||||
type: String as PropType<string>,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String as PropType<string>,
|
||||
default: '400px',
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
const { setOptions } = useECharts(chartRef as Ref<HTMLDivElement>);
|
||||
watch(
|
||||
() => props.loading,
|
||||
() => {
|
||||
if (props.loading) {
|
||||
return;
|
||||
}
|
||||
setOptions({
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ['Visits', 'Sales'],
|
||||
},
|
||||
tooltip: {},
|
||||
radar: {
|
||||
radius: '60%',
|
||||
splitNumber: 8,
|
||||
indicator: [
|
||||
{
|
||||
name: '2017',
|
||||
},
|
||||
{
|
||||
name: '2017',
|
||||
},
|
||||
{
|
||||
name: '2018',
|
||||
},
|
||||
{
|
||||
name: '2019',
|
||||
},
|
||||
{
|
||||
name: '2020',
|
||||
},
|
||||
{
|
||||
name: '2021',
|
||||
},
|
||||
],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar' as 'custom',
|
||||
symbolSize: 0,
|
||||
areaStyle: {
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0,0,0,.2)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 10,
|
||||
opacity: 1,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: [90, 50, 86, 40, 50, 20],
|
||||
name: 'Visits',
|
||||
itemStyle: {
|
||||
color: '#b6a2de',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: [70, 75, 70, 76, 20, 85],
|
||||
name: 'Sales',
|
||||
itemStyle: {
|
||||
color: '#67e0e3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="lg:flex">
|
||||
<Avatar :src="userinfo.avatar || headerImg" :size="72" class="!mx-auto !block" />
|
||||
<div class="md:ml-6 flex flex-col justify-center md:mt-0 mt-2">
|
||||
<h1 class="md:text-lg text-md">早安, {{ userinfo.realname }}, 开始您一天的工作吧!</h1>
|
||||
<span class="text-secondary"> 今日晴,20℃ - 32℃! </span>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end md:mt-0 mt-4">
|
||||
<div class="flex flex-col justify-center text-right">
|
||||
<span class="text-secondary"> 待办 </span>
|
||||
<span class="text-2xl">2/10</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center text-right md:mx-16 mx-12">
|
||||
<span class="text-secondary"> 项目 </span>
|
||||
<span class="text-2xl">8</span>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center text-right md:mr-10 mr-4">
|
||||
<span class="text-secondary"> 团队 </span>
|
||||
<span class="text-2xl">300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { Avatar } from 'ant-design-vue';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import headerImg from '/@/assets/images/header.jpg';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userinfo = computed(() => userStore.getUserInfo);
|
||||
</script>
|
||||
156
jeecgboot-vue3/src/views/dashboard/workbench/components/data.ts
Normal file
156
jeecgboot-vue3/src/views/dashboard/workbench/components/data.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
interface GroupItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
desc: string;
|
||||
date: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface DynamicInfoItem {
|
||||
avatar: string;
|
||||
name: string;
|
||||
date: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{
|
||||
title: '首页',
|
||||
icon: 'ion:home-outline',
|
||||
color: '#1fdaca',
|
||||
},
|
||||
{
|
||||
title: '仪表盘',
|
||||
icon: 'ion:grid-outline',
|
||||
color: '#bf0c2c',
|
||||
},
|
||||
{
|
||||
title: '组件',
|
||||
icon: 'ion:layers-outline',
|
||||
color: '#e18525',
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
icon: 'ion:settings-outline',
|
||||
color: '#3fb27f',
|
||||
},
|
||||
{
|
||||
title: '权限管理',
|
||||
icon: 'ion:key-outline',
|
||||
color: '#4daf1bc9',
|
||||
},
|
||||
{
|
||||
title: '图表',
|
||||
icon: 'ion:bar-chart-outline',
|
||||
color: '#00d8ff',
|
||||
},
|
||||
];
|
||||
|
||||
export const dynamicInfoItems: DynamicInfoItem[] = [
|
||||
{
|
||||
avatar: 'dynamic-avatar-1|svg',
|
||||
name: '威廉',
|
||||
date: '刚刚',
|
||||
desc: `在 <a>开源组</a> 创建了项目 <a>Vue</a>`,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-2|svg',
|
||||
name: '艾文',
|
||||
date: '1个小时前',
|
||||
desc: `关注了 <a>威廉</a> `,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-3|svg',
|
||||
name: '克里斯',
|
||||
date: '1天前',
|
||||
desc: `发布了 <a>个人动态</a> `,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-4|svg',
|
||||
name: 'Jeecg',
|
||||
date: '2天前',
|
||||
desc: `发表文章 <a>如何编写一个Vite插件</a> `,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-5|svg',
|
||||
name: '皮特',
|
||||
date: '3天前',
|
||||
desc: `回复了 <a>杰克</a> 的问题 <a>如何进行项目优化?</a>`,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-6|svg',
|
||||
name: '杰克',
|
||||
date: '1周前',
|
||||
desc: `关闭了问题 <a>如何运行项目</a> `,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-1|svg',
|
||||
name: '威廉',
|
||||
date: '1周前',
|
||||
desc: `发布了 <a>个人动态</a> `,
|
||||
},
|
||||
{
|
||||
avatar: 'dynamic-avatar-1|svg',
|
||||
name: '威廉',
|
||||
date: '2021-04-01 20:00',
|
||||
desc: `推送了代码到 <a>Github</a>`,
|
||||
},
|
||||
];
|
||||
|
||||
export const groupItems: GroupItem[] = [
|
||||
{
|
||||
title: 'Github',
|
||||
icon: 'carbon:logo-github',
|
||||
color: '',
|
||||
desc: '不要等待机会,而要创造机会。',
|
||||
group: '开源组',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
{
|
||||
title: 'Vue',
|
||||
icon: 'ion:logo-vue',
|
||||
color: '#3fb27f',
|
||||
desc: '现在的你决定将来的你。',
|
||||
group: '算法组',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
{
|
||||
title: 'Html5',
|
||||
icon: 'ion:logo-html5',
|
||||
color: '#e18525',
|
||||
desc: '没有什么才能比努力更重要。',
|
||||
group: '上班摸鱼',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
{
|
||||
title: 'Angular',
|
||||
icon: 'ion:logo-angular',
|
||||
color: '#bf0c2c',
|
||||
desc: '热情和欲望可以突破一切难关。',
|
||||
group: 'UI',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
{
|
||||
title: 'React',
|
||||
icon: 'bx:bxl-react',
|
||||
color: '#00d8ff',
|
||||
desc: '健康的身体是实目标的基石。',
|
||||
group: '技术牛',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
{
|
||||
title: 'Js',
|
||||
icon: 'ion:logo-javascript',
|
||||
color: '#4daf1bc9',
|
||||
desc: '路是走出来的,而不是空想出来的。',
|
||||
group: '架构组',
|
||||
date: '2021-04-01',
|
||||
},
|
||||
];
|
||||
36
jeecgboot-vue3/src/views/dashboard/workbench/index.vue
Normal file
36
jeecgboot-vue3/src/views/dashboard/workbench/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<template #headerContent> <WorkbenchHeader /> </template>
|
||||
<div class="lg:flex">
|
||||
<div class="lg:w-7/10 w-full !mr-4 enter-y">
|
||||
<ProjectCard :loading="loading" class="enter-y" />
|
||||
<DynamicInfo :loading="loading" class="!my-4 enter-y" />
|
||||
</div>
|
||||
<div class="lg:w-3/10 w-full enter-y">
|
||||
<QuickNav :loading="loading" class="enter-y" />
|
||||
|
||||
<Card class="!my-4 enter-y" :loading="loading">
|
||||
<img class="xl:h-50 h-30 mx-auto" src="../../../assets/svg/illustration.svg" />
|
||||
</Card>
|
||||
|
||||
<SaleRadar :loading="loading" class="enter-y" />
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Card } from 'ant-design-vue';
|
||||
import { PageWrapper } from '/@/components/Page';
|
||||
import WorkbenchHeader from './components/WorkbenchHeader.vue';
|
||||
import ProjectCard from './components/ProjectCard.vue';
|
||||
import QuickNav from './components/QuickNav.vue';
|
||||
import DynamicInfo from './components/DynamicInfo.vue';
|
||||
import SaleRadar from './components/SaleRadar.vue';
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
</script>
|
||||
905
jeecgboot-vue3/src/views/dashboard/workshop3d/index.vue
Normal file
905
jeecgboot-vue3/src/views/dashboard/workshop3d/index.vue
Normal file
@@ -0,0 +1,905 @@
|
||||
<template>
|
||||
<div class="workshop-container">
|
||||
<div ref="container" class="three-container"></div>
|
||||
|
||||
<!-- 虚拟光标 -->
|
||||
<div v-if="cursorVisible" class="virtual-cursor" :style="{ left: cursorPosition.x + 'px', top: cursorPosition.y + 'px' }">
|
||||
<div class="cursor-dot"></div>
|
||||
<div class="cursor-ring"></div>
|
||||
</div>
|
||||
|
||||
<!-- 手势控制开关按钮 - 放在顶部更明显 -->
|
||||
<div class="gesture-control-wrapper">
|
||||
<button v-if="!gestureEnabled" @click="toggleGesture" class="gesture-toggle-btn">
|
||||
🎥 启用手势控制
|
||||
</button>
|
||||
<div v-else class="gesture-info">
|
||||
<span>{{ gestureStatus }}</span>
|
||||
<button @click="toggleGesture" class="toggle-btn">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 摄像头预览窗口 - 始终渲染但可能隐藏 -->
|
||||
<div class="camera-preview" :class="{ hidden: !gestureEnabled }">
|
||||
<video ref="videoElement" autoplay playsinline></video>
|
||||
<canvas ref="canvasElement"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 信息面板 -->
|
||||
<div v-if="selectedCube" class="info-panel">
|
||||
<div class="info-header">
|
||||
<h3>设备信息</h3>
|
||||
<button @click="selectedCube = null" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p><strong>设备名称:</strong> {{ selectedCube.name }}</p>
|
||||
<p><strong>设备ID:</strong> {{ selectedCube.id }}</p>
|
||||
<p><strong>状态:</strong> <span :style="{ color: selectedCube.statusColor }">{{ selectedCube.status }}</span></p>
|
||||
<p><strong>位置:</strong> X: {{ selectedCube.position.x.toFixed(2) }}, Y: {{ selectedCube.position.y.toFixed(2) }}, Z: {{ selectedCube.position.z.toFixed(2) }}</p>
|
||||
<p><strong>温度:</strong> {{ selectedCube.temperature }}°C</p>
|
||||
<p><strong>运行时长:</strong> {{ selectedCube.runtime }}小时</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
const container = ref(null)
|
||||
const selectedCube = ref(null)
|
||||
const videoElement = ref(null)
|
||||
const canvasElement = ref(null)
|
||||
const gestureEnabled = ref(false)
|
||||
const gestureStatus = ref('正在加载模型...')
|
||||
const cursorVisible = ref(false)
|
||||
const cursorPosition = ref({ x: 0, y: 0 })
|
||||
|
||||
let scene, camera, renderer, controls, animationId
|
||||
let cubes = []
|
||||
let raycaster, mouse
|
||||
let robotModel = null
|
||||
|
||||
// 手势控制相关变量
|
||||
let videoStream = null
|
||||
let gestureAnimationId = null
|
||||
let handposeModel = null
|
||||
let previousHandPosition = null
|
||||
let isPinching = false
|
||||
let isTwoHandMode = false
|
||||
let palmOpenStartTime = null
|
||||
let smoothedDelta = { x: 0, y: 0 }
|
||||
|
||||
// 手势识别参数
|
||||
const PINCH_THRESHOLD = 50 // 捏合判定阈值(提高以更容易保持捏合状态)
|
||||
const DEAD_ZONE = 0.002 // 死区(降低以提高响应)
|
||||
const SMOOTH_FACTOR = 0.6 // 平滑系数(提高以更平滑)
|
||||
const ROTATE_SPEED = 2.5 // 旋转速度(继续提高)
|
||||
const PAN_SPEED = 4.0 // 平移速度(继续提高)
|
||||
const PALM_RESET_DURATION = 1500 // 手掌重置需要停留的时间(提高到1.5秒避免误触)
|
||||
const PALM_OPEN_THRESHOLD = 120 // 手掌张开判定阈值(提高以减少误识别)
|
||||
|
||||
// 设备数据
|
||||
const cubeData = [
|
||||
{ id: 'DEV-001', name: '加工中心A', status: '运行中', statusColor: '#52c41a', temperature: 45, runtime: 1234, color: 0x1890ff },
|
||||
{ id: 'DEV-002', name: '加工中心B', status: '待机', statusColor: '#faad14', temperature: 28, runtime: 2456, color: 0x52c41a },
|
||||
{ id: 'DEV-003', name: '装配机器人', status: '运行中', statusColor: '#52c41a', temperature: 38, runtime: 3678, color: 0xf5222d },
|
||||
{ id: 'DEV-004', name: '质检设备', status: '故障', statusColor: '#f5222d', temperature: 65, runtime: 890, color: 0xfa8c16 },
|
||||
{ id: 'DEV-005', name: '包装机', status: '运行中', statusColor: '#52c41a', temperature: 42, runtime: 5432, color: 0x722ed1 }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[Workshop3D] 组件已挂载')
|
||||
console.log('[Workshop3D] gestureEnabled 初始值:', gestureEnabled.value)
|
||||
|
||||
initScene()
|
||||
createCubes()
|
||||
setupInteraction()
|
||||
animate()
|
||||
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationId)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
window.removeEventListener('click', onMouseClick)
|
||||
stopGestureControl()
|
||||
controls.dispose()
|
||||
renderer.dispose()
|
||||
scene.clear()
|
||||
})
|
||||
|
||||
function initScene() {
|
||||
// 场景
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0xf0f2f5)
|
||||
|
||||
// 相机
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
container.value.clientWidth / container.value.clientHeight,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.set(8, 8, 8)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
// 渲染器
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
|
||||
renderer.shadowMap.enabled = true
|
||||
container.value.appendChild(renderer.domElement)
|
||||
|
||||
// 轨道控制器
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.05
|
||||
|
||||
// 启用鼠标中键平移
|
||||
controls.mouseButtons = {
|
||||
LEFT: THREE.MOUSE.ROTATE, // 左键旋转
|
||||
MIDDLE: THREE.MOUSE.PAN, // 中键(滚轮)平移
|
||||
RIGHT: THREE.MOUSE.PAN // 右键平移
|
||||
}
|
||||
|
||||
// 光源
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||
directionalLight.position.set(10, 10, 5)
|
||||
directionalLight.castShadow = true
|
||||
scene.add(directionalLight)
|
||||
|
||||
// 地面
|
||||
const groundGeometry = new THREE.PlaneGeometry(20, 20)
|
||||
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc })
|
||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
|
||||
// 网格辅助线
|
||||
const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0xdddddd)
|
||||
scene.add(gridHelper)
|
||||
}
|
||||
|
||||
function createCubes() {
|
||||
const positions = [
|
||||
{ x: -4, y: 1, z: -4 },
|
||||
{ x: 4, y: 1, z: -4 },
|
||||
{ x: 0, y: 1, z: 0 },
|
||||
{ x: -4, y: 1, z: 4 },
|
||||
{ x: 4, y: 1, z: 4 }
|
||||
]
|
||||
|
||||
cubeData.forEach((data, index) => {
|
||||
// 装配机器人(索引2)使用 GLB 模型
|
||||
if (index === 2) {
|
||||
loadRobotModel(positions[index], data)
|
||||
} else {
|
||||
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5)
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: data.color,
|
||||
metalness: 0.3,
|
||||
roughness: 0.4
|
||||
})
|
||||
const cube = new THREE.Mesh(geometry, material)
|
||||
|
||||
cube.position.set(positions[index].x, positions[index].y, positions[index].z)
|
||||
cube.castShadow = true
|
||||
cube.receiveShadow = true
|
||||
|
||||
// 存储设备数据
|
||||
cube.userData = {
|
||||
...data,
|
||||
position: positions[index],
|
||||
originalColor: data.color
|
||||
}
|
||||
|
||||
scene.add(cube)
|
||||
cubes.push(cube)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadRobotModel(position, data) {
|
||||
const loader = new GLTFLoader()
|
||||
loader.load(
|
||||
'/models/robot.glb',
|
||||
(gltf) => {
|
||||
robotModel = gltf.scene
|
||||
robotModel.position.set(position.x, position.y, position.z)
|
||||
|
||||
// 调整模型大小,放大200%(2倍)
|
||||
robotModel.scale.set(2, 2, 2)
|
||||
|
||||
// 启用阴影
|
||||
robotModel.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
}
|
||||
})
|
||||
|
||||
// 存储设备数据
|
||||
robotModel.userData = {
|
||||
...data,
|
||||
position: position,
|
||||
originalColor: data.color,
|
||||
isModel: true
|
||||
}
|
||||
|
||||
scene.add(robotModel)
|
||||
cubes.push(robotModel)
|
||||
},
|
||||
(progress) => {
|
||||
console.log('模型加载进度:', Math.round((progress.loaded / progress.total) * 100) + '%')
|
||||
},
|
||||
(error) => {
|
||||
console.error('模型加载失败:', error)
|
||||
// 加载失败时使用立方体作为备用
|
||||
const geometry = new THREE.BoxGeometry(1.5, 1.5, 1.5)
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: data.color,
|
||||
metalness: 0.3,
|
||||
roughness: 0.4
|
||||
})
|
||||
const cube = new THREE.Mesh(geometry, material)
|
||||
cube.position.set(position.x, position.y, position.z)
|
||||
cube.castShadow = true
|
||||
cube.receiveShadow = true
|
||||
cube.userData = {
|
||||
...data,
|
||||
position: position,
|
||||
originalColor: data.color
|
||||
}
|
||||
scene.add(cube)
|
||||
cubes.push(cube)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function setupInteraction() {
|
||||
raycaster = new THREE.Raycaster()
|
||||
mouse = new THREE.Vector2()
|
||||
|
||||
window.addEventListener('click', onMouseClick)
|
||||
}
|
||||
|
||||
function onMouseClick(event) {
|
||||
const rect = container.value.getBoundingClientRect()
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
|
||||
raycaster.setFromCamera(mouse, camera)
|
||||
|
||||
// 递归检测所有子对象(包括 GLB 模型的子网格)
|
||||
const intersects = raycaster.intersectObjects(cubes, true)
|
||||
|
||||
// 重置所有立方体颜色
|
||||
cubes.forEach(cube => {
|
||||
if (cube.material) {
|
||||
// 立方体
|
||||
cube.material.color.setHex(cube.userData.originalColor)
|
||||
cube.material.emissive.setHex(0x000000)
|
||||
} else if (cube.userData.isModel) {
|
||||
// GLB 模型,重置所有子网格的发光效果
|
||||
cube.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
child.material.emissive.setHex(0x000000)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const clickedObject = intersects[0].object
|
||||
|
||||
// 找到顶层对象(立方体或 GLB 模型)
|
||||
let targetObject = clickedObject
|
||||
while (targetObject.parent && !cubes.includes(targetObject)) {
|
||||
targetObject = targetObject.parent
|
||||
}
|
||||
|
||||
// 高亮选中的对象
|
||||
if (targetObject.material) {
|
||||
// 立方体
|
||||
targetObject.material.emissive.setHex(0x555555)
|
||||
} else if (targetObject.userData.isModel) {
|
||||
// GLB 模型,高亮所有子网格
|
||||
targetObject.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
child.material.emissive.setHex(0x555555)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示信息面板
|
||||
selectedCube.value = targetObject.userData
|
||||
} else {
|
||||
selectedCube.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = container.value.clientWidth / container.value.clientHeight
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
|
||||
}
|
||||
|
||||
// 手势控制相关函数
|
||||
async function toggleGesture() {
|
||||
console.log('[Workshop3D] toggleGesture 被调用')
|
||||
console.log('[Workshop3D] 当前 gestureEnabled 值:', gestureEnabled.value)
|
||||
|
||||
if (gestureEnabled.value) {
|
||||
console.log('[Workshop3D] 准备停止手势控制')
|
||||
stopGestureControl()
|
||||
} else {
|
||||
console.log('[Workshop3D] 准备启动手势控制')
|
||||
await startGestureControl()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTensorFlowJS() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.tf && window.handpose) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// 加载 TensorFlow.js
|
||||
const tfScript = document.createElement('script')
|
||||
tfScript.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@3.11.0/dist/tf.min.js'
|
||||
tfScript.onload = () => {
|
||||
// 加载 HandPose 模型
|
||||
const handposeScript = document.createElement('script')
|
||||
handposeScript.src = 'https://cdn.jsdelivr.net/npm/@tensorflow-models/handpose@0.0.7/dist/handpose.min.js'
|
||||
handposeScript.onload = () => resolve()
|
||||
handposeScript.onerror = () => reject(new Error('HandPose 加载失败'))
|
||||
document.head.appendChild(handposeScript)
|
||||
}
|
||||
tfScript.onerror = () => reject(new Error('TensorFlow.js 加载失败'))
|
||||
document.head.appendChild(tfScript)
|
||||
})
|
||||
}
|
||||
|
||||
async function startGestureControl() {
|
||||
try {
|
||||
gestureStatus.value = '正在加载 TensorFlow.js...'
|
||||
|
||||
// 加载 TensorFlow.js 和 HandPose 模型
|
||||
await loadTensorFlowJS()
|
||||
|
||||
gestureStatus.value = '正在加载手势识别模型...'
|
||||
handposeModel = await window.handpose.load()
|
||||
|
||||
gestureStatus.value = '正在启动摄像头...'
|
||||
videoStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: 640, height: 480, facingMode: 'user' }
|
||||
})
|
||||
|
||||
videoElement.value.srcObject = videoStream
|
||||
gestureEnabled.value = true
|
||||
|
||||
// 等待视频加载
|
||||
await new Promise(resolve => {
|
||||
videoElement.value.onloadedmetadata = resolve
|
||||
})
|
||||
|
||||
// 设置 canvas 尺寸
|
||||
canvasElement.value.width = videoElement.value.videoWidth
|
||||
canvasElement.value.height = videoElement.value.videoHeight
|
||||
|
||||
gestureStatus.value = '手势控制已启用'
|
||||
|
||||
// 开始检测手势
|
||||
detectHandGesture()
|
||||
} catch (error) {
|
||||
console.error('手势控制启动失败:', error)
|
||||
gestureStatus.value = '启动失败: ' + error.message
|
||||
stopGestureControl()
|
||||
alert('手势控制启动失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
function stopGestureControl() {
|
||||
if (videoStream) {
|
||||
videoStream.getTracks().forEach(track => track.stop())
|
||||
videoStream = null
|
||||
}
|
||||
|
||||
if (gestureAnimationId) {
|
||||
cancelAnimationFrame(gestureAnimationId)
|
||||
gestureAnimationId = null
|
||||
}
|
||||
|
||||
gestureEnabled.value = false
|
||||
previousHandPosition = null
|
||||
isPinching = false
|
||||
isTwoHandMode = false
|
||||
palmOpenStartTime = null
|
||||
smoothedDelta = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
async function detectHandGesture() {
|
||||
if (!gestureEnabled.value || !handposeModel) return
|
||||
|
||||
try {
|
||||
const predictions = await handposeModel.estimateHands(videoElement.value)
|
||||
|
||||
// 绘制手部关键点
|
||||
const ctx = canvasElement.value.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvasElement.value.width, canvasElement.value.height)
|
||||
ctx.drawImage(videoElement.value, 0, 0, canvasElement.value.width, canvasElement.value.height)
|
||||
|
||||
if (predictions.length > 0) {
|
||||
// 绘制所有检测到的手
|
||||
predictions.forEach(hand => drawHand(ctx, hand.landmarks))
|
||||
|
||||
// 判断是单手还是双手模式
|
||||
if (predictions.length >= 2) {
|
||||
// 双手模式 - 平移
|
||||
processTwoHandGesture(predictions)
|
||||
} else {
|
||||
// 单手模式 - 旋转或重置
|
||||
processSingleHandGesture(predictions[0].landmarks)
|
||||
}
|
||||
} else {
|
||||
gestureStatus.value = '未检测到手部'
|
||||
resetGestureState()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('手势检测错误:', error)
|
||||
}
|
||||
|
||||
gestureAnimationId = requestAnimationFrame(detectHandGesture)
|
||||
}
|
||||
|
||||
function resetGestureState() {
|
||||
previousHandPosition = null
|
||||
isPinching = false
|
||||
isTwoHandMode = false
|
||||
palmOpenStartTime = null
|
||||
smoothedDelta = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
function isPinchGesture(landmarks) {
|
||||
const thumbTip = landmarks[4]
|
||||
const indexTip = landmarks[8]
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(thumbTip[0] - indexTip[0], 2) +
|
||||
Math.pow(thumbTip[1] - indexTip[1], 2)
|
||||
)
|
||||
|
||||
return distance < PINCH_THRESHOLD
|
||||
}
|
||||
|
||||
function isPalmOpen(landmarks) {
|
||||
// 检测手掌是否张开:所有手指尖端距离手掌中心的距离都较大
|
||||
const palmCenter = landmarks[0]
|
||||
const fingerTips = [landmarks[4], landmarks[8], landmarks[12], landmarks[16], landmarks[20]]
|
||||
|
||||
let totalDistance = 0
|
||||
fingerTips.forEach(tip => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(tip[0] - palmCenter[0], 2) +
|
||||
Math.pow(tip[1] - palmCenter[1], 2)
|
||||
)
|
||||
totalDistance += distance
|
||||
})
|
||||
|
||||
const avgDistance = totalDistance / fingerTips.length
|
||||
return avgDistance > PALM_OPEN_THRESHOLD // 使用更高的阈值减少误识别
|
||||
}
|
||||
|
||||
function processSingleHandGesture(landmarks) {
|
||||
const pinching = isPinchGesture(landmarks)
|
||||
const palmOpen = isPalmOpen(landmarks)
|
||||
|
||||
if (pinching) {
|
||||
// 捏合状态 - 旋转模式
|
||||
const thumbTip = landmarks[4]
|
||||
const indexTip = landmarks[8]
|
||||
const pinchCenter = [(thumbTip[0] + indexTip[0]) / 2, (thumbTip[1] + indexTip[1]) / 2]
|
||||
|
||||
// 更新虚拟光标位置(映射到屏幕坐标)
|
||||
const screenX = (pinchCenter[0] / canvasElement.value.width) * window.innerWidth
|
||||
const screenY = (pinchCenter[1] / canvasElement.value.height) * window.innerHeight
|
||||
cursorPosition.value = { x: screenX, y: screenY }
|
||||
cursorVisible.value = true
|
||||
|
||||
if (isPinching && previousHandPosition) {
|
||||
// 计算移动量
|
||||
let deltaX = (pinchCenter[0] - previousHandPosition[0]) / canvasElement.value.width
|
||||
let deltaY = (pinchCenter[1] - previousHandPosition[1]) / canvasElement.value.height
|
||||
|
||||
// 平滑处理
|
||||
smoothedDelta.x = smoothedDelta.x * (1 - SMOOTH_FACTOR) + deltaX * SMOOTH_FACTOR
|
||||
smoothedDelta.y = smoothedDelta.y * (1 - SMOOTH_FACTOR) + deltaY * SMOOTH_FACTOR
|
||||
|
||||
// 死区处理
|
||||
if (Math.abs(smoothedDelta.x) < DEAD_ZONE) smoothedDelta.x = 0
|
||||
if (Math.abs(smoothedDelta.y) < DEAD_ZONE) smoothedDelta.y = 0
|
||||
|
||||
// 应用旋转
|
||||
applyRotation(smoothedDelta.x, smoothedDelta.y)
|
||||
|
||||
gestureStatus.value = '捏合旋转'
|
||||
}
|
||||
|
||||
previousHandPosition = pinchCenter
|
||||
isPinching = true
|
||||
palmOpenStartTime = null
|
||||
} else if (palmOpen) {
|
||||
// 手掌张开 - 检测是否需要重置
|
||||
cursorVisible.value = false
|
||||
|
||||
if (palmOpenStartTime === null) {
|
||||
palmOpenStartTime = Date.now()
|
||||
} else {
|
||||
const duration = Date.now() - palmOpenStartTime
|
||||
if (duration >= PALM_RESET_DURATION) {
|
||||
// 重置视角
|
||||
resetCameraView()
|
||||
gestureStatus.value = '视角已重置'
|
||||
palmOpenStartTime = null
|
||||
} else {
|
||||
gestureStatus.value = `保持手掌张开 ${((PALM_RESET_DURATION - duration) / 1000).toFixed(1)}s 重置视角`
|
||||
}
|
||||
}
|
||||
isPinching = false
|
||||
previousHandPosition = null
|
||||
} else {
|
||||
// 既不是捏合也不是张开手掌
|
||||
cursorVisible.value = false
|
||||
resetGestureState()
|
||||
gestureStatus.value = '等待手势...'
|
||||
}
|
||||
}
|
||||
|
||||
function processTwoHandGesture(predictions) {
|
||||
const hand1 = predictions[0].landmarks
|
||||
const hand2 = predictions[1].landmarks
|
||||
|
||||
const pinching1 = isPinchGesture(hand1)
|
||||
const pinching2 = isPinchGesture(hand2)
|
||||
|
||||
if (pinching1 && pinching2) {
|
||||
// 双手都在捏合 - 平移模式
|
||||
const thumbTip1 = hand1[4]
|
||||
const indexTip1 = hand1[8]
|
||||
const thumbTip2 = hand2[4]
|
||||
const indexTip2 = hand2[8]
|
||||
|
||||
const center1 = [(thumbTip1[0] + indexTip1[0]) / 2, (thumbTip1[1] + indexTip1[1]) / 2]
|
||||
const center2 = [(thumbTip2[0] + indexTip2[0]) / 2, (thumbTip2[1] + indexTip2[1]) / 2]
|
||||
const twoHandCenter = [(center1[0] + center2[0]) / 2, (center1[1] + center2[1]) / 2]
|
||||
|
||||
// 更新虚拟光标位置(双手模式)
|
||||
const screenX = (twoHandCenter[0] / canvasElement.value.width) * window.innerWidth
|
||||
const screenY = (twoHandCenter[1] / canvasElement.value.height) * window.innerHeight
|
||||
cursorPosition.value = { x: screenX, y: screenY }
|
||||
cursorVisible.value = true
|
||||
|
||||
if (isTwoHandMode && previousHandPosition) {
|
||||
// 计算移动量
|
||||
let deltaX = (twoHandCenter[0] - previousHandPosition[0]) / canvasElement.value.width
|
||||
let deltaY = (twoHandCenter[1] - previousHandPosition[1]) / canvasElement.value.height
|
||||
|
||||
// 平滑处理
|
||||
smoothedDelta.x = smoothedDelta.x * (1 - SMOOTH_FACTOR) + deltaX * SMOOTH_FACTOR
|
||||
smoothedDelta.y = smoothedDelta.y * (1 - SMOOTH_FACTOR) + deltaY * SMOOTH_FACTOR
|
||||
|
||||
// 死区处理
|
||||
if (Math.abs(smoothedDelta.x) < DEAD_ZONE) smoothedDelta.x = 0
|
||||
if (Math.abs(smoothedDelta.y) < DEAD_ZONE) smoothedDelta.y = 0
|
||||
|
||||
// 应用平移
|
||||
applyPanning(smoothedDelta.x, smoothedDelta.y)
|
||||
|
||||
gestureStatus.value = '双手平移'
|
||||
}
|
||||
|
||||
previousHandPosition = twoHandCenter
|
||||
isTwoHandMode = true
|
||||
isPinching = false
|
||||
} else {
|
||||
// 不是双手捏合,回到单手模式
|
||||
isTwoHandMode = false
|
||||
processSingleHandGesture(predictions[0].landmarks)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRotation(deltaX, deltaY) {
|
||||
// 水平和垂直旋转角度
|
||||
const horizontalAngle = -deltaX * Math.PI * ROTATE_SPEED
|
||||
const verticalAngle = -deltaY * Math.PI * ROTATE_SPEED
|
||||
|
||||
// 获取相机到目标的向量
|
||||
const offset = camera.position.clone().sub(controls.target)
|
||||
const distance = offset.length()
|
||||
|
||||
// 转换为球坐标
|
||||
let theta = Math.atan2(offset.x, offset.z)
|
||||
let phi = Math.acos(Math.max(-1, Math.min(1, offset.y / distance)))
|
||||
|
||||
// 应用旋转
|
||||
theta += horizontalAngle
|
||||
phi += verticalAngle
|
||||
|
||||
// 限制垂直角度
|
||||
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi))
|
||||
|
||||
// 转换回笛卡尔坐标
|
||||
camera.position.x = controls.target.x + distance * Math.sin(phi) * Math.sin(theta)
|
||||
camera.position.y = controls.target.y + distance * Math.cos(phi)
|
||||
camera.position.z = controls.target.z + distance * Math.sin(phi) * Math.cos(theta)
|
||||
|
||||
camera.lookAt(controls.target)
|
||||
}
|
||||
|
||||
function applyPanning(deltaX, deltaY) {
|
||||
// 根据相机方向计算平移向量
|
||||
const right = new THREE.Vector3()
|
||||
const up = new THREE.Vector3()
|
||||
right.setFromMatrixColumn(camera.matrix, 0)
|
||||
up.setFromMatrixColumn(camera.matrix, 1)
|
||||
|
||||
// 应用平移
|
||||
controls.target.add(right.multiplyScalar(-deltaX * PAN_SPEED))
|
||||
controls.target.add(up.multiplyScalar(deltaY * PAN_SPEED))
|
||||
}
|
||||
|
||||
function resetCameraView() {
|
||||
// 重置相机到初始位置
|
||||
camera.position.set(8, 8, 8)
|
||||
controls.target.set(0, 0, 0)
|
||||
camera.lookAt(controls.target)
|
||||
}
|
||||
|
||||
function drawHand(ctx, landmarks) {
|
||||
// 绘制手部关键点
|
||||
ctx.fillStyle = '#00ff00'
|
||||
landmarks.forEach(point => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(point[0], point[1], 5, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
// 绘制连接线
|
||||
const connections = [
|
||||
[0, 1], [1, 2], [2, 3], [3, 4], // 拇指
|
||||
[0, 5], [5, 6], [6, 7], [7, 8], // 食指
|
||||
[0, 9], [9, 10], [10, 11], [11, 12], // 中指
|
||||
[0, 13], [13, 14], [14, 15], [15, 16], // 无名指
|
||||
[0, 17], [17, 18], [18, 19], [19, 20], // 小指
|
||||
[5, 9], [9, 13], [13, 17] // 手掌
|
||||
]
|
||||
|
||||
ctx.strokeStyle = '#00ff00'
|
||||
ctx.lineWidth = 2
|
||||
connections.forEach(([start, end]) => {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(landmarks[start][0], landmarks[start][1])
|
||||
ctx.lineTo(landmarks[end][0], landmarks[end][1])
|
||||
ctx.stroke()
|
||||
})
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
// 只旋转立方体,不旋转 GLB 模型
|
||||
cubes.forEach((cube, index) => {
|
||||
if (!cube.userData.isModel) {
|
||||
cube.rotation.y += 0.005 * (index % 2 === 0 ? 1 : -1)
|
||||
}
|
||||
})
|
||||
|
||||
controls.update()
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workshop-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.three-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.info-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-content p {
|
||||
margin: 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content strong {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gesture-control-wrapper {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.gesture-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
width: 320px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.camera-preview.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-preview video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-preview canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: #f5222d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: #cf1322;
|
||||
}
|
||||
|
||||
.gesture-toggle-btn {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gesture-toggle-btn:hover {
|
||||
background: #096dd9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.virtual-cursor {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.cursor-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 8px rgba(24, 144, 255, 0.6);
|
||||
}
|
||||
|
||||
.cursor-ring {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid #1890ff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user