add transaction statistics page

This commit is contained in:
MaysWind
2023-07-09 23:58:33 +08:00
parent 298c0922cb
commit 5e986b2d04
10 changed files with 1289 additions and 4 deletions
@@ -0,0 +1,194 @@
<template>
<v-dialog width="460" v-model="showState">
<v-card>
<v-toolbar color="primary">
<v-toolbar-title>{{ title }}</v-toolbar-title>
</v-toolbar>
<v-card-text class="pa-4">
<p v-if="hint">{{ hint }}</p>
<span v-if="beginDateTime && endDateTime">
<span>{{ beginDateTime }}</span>
<span> - </span>
<span>{{ endDateTime }}</span>
</span>
<slot></slot>
</v-card-text>
<v-card-text class="pa-4 w-100 d-flex justify-center">
<vue-date-picker range inline enable-seconds auto-apply
ref="datetimepicker"
month-name-format="long"
six-weeks="center"
:clearable="false"
:dark="isDarkMode"
:week-start="firstDayOfWeek"
:year-range="yearRange"
:day-names="dayNames"
:is24="is24Hour"
:partial-range="false"
:preset-ranges="presetRanges"
v-model="dateRange">
<template #month="{ text }">
{{ $t(`datetime.${text}.short`) }}
</template>
<template #month-overlay-value="{ text }">
{{ $t(`datetime.${text}.short`) }}
</template>
</vue-date-picker>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="cancel">{{ $t('Cancel') }}</v-btn>
<v-btn :disabled="!dateRange[0] || !dateRange[1]" @click="confirm">{{ $t('OK') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useUserStore } from '@/stores/user.js';
import datetimeConstants from '@/consts/datetime.js';
import { arrangeArrayWithNewStartIndex } from '@/lib/common.js';
import {
getCurrentUnixTime,
getCurrentDateTime,
getUnixTime,
getLocalDatetimeFromUnixTime,
getTodayFirstUnixTime,
getYear,
getDummyUnixTimeForLocalUsage,
getActualUnixTimeForStore,
getTimezoneOffsetMinutes,
getBrowserTimezoneOffsetMinutes,
getDateRangeByDateType
} from '@/lib/datetime.js';
export default {
props: [
'minTime',
'maxTime',
'title',
'hint',
'show'
],
emits: [
'update:show',
'dateRange:change'
],
data() {
const self = this;
let minDate = getTodayFirstUnixTime();
let maxDate = getCurrentUnixTime();
if (self.minTime) {
minDate = self.minTime;
}
if (self.maxTime) {
maxDate = self.maxTime;
}
return {
yearRange: [
2000,
getYear(getCurrentDateTime()) + 1
],
dateRange: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(minDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(maxDate, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
}
},
computed: {
...mapStores(useUserStore),
showState: {
get: function () {
return this.show;
},
set: function (value) {
this.$emit('update:show', value);
}
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
firstDayOfWeek() {
return this.userStore.currentUserFirstDayOfWeek;
},
dayNames() {
return arrangeArrayWithNewStartIndex(this.$locale.getAllMinWeekdayNames(), this.firstDayOfWeek);
},
is24Hour() {
return this.$locale.isLongTime24HourFormat(this.userStore);
},
beginDateTime() {
const actualBeginUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[0]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualBeginUnixTime);
},
endDateTime() {
const actualEndUnixTime = getActualUnixTimeForStore(getUnixTime(this.dateRange[1]), getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
return this.$locale.formatUnixTimeToLongDateTime(this.userStore, actualEndUnixTime);
},
presetRanges() {
const presetRanges = [];
[
datetimeConstants.allDateRanges.Today,
datetimeConstants.allDateRanges.LastSevenDays,
datetimeConstants.allDateRanges.LastThirtyDays,
datetimeConstants.allDateRanges.ThisWeek,
datetimeConstants.allDateRanges.ThisMonth,
datetimeConstants.allDateRanges.ThisYear
].forEach(dateRangeType => {
const dateRange = getDateRangeByDateType(dateRangeType.type, this.firstDayOfWeek);
presetRanges.push({
label: this.$t(dateRangeType.name),
range: [
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.minTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes())),
getLocalDatetimeFromUnixTime(getDummyUnixTimeForLocalUsage(dateRange.maxTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes()))
]
});
});
return presetRanges;
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
confirm() {
if (!this.dateRange[0] || !this.dateRange[1]) {
return;
}
const currentMinDate = this.dateRange[0];
const currentMaxDate = this.dateRange[1];
let minUnixTime = getUnixTime(currentMinDate);
let maxUnixTime = getUnixTime(currentMaxDate);
if (minUnixTime < 0 || maxUnixTime < 0) {
this.$toast('Date is too early');
return;
}
minUnixTime = getActualUnixTimeForStore(minUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
maxUnixTime = getActualUnixTimeForStore(maxUnixTime, getTimezoneOffsetMinutes(), getBrowserTimezoneOffsetMinutes());
this.$emit('dateRange:change', minUnixTime, maxUnixTime);
},
cancel() {
this.$emit('update:show', false);
}
}
}
</script>
+13
View File
@@ -27,6 +27,7 @@ export default {
'color',
'defaultColor',
'additionalColorAttr',
'size',
'hiddenStatus'
],
data() {
@@ -102,6 +103,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getCategoryIconStyle(color, defaultColor, additionalColorAttr) {
@@ -119,6 +124,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
},
getDefaultIconStyle(color, defaultColor, additionalColorAttr) {
@@ -136,6 +145,10 @@ export default {
ret[additionalColorAttr] = color;
}
if (this.size) {
ret['font-size'] = this.size;
}
return ret;
}
}
+265
View File
@@ -0,0 +1,265 @@
<template>
<v-chart class="pie-chart-container" autoresize :option="chartOptions"
@click="clickItem" @legendselectchanged="onLegendSelectChanged" />
</template>
<script>
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/setting.js';
import colorConstants from '@/consts/color.js';
import statisticsConstants from '@/consts/statistics.js';
import { formatPercent } from '@/lib/common.js';
export default {
props: [
'items',
'idField',
'nameField',
'valueField',
'percentField',
'currencyField',
'colorField',
'hiddenField',
'minValidPercent',
'defaultCurrency',
'showValue',
'enableClickItem'
],
emits: [
'click'
],
data() {
return {
selectedLegends: null
};
},
computed: {
...mapStores(useSettingsStore),
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
itemsMap: function () {
const map = {};
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
let id = '';
if (this.idField && item[this.idField]) {
id = item[this.idField];
} else {
id = item[this.nameField];
}
map[id] = item;
}
return map;
},
validItems: function () {
let totalValidValue = 0;
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 && (!this.hiddenField || !item[this.hiddenField])) {
totalValidValue += item[this.valueField];
}
}
const validItems = [];
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item[this.valueField] && item[this.valueField] > 0 &&
(!this.hiddenField || !item[this.hiddenField]) &&
(!this.minValidPercent || item[this.valueField] / totalValidValue > this.minValidPercent)) {
const finalItem = {
id: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
name: (this.idField && item[this.idField]) ? item[this.idField] : item[this.nameField],
displayName: item[this.nameField],
value: item[this.valueField],
percent: (item[this.percentField] > 0 || item[this.percentField] === 0 || item[this.percentField] === '0') ? item[this.percentField] : (item[this.valueField] / totalValidValue * 100),
actualPercent: item[this.valueField] / totalValidValue,
currency: item[this.currencyField],
itemStyle: {
color: this.getColor(item[this.colorField] ? item[this.colorField] : statisticsConstants.defaultChartColors[validItems.length % statisticsConstants.defaultChartColors.length]),
},
selected: true,
sourceItem: item
};
finalItem.displayPercent = formatPercent(finalItem.percent, 2, '&lt;0.01');
finalItem.displayValue = this.getDisplayCurrency(finalItem.value, (finalItem.currency || this.defaultCurrency));
validItems.push(finalItem);
}
}
return validItems;
},
hasUnselectedItem: function () {
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
return true;
}
}
return false;
},
currentFirstItemPercent: function () {
let totalValue = 0;
let firstValue = null;
for (let i = 0; i < this.validItems.length; i++) {
const item = this.validItems[i];
if (this.selectedLegends && !this.selectedLegends[item.id]) {
continue;
}
if (firstValue === null) {
firstValue = item.value;
}
totalValue += item.value;
}
if (firstValue && totalValue > 0) {
return firstValue / totalValue;
} else {
return 0;
}
},
chartOptions: function () {
const self = this;
return {
tooltip: {
trigger: 'item',
backgroundColor: self.isDarkMode ? '#333' : '#fff',
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: params => {
const name = params.data ? params.data.displayName : '';
const value = params.data ? params.data.displayValue : self.getDisplayCurrency(params.value);
let percent = params.data ? params.data.displayPercent : (params.percent + '%');
if (self.hasUnselectedItem) {
percent = params.percent + '%';
}
if (name) {
return `${name}<br/>${value} (${percent})`;
} else {
return `${value} (${percent})`;
}
}
},
legend: {
orient: 'horizontal',
left: 'top',
data: self.validItems.map(item => item.name),
selected: self.selectedLegends,
textStyle: {
color: self.isDarkMode ? '#eee' : '#333'
},
formatter: id => {
return self.itemsMap[id] && self.nameField && self.itemsMap[id][self.nameField] ? self.itemsMap[id][self.nameField] : id;
}
},
series: [
{
type: 'pie',
data: self.validItems,
top: 50,
startAngle: -90 + self.currentFirstItemPercent / 2 * 360,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
}
},
label: {
color: self.isDarkMode ? '#eee' : '#333',
formatter: params => {
return params.data ? params.data.displayName : '';
}
}
}
],
media: [
{
query: {
minWidth: 600
},
option: {
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
type: 'pie',
top: 0
}
]
}
}
]
}
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
clickItem: function (e) {
if (this.enableClickItem && e.componentType === 'series' && e.seriesType ==='pie' && e.data && e.data.sourceItem) {
this.$emit('click', e.data.sourceItem);
}
},
onLegendSelectChanged: function (e) {
this.selectedLegends = e.selected;
},
getColor: function (color) {
if (color && color !== colorConstants.defaultColor) {
color = '#' + color;
}
return color;
},
getDisplayCurrency(value, currencyCode) {
return this.$locale.getDisplayCurrency(value, currencyCode, {
currencyDisplayMode: this.settingsStore.appSettings.currencyDisplayMode,
enableThousandsSeparator: this.settingsStore.appSettings.thousandsSeparator
});
}
}
}
</script>
<style scoped>
.pie-chart-container {
width: 100%;
height: 400px;
}
@media (min-width: 600px) {
.pie-chart-container {
height: 500px;
}
}
</style>