抽卡日历&堆叠柱状图

This commit is contained in:
目棃
2025-01-13 16:59:08 +08:00
parent 5f6f6e0ea6
commit 547cb2cce7
4 changed files with 440 additions and 210 deletions

View File

@@ -1,17 +1,42 @@
<template> <template>
<div class="gro-echart"> <div class="gro-chart">
<v-chart :option="getPoolData()" autoresize :theme="echartsTheme" /> <div class="gro-chart-options">
<v-select
class="gro-chart-select"
v-model="curChartType"
:items="chartTypes"
item-title="label"
item-value="value"
label="选择图表"
density="compact"
outlined
hide-details
width="200px"
/>
</div>
<v-chart
:option="chartOptions"
autoresize
:theme="echartsTheme"
:init-options="{ locale: 'ZH' }"
v-if="chartOptions"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// about import err,see:https://github.com/apache/echarts/issues/19992 // about import err,see:https://github.com/apache/echarts/issues/19992
import showLoading from "@comp/func/loading.js";
// @ts-expect-error no-exported-member // @ts-expect-error no-exported-member
import { PieChart } from "echarts/charts.js"; import { BarChart, HeatmapChart, PieChart } from "echarts/charts.js";
import { import {
CalendarComponent,
DataZoomComponent,
GridComponent,
LegendComponent, LegendComponent,
TitleComponent, TitleComponent,
ToolboxComponent, ToolboxComponent,
TooltipComponent, TooltipComponent,
VisualMapComponent,
} from "echarts/components.js"; } from "echarts/components.js";
// @ts-expect-error no-exported-member // @ts-expect-error no-exported-member
import { use } from "echarts/core.js"; import { use } from "echarts/core.js";
@@ -21,223 +46,96 @@ import { LabelLayout } from "echarts/features.js";
import { CanvasRenderer } from "echarts/renderers.js"; import { CanvasRenderer } from "echarts/renderers.js";
import type { EChartsOption } from "echarts/types/dist/shared.js"; import type { EChartsOption } from "echarts/types/dist/shared.js";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { computed } from "vue"; import { computed, ref, shallowRef, watch } from "vue";
import VChart from "vue-echarts"; import VChart from "vue-echarts";
import { useAppStore } from "@/store/modules/app.js"; import { useAppStore } from "@/store/modules/app.js";
import TGachaCharts from "@/utils/gachaCharts.js";
// echarts // echarts
use([ use([
TitleComponent,
TooltipComponent,
ToolboxComponent,
LegendComponent,
PieChart,
CanvasRenderer,
LabelLayout, LabelLayout,
CanvasRenderer,
BarChart,
HeatmapChart,
PieChart,
CalendarComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
VisualMapComponent,
]); ]);
interface GachaOverviewEchartsProps { type GachaOverviewEchartsProps = { uid: string; gachaType?: string };
modelValue: TGApp.Sqlite.GachaRecords.SingleTable[]; type ChartsType = "overview" | "calendar" | "stackBar";
} type ChartItem = { label: string; value: ChartsType };
const props = defineProps<GachaOverviewEchartsProps>(); const props = defineProps<GachaOverviewEchartsProps>();
const { theme } = storeToRefs(useAppStore()); const { theme } = storeToRefs(useAppStore());
const chartTypes: Array<ChartItem> = [
{ label: "祈愿分析", value: "overview" },
{ label: "祈愿日历", value: "calendar" },
{ label: "祈愿柱状图", value: "stackBar" },
];
const curChartType = ref<ChartsType>("overview");
const chartOptions = shallowRef<EChartsOption>();
const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light")); const echartsTheme = computed<"dark" | "light">(() => (theme.value === "dark" ? "dark" : "light"));
// data watch(
const defaultOptions = <EChartsOption>{ () => curChartType.value,
title: [ () => {
{ getOptions();
text: ">> 祈愿系统大数据分析 <<",
left: "center",
top: "5%",
}, },
{ { immediate: true },
text: "卡池分布", );
left: "17%",
top: "45%",
},
{
text: "星级分布",
left: "17%",
top: "90%",
},
{
text: "角色池分布",
left: "45%",
bottom: "10%",
},
{
text: "武器池分布",
right: "5%",
bottom: "10%",
},
],
tooltip: { trigger: "item" },
legend: {
type: "scroll",
orient: "vertical",
left: 10,
top: 20,
bottom: 20,
},
toolbox: {
show: true,
feature: {
restore: {},
saveAsImage: {},
},
},
series: [
{
name: "卡池分布",
type: "pie",
radius: "50%",
data: [],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
right: "60%",
top: 0,
bottom: "50%",
},
{
name: "星级分布",
type: "pie",
radius: "50%",
data: [],
right: "60%",
top: "50%",
bottom: "0",
},
{
name: "角色池分布",
type: "pie",
radius: [30, 150],
center: ["50%", "50%"],
roseType: "area",
itemStyle: {
borderRadius: [2, 10],
},
data: [],
datasetIndex: 3,
},
{
name: "武器池分布",
type: "pie",
radius: [30, 100],
itemStyle: {
borderRadius: [3, 10],
},
data: [],
left: "70%",
},
],
};
// 获取卡池分布的数据 async function getOptions(): Promise<void> {
// todo 重构以完善类型 await showLoading.start("加载中...");
function getPoolData(): EChartsOption { switch (curChartType.value) {
const data: EChartsOption = JSON.parse(JSON.stringify(defaultOptions)); case "overview":
if (data.title !== undefined && Array.isArray(data.title)) { chartOptions.value = await TGachaCharts.overview(props.uid);
data.title[0].subtext = `${props.modelValue.length} 条数据`; break;
case "calendar":
chartOptions.value = await TGachaCharts.calendar(props.uid, props.gachaType);
break;
case "stackBar":
chartOptions.value = await TGachaCharts.stackBar(props.uid, props.gachaType);
break;
} }
if (data.series !== undefined && Array.isArray(data.series)) { await showLoading.end();
data.series[0].data = [
{
value: props.modelValue.filter((item) => item.uigfType === "100").length,
name: "新手祈愿",
},
{
value: props.modelValue.filter((item) => item.uigfType === "200").length,
name: "常驻祈愿",
},
{
value: props.modelValue.filter((item) => item.uigfType === "301").length,
name: "角色活动祈愿",
},
{
value: props.modelValue.filter((item) => item.uigfType === "302").length,
name: "武器活动祈愿",
},
{
value: props.modelValue.filter((item) => item.uigfType === "500").length,
name: "集录祈愿",
},
];
data.series[1].data = [
{ value: props.modelValue.filter((item) => item.rank === "3").length, name: "3星" },
{ value: props.modelValue.filter((item) => item.rank === "4").length, name: "4星" },
{ value: props.modelValue.filter((item) => item.rank === "5").length, name: "5星" },
];
}
const tempSet = new Set<string>();
const tempRecord = new Map<string, number>();
// 角色池分析
let tempList = props.modelValue.filter((item) => item.uigfType === "301");
let star3 = tempList.filter((item) => item.rank === "3").length;
if (data.title !== undefined && Array.isArray(data.title)) {
data.title[3].subtext = `${tempList.length} 条数据, 其中三星武器 ${star3}`;
}
tempList
.filter((item) => item.rank !== "3")
.forEach((item) => {
if (tempSet.has(item.name)) {
tempRecord.set(item.name, (tempRecord.get(item.name) ?? 0) + 1);
} else {
tempSet.add(item.name);
tempRecord.set(item.name, 1);
}
});
if (data.series !== undefined && Array.isArray(data.series)) {
data.series[2].data = Array.from(tempRecord).map((item) => {
return {
value: item[1],
name: item[0],
};
});
}
tempSet.clear();
tempRecord.clear();
// 武器池分析
tempList = props.modelValue.filter((item) => item.uigfType === "302");
star3 = tempList.filter((item) => item.rank === "3").length;
if (data.title !== undefined && Array.isArray(data.title)) {
data.title[4].subtext = `${tempList.length} 条数据,其中三星武器 ${star3}`;
}
tempList
.filter((item) => item.rank !== "3")
.forEach((item) => {
if (tempSet.has(item.name)) {
tempRecord.set(item.name, (tempRecord.get(item.name) ?? 0) + 1);
} else {
tempSet.add(item.name);
tempRecord.set(item.name, 1);
}
});
if (data.series !== undefined && Array.isArray(data.series)) {
data.series[3].data = Array.from(tempRecord).map((item) => {
return {
value: item[1],
name: item[0],
};
});
}
return data;
} }
</script> </script>
<style lang="css" scoped> <style lang="css" scoped>
.gro-echart { .gro-chart {
position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 100%; height: 100%;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px;
overflow-y: auto;
}
.gro-chart-options {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-right: auto;
gap: 10px;
}
.gro-chart-select {
width: 150px;
color: var(--common-text-title);
font-family: var(--font-title);
} }
</style> </style>

