feat: Instance Activity Chart (#1141)

* feat: Instance Activity Chart

* fix: chart data handling
This commit is contained in:
pa
2025-02-18 14:32:47 +09:00
committed by GitHub
parent 42d623c932
commit 4002e944b9
21 changed files with 1145 additions and 87 deletions

View File

@@ -1,13 +1,14 @@
{
"root": true,
"extends": ["eslint:all", "prettier"],
"extends": ["eslint:all", "plugin:vue/recommended", "prettier"],
"env": {
"browser": true,
"commonjs": true,
"es2021": true
},
"parser": "@babel/eslint-parser",
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@babel/eslint-parser",
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {

259
package-lock.json generated
View File

@@ -25,12 +25,15 @@
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"dayjs": "^1.11.13",
"default-passive-events": "^2.0.0",
"echarts": "^5.6.0",
"electron": "^34.0.2",
"electron-builder": "^25.1.8",
"element-ui": "^2.15.14",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-vue": "^9.32.0",
"html-webpack-plugin": "^5.6.3",
"mini-css-extract-plugin": "^2.9.2",
"normalize.css": "^8.0.1",
@@ -1967,9 +1970,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.19.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
"integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
"version": "9.20.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz",
"integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4811,6 +4814,13 @@
"node": ">=4"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"dev": true,
"license": "MIT"
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -5164,6 +5174,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"dev": true,
"license": "0BSD"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -5439,18 +5467,18 @@
}
},
"node_modules/eslint": {
"version": "9.19.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
"integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
"version": "9.20.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz",
"integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.10.0",
"@eslint/core": "^0.11.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.19.0",
"@eslint/js": "9.20.0",
"@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -5511,6 +5539,85 @@
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-vue": {
"version": "9.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.32.0.tgz",
"integrity": "sha512-b/Y05HYmnB/32wqVcjxjHZzNpwxj1onBOvqW89W+V+XNG1dRuaFbNd3vT9CLbr2LXjEoq+3vn8DanWf7XU22Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
"natural-compare": "^1.4.0",
"nth-check": "^2.1.1",
"postcss-selector-parser": "^6.0.15",
"semver": "^7.6.3",
"vue-eslint-parser": "^9.4.3",
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"peerDependencies": {
"eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-vue/node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/eslint-plugin-vue/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-vue/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -5533,6 +5640,19 @@
"node": ">=10"
}
},
"node_modules/eslint/node_modules/@eslint/core": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz",
"integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/eslint/node_modules/eslint-scope": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz",
@@ -10331,6 +10451,102 @@
"npm": ">= 3.0.0"
}
},
"node_modules/vue-eslint-parser": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
"integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"eslint-scope": "^7.1.1",
"eslint-visitor-keys": "^3.3.0",
"espree": "^9.3.1",
"esquery": "^1.4.0",
"lodash": "^4.17.21",
"semver": "^7.3.6"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
},
"peerDependencies": {
"eslint": ">=6.0.0"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/vue-eslint-parser/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vue-hot-reload-api": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz",
@@ -10751,6 +10967,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
"integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -10887,6 +11113,23 @@
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"dev": true,
"license": "0BSD"
}
}
}

View File

@@ -41,12 +41,15 @@
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"dayjs": "^1.11.13",
"default-passive-events": "^2.0.0",
"echarts": "^5.6.0",
"electron": "^34.0.2",
"electron-builder": "^25.1.8",
"element-ui": "^2.15.14",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-vue": "^9.32.0",
"html-webpack-plugin": "^5.6.3",
"mini-css-extract-plugin": "^2.9.2",
"normalize.css": "^8.0.1",

View File

@@ -16,6 +16,10 @@ import VueLazyload from 'vue-lazyload';
import VueI18n from 'vue-i18n';
import { DataTables } from 'vue-data-tables';
import ElementUI from 'element-ui';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import * as workerTimers from 'worker-timers';
import 'default-passive-events';
@@ -33,6 +37,7 @@ import _vrcxJsonStorage from './classes/vrcxJsonStorage.js';
// tabs
import ModerationTab from './views/tabs/Moderation.vue';
import ChartsTab from './views/tabs/Charts.vue';
// components
import SimpleSwitch from './components/settings/SimpleSwitch.vue';
@@ -109,6 +114,13 @@ console.log(`isLinux: ${LINUX}`);
});
// #endregion
// #region | date utility library
// - dayjs plugin init
dayjs.extend(duration);
dayjs.extend(utc);
dayjs.extend(timezone);
// #endregion
// everything in this program is global stored in $app, I hate it, it is what it is
let $app = {};
const API = new _apiInit($app);
@@ -164,6 +176,7 @@ console.log(`isLinux: ${LINUX}`);
components: {
// tabs
ModerationTab,
ChartsTab,
// components
// - settings
@@ -173,6 +186,12 @@ console.log(`isLinux: ${LINUX}`);
// - sidebar(friendsListSidebar)
GroupsSidebar
},
provide() {
return {
API,
showUserDialog: this.showUserDialog
};
},
el: '#x-app',
async mounted() {
await this.initLanguage();
@@ -4824,7 +4843,9 @@ console.log(`isLinux: ${LINUX}`);
});
worldName = args.ref.name;
}
} catch (err) {}
} catch (e) {
throw e;
}
return worldName;
};

View File