View File

@@ -35,7 +35,7 @@
<gro-overview v-model="gachaListCur" /> <gro-overview v-model="gachaListCur" />
</v-window-item> </v-window-item>
<v-window-item value="echarts" class="gacha-window-item"> <v-window-item value="echarts" class="gacha-window-item">
<gro-echarts v-model="gachaListCur" /> <gro-echarts :uid="uidCur" v-if="uidCur" />
</v-window-item> </v-window-item>
<v-window-item value="table" class="gacha-window-item"> <v-window-item value="table" class="gacha-window-item">
<gro-table v-model="gachaListCur" /> <gro-table v-model="gachaListCur" />

View File

@@ -25,9 +25,9 @@ function getInsertSql(uid: string, gacha: TGApp.Plugins.UIGF.GachaItem): string
INSERT INTO GachaRecords (uid, gachaType, itemId, count, time, name, type, rank, id, uigfType, updated) INSERT INTO GachaRecords (uid, gachaType, itemId, count, time, name, type, rank, id, uigfType, updated)
VALUES ('${uid}', '${gacha.gacha_type}', '${gacha.item_id ?? null}', '${gacha.count ?? null}', '${gacha.time}', VALUES ('${uid}', '${gacha.gacha_type}', '${gacha.item_id ?? null}', '${gacha.count ?? null}', '${gacha.time}',
'${gacha.name}', '${gacha.item_type ?? null}', '${gacha.rank_type ?? null}', '${gacha.id}', '${gacha.name}', '${gacha.item_type ?? null}', '${gacha.rank_type ?? null}', '${gacha.id}',
'${gacha.uigf_gacha_type}', datetime('now', 'localtime')) '${gacha.uigf_gacha_type}', datetime('now', 'localtime')) ON CONFLICT (id)
ON CONFLICT (id) DO
DO UPDATE UPDATE
SET uid = '${uid}', SET uid = '${uid}',
gachaType = '${gacha.gacha_type}', gachaType = '${gacha.gacha_type}',
uigfType = '${gacha.uigf_gacha_type}', uigfType = '${gacha.uigf_gacha_type}',
@@ -104,6 +104,40 @@ async function getGachaRecords(uid: string): Promise<TGApp.Sqlite.GachaRecords.S
return await db.select("SELECT * FROM GachaRecords WHERE uid = ?;", [uid]); return await db.select("SELECT * FROM GachaRecords WHERE uid = ?;", [uid]);
} }
/**
* @description 获取用户祈愿记录,并按照日期进行分组排序
* @since Beta v0.6.8
* @param {string} uid - UID
* @param {string} type - 类型
* @return {Promise<Record<string, TGApp.Sqlite.GachaRecords.SingleTable[]>} 日期分组的祈愿记录
*/
async function getGachaRecordsGroupByDate(
uid: string,
type?: string,
): Promise<Record<string, TGApp.Sqlite.GachaRecords.SingleTable[]>> {
const db = await TGSqlite.getDB();
type resType = Array<TGApp.Sqlite.GachaRecords.SingleTable>;
let res: resType;
if (type) {
res = await db.select<resType>(
"SELECT * FROM GachaRecords WHERE uid = ? AND gachaType = ? ORDER BY time;",
[uid, type],
);
} else {
res = await db.select<resType>("SELECT * FROM GachaRecords WHERE uid = ? ORDER BY time;", [
uid,
]);
}
const map: Record<string, TGApp.Sqlite.GachaRecords.SingleTable[]> = {};
for (const item of res) {
// key 是 yyyy-MM-dd hh:mm:ss按照日期分组
const key = item.time.split(" ")[0];
if (!map[key]) map[key] = [];
map[key].push(item);
}
return map;
}
/** /**
* @description 删除指定UID的祈愿记录 * @description 删除指定UID的祈愿记录
* @since Beta v0.4.7 * @since Beta v0.4.7
@@ -236,6 +270,7 @@ const TSUserGacha = {
getUidList, getUidList,
getGachaCheck, getGachaCheck,
getGachaRecords, getGachaRecords,
getGachaRecordsGroupByDate,
deleteGachaRecords, deleteGachaRecords,
cleanGachaRecords, cleanGachaRecords,
mergeUIGF, mergeUIGF,

297
src/utils/gachaCharts.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* @file utils/gachaCharts.ts
* @description 祈愿图表配置
* @since Beta v0.6.8
*/
import TSUserGacha from "@Sqlite/modules/userGacha.js";
// @ts-expect-error no-export-member
import type { BarSeriesOption } from "echarts/charts.js";
import type { EChartsOption, XAXisOption } from "echarts/types/dist/shared.js";
/**
* @description 获取整体祈愿图表配置
* @param {string} uid - 用户UID
* @returns {EChartsOption}
*/
async function getOverviewOptions(uid: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecords(uid);
const data: EChartsOption = {
title: [
{
text: ">> 祈愿系统大数据分析 <<",
left: "center",
top: "5%",
},
{
text: "卡池分布",
left: "17%",
top: "45%",
},
{
text: "星级分布",
left: "17%",
top: "90%",
},
{
text: "角色池分布",
left: "45%",
bottom: "10%",
},
{
text: "武器池分布",
right: "5%",
bottom: "10%",
},
],
tooltip: { trigger: "item" },
legend: {
type: "scroll",
orient: "vertical",
left: 10,
top: 20,
bottom: 20,
},
toolbox: {
show: true,
feature: {
restore: {},
saveAsImage: {},
},
},
series: [
{
name: "卡池分布",
type: "pie",
radius: "50%",
data: [],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
right: "60%",
top: 0,
bottom: "50%",
},
{
name: "星级分布",
type: "pie",
radius: "50%",
data: [],
right: "60%",
top: "50%",
bottom: "0",
},
{
name: "角色池分布",
type: "pie",
radius: [30, 150],
center: ["50%", "50%"],
roseType: "area",
itemStyle: { borderRadius: [2, 10] },
data: [],
datasetIndex: 3,
},
{
name: "武器池分布",
type: "pie",
radius: [30, 100],
itemStyle: { borderRadius: [3, 10] },
data: [],
left: "70%",
},
],
};
if (data.title !== undefined && Array.isArray(data.title)) {
data.title[0].subtext = `${records.length} 条数据`;
}
if (data.series !== undefined && Array.isArray(data.series)) {
data.series[0].data = [
{
value: records.filter((item) => item.uigfType === "100").length,
name: "新手祈愿",
},
{
value: records.filter((item) => item.uigfType === "200").length,
name: "常驻祈愿",
},
{
value: records.filter((item) => item.uigfType === "301").length,
name: "角色活动祈愿",
},
{
value: records.filter((item) => item.uigfType === "302").length,
name: "武器活动祈愿",
},
{
value: records.filter((item) => item.uigfType === "500").length,
name: "集录祈愿",
},
];
data.series[1].data = [
{ value: records.filter((item) => item.rank === "3").length, name: "3星" },
{ value: records.filter((item) => item.rank === "4").length, name: "4星" },
{ value: records.filter((item) => item.rank === "5").length, name: "5星" },
];
}
const tempSet = new Set<string>();
const tempRecord = new Map<string, number>();
// 角色池分析
let tempList = records.filter((item) => item.uigfType === "301");
let star3 = tempList.filter((item) => item.rank === "3").length;
if (data.title !== undefined && Array.isArray(data.title)) {
data.title[3].subtext = `${tempList.length} 条数据, 其中三星武器 ${star3}`;
}
tempList
.filter((item) => item.rank !== "3")
.forEach((item) => {
if (tempSet.has(item.name)) {
tempRecord.set(item.name, (tempRecord.get(item.name) ?? 0) + 1);
} else {
tempSet.add(item.name);
tempRecord.set(item.name, 1);
}
});
if (data.series !== undefined && Array.isArray(data.series)) {
data.series[2].data = Array.from(tempRecord).map((item) => ({ value: item[1], name: item[0] }));
}
tempSet.clear();
tempRecord.clear();
// 武器池分析
tempList = records.filter((item) => item.uigfType === "302");
star3 = tempList.filter((item) => item.rank === "3").length;
if (data.title !== undefined && Array.isArray(data.title)) {
data.title[4].subtext = `${tempList.length} 条数据,其中三星武器 ${star3}`;
}
tempList
.filter((item) => item.rank !== "3")
.forEach((item) => {
if (tempSet.has(item.name)) {
tempRecord.set(item.name, (tempRecord.get(item.name) ?? 0) + 1);
} else {
tempSet.add(item.name);
tempRecord.set(item.name, 1);
}
});
if (data.series !== undefined && Array.isArray(data.series)) {
data.series[3].data = Array.from(tempRecord).map((item) => ({ value: item[1], name: item[0] }));
}
return data;
}
/**
* @description 获取日历图表配置
* @param {string} uid - 用户UID
* @param {string} gachaType - 祈愿类型
* @returns {EChartsOption}
*/
async function getCalendarOptions(uid: string, gachaType?: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(uid, gachaType);
// 获取最大长度
const maxLen = Math.max(...Object.values(records).map((v) => v.length));
// 获取年份
const yearsSet = new Set(Object.keys(records).map((v) => v.split("-")[0]));
function getYearData(year: string): [string, number][] {
const res: [string, number][] = [];
for (const key in records) {
if (key.startsWith(year)) res.push([key, records[key].length]);
}
return res;
}
return {
tooltip: { position: "top" },
toolbox: { show: true, feature: { restore: {}, saveAsImage: {} } },
visualMap: {
min: 0,
max: maxLen,
calculable: true,
orient: "horizontal",
left: "center",
top: "top",
},
calendar: Array.from(yearsSet).map((year, index) => ({
range: year,
cellSize: ["auto", 15],
top: 150 * index + 80,
})),
series: Array.from(yearsSet).map((year, index) => ({
type: "heatmap",
coordinateSystem: "calendar",
calendarIndex: index,
data: getYearData(year),
})),
};
}
/**
* @description 堆叠柱状图
* @param {string} uid - 用户UID
* @param {string} gachaType - 祈愿类型
* @returns {EChartsOption}
*/
async function getStackBarOptions(uid: string, gachaType?: string): Promise<EChartsOption> {
const records = await TSUserGacha.getGachaRecordsGroupByDate(uid, gachaType);
const xAxis: XAXisOption = {
type: "category",
data: Object.keys(records),
axisTick: { alignWithLabel: true },
axisLine: { show: true, lineStyle: { color: "#000" } },
axisLabel: {
rotate: 45,
interval: 4,
fontSize: 12,
fontFamily: "var(--font-title)",
},
// 间距
axisPointer: { type: "shadow" },
};
const temp5 = [];
const temp4 = [];
const temp3 = [];
for (const key in records) {
const gachaLogs = records[key];
const star5 = gachaLogs.filter((r) => r.rank === "5").length;
const star4 = gachaLogs.filter((r) => r.rank === "4").length;
const star3 = gachaLogs.filter((r) => r.rank === "3").length;
temp5.push(star5);
temp4.push(star4);
temp3.push(star3);
}
const series: BarSeriesOption = [
{ data: temp5, type: "bar", stack: "a", name: "五星数量", barWidth: "10px" },
{ data: temp4, type: "bar", stack: "a", name: "四星数量", barWidth: "10px" },
{ data: temp3, type: "bar", stack: "a", name: "三星数量", barWidth: "10px" },
];
return {
title: { text: "祈愿记录" },
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
toolbox: { show: true, feature: { restore: {}, saveAsImage: {} } },
legend: { data: ["三星数量", "四星数量", "五星数量"] },
xAxis,
yAxis: { type: "value" },
series,
dataZoom: [
{
type: "inside",
show: true,
xAxisIndex: [0],
start: 0,
end: 100,
zoomOnMouseWheel: true,
},
],
};
}
const TGachaCharts = {
overview: getOverviewOptions,
calendar: getCalendarOptions,
stackBar: getStackBarOptions,
};
export default TGachaCharts;