@@ -89,7 +89,8 @@ export default class extends baseClass {
link: {
type: Boolean,
default: true
}
},
isOpenPreviousInstanceInfoDialog: Boolean
},
data() {
return {
@@ -177,7 +178,14 @@ export default class extends baseClass {
API.$emit('SHOW_WORLD_DIALOG_SHORTNAME', this.hint);
return;
}
API.$emit('SHOW_WORLD_DIALOG', instanceId);
if (this.isOpenPreviousInstanceInfoDialog) {
this.$emit(
'open-previous-instance-info-dialog',
instanceId
);
} else {
API.$emit('SHOW_WORLD_DIALOG', instanceId);
}
}
},
showGroupDialog() {

View File

@@ -40,13 +40,13 @@ export default {
return obj;
},
timeToText(sec) {
var n = Number(sec);
timeToText(sec, isNeedSeconds = false) {
let n = Number(sec);
if (isNaN(n)) {
return this.escapeTag(sec);
}
n = Math.floor(n / 1000);
var arr = [];
const arr = [];
if (n < 0) {
n = -n;
}
@@ -62,7 +62,7 @@ export default {
arr.push(`${Math.floor(n / 60)}m`);
n %= 60;
}
if (arr.length === 0 && n < 60) {
if (isNeedSeconds || (arr.length === 0 && n < 60)) {
arr.push(`${n}s`);
}
return arr.join(' ');

View File

@@ -0,0 +1,421 @@
<template>
<div>
<div class="options-container flex-between" style="margin-top: 0">
<span style="margin-top: 10px">Instance Activity</span>
<el-date-picker
v-model="selectedDate"
type="date"
:clearable="false"
align="right"
:picker-options="{
disabledDate: (time) => getDatePickerDisabledDate(time)
}"
@change="handleSelectDate"
></el-date-picker>
</div>
<div style="position: relative">
<el-statistic title="Total Online Time">
<template #formatter>
<span :style="isDarkMode ? 'color:rgb(120,120,120)' : ''">{{ totalOnlineTime }}</span>
</template>
</el-statistic>
</div>
<div ref="activityChartRef" style="width: 100%"></div>
<div v-if="!isLoading && activityData.length === 0" class="nodata">
<span>No data here, try another day</span>
</div>
<transition name="el-fade-in-linear">
<div v-show="!isLoading && activityData.length !== 0" class="divider"><el-divider>·</el-divider></div>
</transition>
<instance-activity-detail
v-for="arr in activityDetailData"
:key="arr[0].location"
ref="activityDetailChartRef"
:activity-data="activityData"
:activity-detail-data="arr"
:is-dark-mode="isDarkMode"
style="width: 100%"
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)"
/>
</div>
</template>
<script>
import dayjs from 'dayjs';
import database from '../../repository/database';
import utils from '../../classes/utils';
import InstanceActivityDetail from './InstanceActivityDetail.vue';
let echarts = null;
export default {
name: 'InstanceActivity',
components: {
InstanceActivityDetail
},
inject: ['API'],
props: {
getWorldName: Function,
isDarkMode: Boolean
},
data() {
return {
echartsInstance: null,
resizeObserver: null,
intersectionObservers: [],
selectedDate: dayjs().add(-1, 'day'),
activityData: [],
activityDetailData: [],
// previousDarkMode: this.isDarkMode,
allDateOfActivity: null,
firstDateOfActivity: null,
worldNameArray: [],
isLoading: true
};
},
computed: {
totalOnlineTime() {
return utils.timeToText(
this.activityData.reduce((acc, item) => acc + item.time, 0),
true
);
}
},
activated() {
// first time also call activated
if (!this.echartsInstance) {
return;
}
// if (this.isDarkMode === this.previousDarkMode) {
// when tab activated, play animation
this.echartsInstance.clear();
this.initEcharts();
// }
},
deactivated() {
// prevent switch tab play resize animation
this.resizeObserver.disconnect();
},
created() {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
}
});
},
async mounted() {
try {
const [echartsModule] = await Promise.all([
// lazy load echarts
// reduce the VRCX initial screen load times
// TODO: export lazy load func to a single file
import('echarts').catch((error) => {
console.error('lazy load echarts failed', error);
return null;
}),
this.getActivityData()
]);
if (echartsModule) {
echarts = echartsModule;
}
if (this.activityData.length && echarts) {
// actvity data is ready, but world name data isn't ready
// so init echarts with empty data, redcuce the render time of init screen
// TODO: move to created lifecycle, init screen faster
this.initEcharts(true);
this.getAllDateOfActivity();
this.getWorldNameData();
} else {
this.isLoading = false;
}
} catch (error) {
console.error('error in mounted', error);
}
},
methods: {
// reload data
async handleSelectDate() {
this.isLoading = true;
await this.getActivityData();
this.getWorldNameData();
},
initEcharts(isFirstTime = false) {
const chartsHeight = this.activityData.length * 40 + 200;
const chartDom = this.$refs.activityChartRef;
if (!this.echartsInstance) {
this.echartsInstance = echarts.init(chartDom, `${this.isDarkMode ? 'dark' : null}`, {
height: chartsHeight
});
this.resizeObserver.observe(chartDom);
}
this.echartsInstance.resize({
height: chartsHeight,
animation: {
duration: 300
}
});
requestAnimationFrame(() => {
this.echartsInstance.setOption(this.getNewOption(isFirstTime), { lazyUpdate: true });
this.echartsInstance.on('click', 'yAxis', this.hanleClickYAxisLable);
this.isLoading = false;
});
},
hanleClickYAxisLable(params) {
const detailDataIdx = this.activityDetailData.findIndex(
(arr) => arr[0]?.location === this.activityData[params?.dataIndex]?.location
);
if (detailDataIdx !== -1) {
this.$refs.activityDetailChartRef[detailDataIdx].$el.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
},
getDatePickerDisabledDate(time) {
if (time > Date.now() || time < this.firstDateOfActivity) {
return true;
}
return !this.allDateOfActivity.has(dayjs(time).format('YYYY-MM-DD'));
},
async getAllDateOfActivity() {
const utcDateStrings = await database.getDateOfInstanceActivity();
const uniqueDates = new Set();
this.firstDateOfActivity = dayjs.utc(utcDateStrings[0]).startOf('day');
for (const utcString of utcDateStrings) {
const formattedDate = dayjs.utc(utcString).tz().format('YYYY-MM-DD');
uniqueDates.add(formattedDate);
}
this.allDateOfActivity = uniqueDates;
},
async getActivityData() {
const localStartDate = dayjs.tz(this.selectedDate).startOf('day').toISOString();
const localEndDate = dayjs.tz(this.selectedDate).endOf('day').toISOString();
const dbData = await database.getInstanceActivity(localStartDate, localEndDate);
const transformData = (item) => ({
...item,
joinTime: dayjs(item.created_at).subtract(item.time, 'millisecond'),
leaveTime: dayjs(item.created_at),
time: item.time < 0 ? 0 : item.time
});
this.activityData = dbData.currentUserData.map(transformData);
// FIXME: some detail data missing current user activity
this.activityDetailData = Array.from(dbData.detailData.values()).map((arr) =>
arr.map(transformData).sort((a, b) => {
const timeDiff = Math.abs(a.joinTime.diff(b.joinTime, 'second'));
// recording delay, under 2s is considered the same time entry, beautify the chart
if (timeDiff < 2) {
return a.leaveTime - b.leaveTime;
}
return a.joinTime - b.joinTime;
})
);
this.$nextTick(() => {
this.handleIntersectionObserver();
});
},
handleIntersectionObserver() {
this.$refs.activityDetailChartRef.forEach((child, index) => {
const observer = new IntersectionObserver(this.handleIntersection.bind(this, index));
observer.observe(child.$el);
this.intersectionObservers[index] = observer;
});
},
handleIntersection(index, entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.$refs.activityDetailChartRef[index].initEcharts();
this.intersectionObservers[index].unobserve(entry.target);
}
});
},
async getWorldNameData() {
this.worldNameArray = await Promise.all(
this.activityData.map(async (item) => {
try {
return await this.getWorldName(item.location);
} catch {
// TODO: no notification
console.error('getWorldName failed location', item.location);
return 'Unknown world';
}
})
);
if (this.worldNameArray && this.echartsInstance) {
this.initEcharts();
}
},
getNewOption(isFirstTime) {
const getTooltip = (params) => {
const activityData = this.activityData;
const param = params[1];
if (!activityData || !activityData[param.dataIndex]) {
return '';
}
const instanceData = activityData[param.dataIndex];
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format('HH:mm:ss');
const formattedJoinDateTime = dayjs(instanceData.joinTime).format('HH:mm:ss');
const timeString = utils.timeToText(param.data, true);
const color = param.color;
const name = param.name;
const location = utils.parseLocation(instanceData.location);
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 5px;"></div>
<div>
<div>${name} #${location.instanceName} ${location.accessTypeName}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const echartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 50,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => (value.length > 20 ? `${value.slice(0, 20)}...` : value)
},
inverse: true,
data: this.worldNameArray,
triggerEvent: true
},
xAxis: {
type: 'value',
min: 0,
// axisLabel max 24:00
max: 24 * 60 * 60 * 1000,
// axisLabel interval 3hr
interval: 3 * 60 * 60 * 1000,
axisLine: { show: true },
axisLabel: {
formatter: (value) =>
value === 24 * 60 * 60 * 1000 ? '24:00' : dayjs(value).utc().format('HH:mm')
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data: isFirstTime
? []
: this.activityData.map((item, idx) => {
if (idx === 0) {
const midnight = dayjs.tz(this.selectedDate).startOf('day');
if (midnight.isAfter(item.joinTime)) {
return 0;
}
}
return item.joinTime - dayjs.tz(this.selectedDate).startOf('day');
})
},
{
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: 30,
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data: isFirstTime
? []
: this.activityData.map((item, idx) => {
// If the joinTime of the first data is on the previous day,
// and the data traverses midnight, the duration starts at midnight
if (idx === 0) {
const midnight = dayjs.tz(this.selectedDate).startOf('day');
if (midnight.isAfter(item.joinTime)) {
return item.leaveTime - dayjs.tz(midnight);
}
}
return item.time;
})
}
]
};
if (this.isDarkMode) {
echartsOption.backgroundColor = 'rgba(0, 0, 0, 0)';
}
return echartsOption;
}
}
};
</script>
<style scoped>
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.nodata {
display: flex;
align-items: center;
justify-content: center;
margin-top: 200px;
color: #5c5c5c;
}
.divider {
padding: 0 400px;
transition: top 0.3s ease;
}
.el-date-editor.el-input,
.el-date-editor.el-input__inner {
width: 200px;
}
.el-divider__text {
padding-left: 10px;
padding-right: 10px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div>
<div style="height: 25px; margin-top: 60px">
<transition name="el-fade-in-linear">
<location
v-show="!isLoading"
class="location"
:location="activityDetailData[0].location"
is-open-previous-instance-info-dialog
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)"
></location>
</transition>
</div>
<div ref="activityDetailChart"></div>
</div>
</template>
<script>
import dayjs from 'dayjs';
import utils from '../../classes/utils';
let echarts = null;
export default {
name: 'InstanceActivityDetail',
inject: ['API'],
props: {
activityDetailData: {
type: Array,
required: true
},
activityData: {
type: Array,
required: true
},
isDarkMode: {
type: Boolean,
required: true
}
},
data() {
return {
isLoading: true,
echartsInstance: null,
resizeObserver: null
};
},
computed: {
startTimeStamp() {
return this.activityData
.find((item) => item.location === this.activityDetailData[0].location)
?.joinTime.valueOf();
},
endTimeStamp() {
return this.activityData
.findLast((item) => item.location === this.activityDetailData[0].location)
?.leaveTime.valueOf();
}
},
created() {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
}
});
},
deactivated() {
// prevent switch tab play resize animation
this.resizeObserver.disconnect();
},
methods: {
async initEcharts() {
// TODO: unnecessary import, import from individual js file
await import('echarts').then((echartsModule) => {
echarts = echartsModule;
});
const chartDom = this.$refs.activityDetailChart;
if (!this.echartsInstance) {
this.echartsInstance = echarts.init(chartDom, `${this.isDarkMode ? 'dark' : null}`, {
height: this.activityDetailData.length * 40 + 200,
useDirtyRect: this.activityDetailData.length > 30
});
this.resizeObserver.observe(chartDom);
}
this.echartsInstance.resize({
height: this.activityDetailData.length * 40 + 200,
animation: {
duration: 300
}
});
this.echartsInstance.setOption(this.getNewOption(), { lazyUpdate: true });
setTimeout(() => {
this.isLoading = false;
}, 200);
},
getNewOption() {
const getTooltip = (params) => {
const activityDetailData = this.activityDetailData;
const param = params[1];
if (!activityDetailData || !activityDetailData[param.dataIndex]) {
return '';
}
const instanceData = activityDetailData[param.dataIndex];
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format('HH:mm:ss');
const formattedJoinDateTime = dayjs(instanceData.joinTime).format('HH:mm:ss');
const timeString = utils.timeToText(instanceData.time, true);
const color = param.color;
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 5px;"></div>
<div>
<div>${instanceData.display_name}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const echartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 60,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => (value.length > 20 ? `${value.slice(0, 20)}...` : value)
},
inverse: true,
data: this.activityDetailData.map((item) => item.display_name)
},
xAxis: {
type: 'value',
min: 0,
max: this.endTimeStamp - this.startTimeStamp,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value + this.startTimeStamp).format('HH:mm')
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: [
{
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data: this.activityDetailData.map((item) => item.joinTime.valueOf() - this.startTimeStamp)
},
{
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: 30,
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data: this.activityDetailData.map((item) => item.time)
}
]
};
if (this.isDarkMode) {
echartsOption.backgroundColor = 'rgba(0, 0, 0, 0)';
}
return echartsOption;
}
}
};
</script>
<style scoped>
.location {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -51,6 +51,7 @@ html
+menuitem('moderation', "{{ $t('nav_tooltip.moderation') }}", 'el-icon-finished')
+menuitem('notification', "{{ $t('nav_tooltip.notification') }}", 'el-icon-bell')
+menuitem('friendsList', "{{ $t('nav_tooltip.friend_list') }}", 'el-icon-s-management')
+menuitem('charts', "{{ $t('nav_tooltip.charts') }}", 'el-icon-data-analysis')
+menuitem('profile', "{{ $t('nav_tooltip.profile') }}", 'el-icon-user')
+menuitem('settings', "{{ $t('nav_tooltip.settings') }}", 'el-icon-s-tools')
@@ -83,10 +84,9 @@ html
//- moderation
moderation-tab(
v-if='$refs.menu?.activeIndex === "moderation"'
:Api='API'
:table-data='playerModerationTable'
:show-user-dialog='showUserDialog'
:shift-held='shiftHeld')
:shift-held='shiftHeld'
:hide-tooltips='hideTooltips')
//- notification
include ./mixins/tabs/notifications.pug
@@ -100,6 +100,14 @@ html
include ./mixins/tabs/friendsList.pug
+friendsListTab
//- charts
keep-alive
charts-tab(
v-if='$refs.menu?.activeIndex === "charts"'
:get-world-name='getWorldName'
:is-dark-mode='isDarkMode'
@open-previous-instance-info-dialog='showPreviousInstanceInfoDialog')
//- settings
include ./mixins/tabs/settings.pug
+settingsTab

View File

@@ -11,6 +11,7 @@
"moderation": "Moderation",
"notification": "Notification",
"friend_list": "Friend List",
"charts": "Charts",
"profile": "Profile",
"settings": "Settings"
},

View File

@@ -398,3 +398,36 @@ mixin openSourceSoftwareNotice
OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
div(style='margin-top: 15px')
p(style='font-weight: bold') Apache ECharts
pre(style='font-size: 12px; white-space: pre-line').
Apache License 2.0
Copyright 2017-2025 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (https://www.apache.org/).
div(style='margin-top: 15px')
p(style='font-weight: bold') dayjs
pre(style='font-size: 12px; white-space: pre-line').
MIT License
Copyright (c) 2018-present, iamkun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +1,6 @@
mixin friendsListSidebar
#aside.x-aside-container(v-show='$refs.menu && $refs.menu.activeIndex !== \'friendsList\'')
#aside.x-aside-container(
v-show='$refs.menu && !($refs.menu.activeIndex == "friendsList" || $refs.menu.activeIndex == "charts") ')
div(style='display: flex; align-items: baseline')
el-select(
v-model='quickSearch'

View File

@@ -2772,6 +2772,80 @@ class Database {
);
return instances;
}
/**
*
* @param {string} startDate: utc string of startOfDay
* @param {string} endDate: utc string endOfDay
* @returns
*/
async getInstanceActivity(startDate, endDate) {
const currentUserData = [];
const detailData = new Map();
await sqliteService.execute(
(row) => {
const rowData = {
id: row[0],
created_at: row[1],
type: row[2],
display_name: row[3],
location: row[4],
user_id: row[5],
time: row[6]
};
// skip dirty data
if (!rowData.location || rowData.location === 'traveling') {
return;
}
if (rowData.user_id === Database.userId) {
currentUserData.push(rowData);
}
const instanceData = detailData.get(rowData.location);
detailData.set(rowData.location, [
...(instanceData || []),
rowData
]);
},
`SELECT
*
FROM
gamelog_join_leave
WHERE type = "OnPlayerLeft"
AND (
strftime('%Y-%m-%dT%H:%M:%SZ', created_at, '-' || (time * 1.0 / 1000) || ' seconds') BETWEEN @utc_start_date AND @utc_end_date
OR created_at BETWEEN @utc_start_date AND @utc_end_date
);`,
{
'@utc_start_date': startDate,
'@utc_end_date': endDate
}
);
return { currentUserData, detailData };
}
/**
* Get the All Date of Instance Activity for the current user
* @returns {Promise<null>}
*/
async getDateOfInstanceActivity() {
let result = [];
await sqliteService.execute(
(row) => {
result.push(row[0]);
},
`SELECT created_at
FROM gamelog_join_leave
WHERE user_id = @userId`,
{
'@userId': Database.userId
}
);
return result;
}
}
var self = new Database();

View File

@@ -432,3 +432,10 @@ button {
padding: 2px 2px;
border-radius: 4px;
}
.el-divider {
background-color: #606266;
}
.el-divider__text {
background: #222;
color: #ffffff;
}

View File

@@ -696,3 +696,11 @@ i[class='el-icon-star-off']:not(.el-menu-item div.el-tooltip i) {
.el-color-picker__panel {
background-color: var(--dv_bg-top);
}
.el-divider {
background-color: #606266;
}
.el-divider__text {
background: var(--dv_bg-top);
color: #efefef;
padding-bottom: 2px;
}

View File

@@ -318,3 +318,10 @@ path[stroke='#e5e9f2'] {
border: transparent;
background-color: #333 !important;
}
.el-divider {
background-color: #606266;
}
.el-divider__text {
background: var(--farback);
color: #efefef;
}

View File

@@ -2012,3 +2012,10 @@ i.x-user-status {
.el-dialog__body .el-tag--mini {
line-height: 28px;
}
.el-divider {
background-color: #606266;
}
.el-divider__text {
background: rgba(var(--md-sys-color-background));
color: #efefef;
}

View File

@@ -366,3 +366,10 @@ input[type='checkbox']:checked + .el-switch__core {
border: transparent;
background-color: #333;
}
.el-divider {
background-color: #606266;
}
.el-divider__text {
background: var(--bg);
color: #efefef;
}

29
src/views/tabs/Charts.vue Normal file
View File

@@ -0,0 +1,29 @@
<template>
<div class="x-container" id="chart">
<div class="options-container" style="margin-top: 0">
<span class="header">Charts</span>
</div>
<instance-activity
:get-world-name="getWorldName"
:is-dark-mode="isDarkMode"
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)"
id="instance-activity"
></instance-activity>
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
</div>
</template>
<script>
import InstanceActivity from '../../components/charts/InstanceActivity.vue';
export default {
name: 'ChartsTab',
inject: ['API'],
props: {
getWorldName: Function,
isDarkMode: Boolean
},
components: {
InstanceActivity
}
};
</script>

View File

@@ -6,7 +6,7 @@
:filters="filters"
:tableProps="tableProps"
:paginationProps="paginationProps"
v-loading="Api.isPlayerModerationsLoading"
v-loading="API.isPlayerModerationsLoading"
>
<template slot="tool">
<div class="tool-slot">
@@ -37,48 +37,30 @@
>
<el-button
type="default"
:loading="Api.isPlayerModerationsLoading"
@click="Api.refreshPlayerModerations()"
:loading="API.isPlayerModerationsLoading"
@click="API.refreshPlayerModerations()"
icon="el-icon-refresh"
circle
/>
</el-tooltip>
</div>
</template>
<el-table-column
:label="$t('table.moderation.date')"
prop="created"
sortable="custom"
width="120"
>
<el-table-column :label="$t('table.moderation.date')" prop="created" sortable="custom" width="120">
<template slot-scope="scope">
<el-tooltip placement="right">
<template slot="content">
<span>{{
scope.row.created | formatDate('long')
}}</span>
<span>{{ scope.row.created | formatDate('long') }}</span>
</template>
<span>{{
scope.row.created | formatDate('short')
}}</span>
<span>{{ scope.row.created | formatDate('short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
:label="$t('table.moderation.type')"
prop="type"
width="100"
>
<el-table-column :label="$t('table.moderation.type')" prop="type" width="100">
<template slot-scope="scope">
<span
v-text="$t('view.moderation.filters.' + scope.row.type)"
></span>
<span v-text="$t('view.moderation.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.moderation.source')"
prop="sourceDisplayName"
>
<el-table-column :label="$t('table.moderation.source')" prop="sourceDisplayName">
<template slot-scope="scope">
<span
class="x-link"
@@ -87,10 +69,7 @@
></span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.moderation.target')"
prop="targetDisplayName"
>
<el-table-column :label="$t('table.moderation.target')" prop="targetDisplayName">
<template slot-scope="scope">
<span
class="x-link"
@@ -99,15 +78,9 @@
></span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.moderation.action')"
width="80"
align="right"
>
<el-table-column :label="$t('table.moderation.action')" width="80" align="right">
<template slot-scope="scope">
<template
v-if="scope.row.sourceUserId === Api.currentUser.id"
>
<template v-if="scope.row.sourceUserId === API.currentUser.id">
<el-button
v-if="shiftHeld"
style="color: #f56c6c"
@@ -135,18 +108,15 @@
export default {
name: 'ModerationTab',
inject: ['API', 'showUserDialog'],
props: {
Api: Object,
tableData: Object,
showUserDialog: Function,
shiftHeld: Boolean
shiftHeld: Boolean,
hideTooltips: Boolean
},
created: async function () {
this.filters[0].value = JSON.parse(
await configRepository.getString(
'VRCX_playerModerationTableFilters',
'[]'
)
await configRepository.getString('VRCX_playerModerationTableFilters', '[]')
);
},
data() {
@@ -155,8 +125,7 @@
{
prop: 'type',
value: [],
filterFn: (row, filter) =>
filter.value.some((v) => v === row.type)
filterFn: (row, filter) => filter.value.some((v) => v === row.type)
},
{
prop: ['sourceDisplayName', 'targetDisplayName'],
@@ -191,32 +160,25 @@
},
methods: {
saveTableFilters() {
configRepository.setString(
'VRCX_playerModerationTableFilters',
JSON.stringify(this.filters[0].value)
);
configRepository.setString('VRCX_playerModerationTableFilters', JSON.stringify(this.filters[0].value));
},
deletePlayerModeration(row) {
this.Api.deletePlayerModeration({
this.API.deletePlayerModeration({
moderated: row.targetUserId,
type: row.type
});
},
deletePlayerModerationPrompt(row) {
this.$confirm(
`Continue? Delete Moderation ${row.type}`,
'Confirm',
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
this.deletePlayerModeration(row);
}
this.$confirm(`Continue? Delete Moderation ${row.type}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
this.deletePlayerModeration(row);
}
}
);
});
}
}
};

View File

@@ -13,7 +13,8 @@ module.exports = {
'noty',
'vue',
'vue-data-tables',
'vue-lazyload'
'vue-lazyload',
'dayjs'
],
app: {
import: ['./src/app.js', './src/app.scss'],