diff --git a/package.json b/package.json index ba88d5f03..f8dcac325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "1.6.0", + "version": "1.7.0", "description": "Web timer", "homepage": "https://github.com/sheepzh/timer", "scripts": { @@ -15,49 +15,52 @@ "author": "zhy", "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.19.2", - "@types/chrome": "0.0.205", + "@crowdin/crowdin-api-client": "^1.22.1", + "@types/chrome": "0.0.224", "@types/copy-webpack-plugin": "^8.0.1", "@types/echarts": "^4.9.16", "@types/generate-json-webpack-plugin": "^0.3.4", - "@types/jest": "^29.2.4", - "@types/node": "^18.11.17", + "@types/jest": "^29.5.0", + "@types/node": "^18.15.3", "@types/psl": "^1.1.0", "@types/webpack": "^5.28.0", "@types/webpack-bundle-analyzer": "^4.6.0", - "babel-loader": "^9.1.0", + "babel-loader": "^9.1.2", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.7.3", - "eslint": "^8.30.0", + "eslint": "^8.36.0", "filemanager-webpack-plugin": "^8.0.0", "generate-json-webpack-plugin": "^2.0.0", - "jest": "^29.3.1", - "jest-environment-jsdom": "^29.3.1", - "mini-css-extract-plugin": "^2.7.2", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "mini-css-extract-plugin": "^2.7.5", "node-sass": "^8.0.0", - "sass-loader": "^13.2.0", - "style-loader": "^3.3.1", - "ts-jest": "^29.0.3", + "sass-loader": "^13.2.1", + "style-loader": "^3.3.2", + "ts-jest": "^29.0.5", "ts-loader": "^9.4.2", "ts-node": "^10.9.1", - "tsconfig-paths": "^4.1.1", - "tslib": "^2.4.1", - "typescript": "4.9.4", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.5.0", + "typescript": "5.0.2", "url-loader": "^4.1.1", - "webpack": "^5.75.0", - "webpack-bundle-analyzer": "^4.7.0", + "webpack": "^5.76.2", + "webpack-bundle-analyzer": "^4.8.0", "webpack-cli": "^5.0.1" }, "dependencies": { - "@element-plus/icons-vue": "^2.0.10", - "axios": "^1.2.1", + "@element-plus/icons-vue": "^2.1.0", + "axios": "^1.3.4", "clipboardy": "^3.0.0", - "countup.js": "^2.3.2", + "countup.js": "^2.6.0", "echarts": "^5.4.1", - "element-plus": "2.2.27", + "element-plus": "2.3.1", "psl": "^1.9.0", "stream-browserify": "^3.0.0", - "vue": "^3.2.45", + "vue": "^3.2.47", "vue-router": "^4.1.6" + }, + "engines": { + "node": ">=16" } } \ No newline at end of file diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts index 40de1ae9d..08b9ebf3d 100644 --- a/script/crowdin/sync-translation.ts +++ b/script/crowdin/sync-translation.ts @@ -6,6 +6,7 @@ import { SourceFilesModel } from "@crowdin/crowdin-api-client" import { groupBy } from "@util/array" async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise { + console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`) const strings = await client.listStringsByFile(file.id) const stringMap = groupBy(strings, s => s.identifier, l => l[0]) for (const [identifier, text] of Object.entries(message)) { @@ -42,10 +43,11 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo console.log(`find ${files.length} files of ${dir}`) const fileMap = groupBy(files, f => f.name, l => l[0]) for (const [tsFilename, message] of Object.entries(messages)) { + console.log(`Start to sync translations of ${dir}/${tsFilename}`) if (isIgnored(dir, tsFilename)) { + console.log("Ignored file: " + tsFilename) continue } - console.log(`Start to sync translations of ${tsFilename}`) const filename = tsFilename.replace('.ts', '.json') const crowdinFile = fileMap[filename] if (!crowdinFile) { @@ -56,7 +58,7 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo for (const locale of ALL_TRANS_LOCALES) { const translated = message[locale] if (!translated || !Object.keys(translated).length) { - return + continue } const strings = transMsg(message[locale]) const crwodinLang = crowdinLangOf(locale) @@ -69,6 +71,9 @@ async function main() { const client = getClientFromEnv() const branch = await checkMainBranch(client) + for (let i = 0; i < ALL_DIRS.length; i++) { + + } for (const dir of ALL_DIRS) { await processDir(client, dir, branch) } diff --git a/src/app/components/analysis/components/common/indicator.ts b/src/app/components/analysis/components/common/indicator.ts new file mode 100644 index 000000000..f40fc10fb --- /dev/null +++ b/src/app/components/analysis/components/common/indicator.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { I18nKey } from "@app/locale" +import type { PropType, VNode } from "vue" + +import { tN } from "@app/locale" +import { defineComponent, h } from "vue" + +export type IndicatorProps = { + mainName: string + mainValue: string + subTips?: I18nKey + subValue?: string +} + +function renderChildren(props: IndicatorProps): VNode[] { + const { mainName, subTips, mainValue, subValue } = props + const children = [ + h('div', { class: 'indicator-name' }, mainName), + h('div', { class: 'indicator-value' }, mainValue || '-'), + ] + const subTipsLine = [] + if (subTips || subValue) { + const subValueSpan = h('span', { class: 'indicator-sub-value' }, subValue || '-') + if (subTips) { + subTipsLine.push(...tN(subTips, { value: subValueSpan })) + } else { + subTipsLine.push(subValueSpan) + } + } else { + subTipsLine.push('') + } + children.push(h('div', { class: 'indicator-sub-tip' }, subTipsLine)) + return children +} + +const _default = defineComponent({ + props: { + mainName: String, + mainValue: String, + subTips: Function as PropType, + subValue: String, + }, + setup(props) { + return () => h('div', { class: 'analysis-indicator-container' }, renderChildren(props)) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/common/row-card.ts b/src/app/components/analysis/components/common/row-card.ts new file mode 100644 index 000000000..d8617f872 --- /dev/null +++ b/src/app/components/analysis/components/common/row-card.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElCard } from "element-plus" +import { defineComponent, h } from "vue" + +const _default = defineComponent({ + props: { + title: String + }, + setup(props, ctx) { + const slots = ctx.slots + const { default: default_ } = slots + return () => { + const title = h('div', { class: 'analysis-row-title' }, props.title) + return h(ElCard, { class: 'analysis-row-card' }, () => [title, h(default_, { class: 'analysis-row-body' })]) + } + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/filter.ts b/src/app/components/analysis/components/filter.ts new file mode 100644 index 000000000..3d2db2453 --- /dev/null +++ b/src/app/components/analysis/components/filter.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { Ref, PropType, VNode } from "vue" + +import { ElOption, ElSelect, ElTag } from "element-plus" +import { ref, h, defineComponent } from "vue" +import statService, { HostSet } from "@service/stat-service" +import { t } from "@app/locale" +import SelectFilterItem from "@app/components/common/select-filter-item" +import { labelOfHostInfo } from "../util" + +async function handleRemoteSearch(queryStr: string, trendDomainOptions: Ref, searching: Ref) { + if (!queryStr) { + trendDomainOptions.value = [] + return + } + searching.value = true + const domains: HostSet = await statService.listHosts(queryStr) + const options: timer.site.SiteKey[] = [] + const { origin, merged, virtual } = domains + origin.forEach(host => options.push({ host })) + merged.forEach(host => options.push({ host, merged: true })) + virtual.forEach(host => options.push({ host, virtual: true })) + trendDomainOptions.value = options + searching.value = false +} + +const HOST_PLACEHOLDER = t(msg => msg.analysis.common.hostPlaceholder) + +const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { + default: t(msg => msg.timeFormat.default), + second: t(msg => msg.timeFormat.second), + minute: t(msg => msg.timeFormat.minute), + hour: t(msg => msg.timeFormat.hour) +} + +function keyOfHostInfo(option: timer.site.SiteKey): string { + const { merged, virtual, host } = option + let prefix = '_' + merged && (prefix = 'm') + virtual && (prefix = 'v') + return `${prefix}${host || ''}` +} + +function hostInfoOfKey(key: string): timer.site.SiteKey { + if (!key?.length) return undefined + const prefix = key.charAt(0) + return { host: key.substring(1), merged: prefix === 'm', virtual: prefix === 'v' } +} + +const MERGED_TAG_TXT = t(msg => msg.analysis.common.merged) +const VIRTUAL_TAG_TXT = t(msg => msg.analysis.common.virtual) +function renderHostLabel(hostInfo: timer.site.SiteKey): VNode[] { + const result = [ + h('span', {}, hostInfo.host) + ] + hostInfo.merged && result.push( + h(ElTag, { size: 'small' }, () => MERGED_TAG_TXT) + ) + hostInfo.virtual && result.push( + h(ElTag, { size: 'small' }, () => VIRTUAL_TAG_TXT) + ) + return result +} + +const _default = defineComponent({ + name: "TrendFilter", + props: { + site: Object as PropType, + timeFormat: String as PropType + }, + emits: { + siteChange: (_site: timer.site.SiteKey) => true, + timeFormatChange: (_format: timer.app.TimeFormat) => true, + }, + setup(props, ctx) { + const domainKey: Ref = ref('') + const trendSearching: Ref = ref(false) + const trendDomainOptions: Ref = ref([]) + const defaultSite: timer.site.SiteKey = props.site + const timeFormat: Ref = ref(props.timeFormat) + if (defaultSite) { + domainKey.value = keyOfHostInfo(defaultSite) + trendDomainOptions.value.push(defaultSite) + } + + function handleSiteChange() { + const siteInfo: timer.site.SiteInfo = hostInfoOfKey(domainKey.value) + ctx.emit('siteChange', siteInfo) + } + + return () => [ + h(ElSelect, { + placeholder: HOST_PLACEHOLDER, + class: 'filter-item', + modelValue: domainKey.value, + filterable: true, + remote: true, + loading: trendSearching.value, + clearable: true, + remoteMethod: (query: string) => handleRemoteSearch(query, trendDomainOptions, trendSearching), + onChange: (key: string) => { + domainKey.value = key + handleSiteChange() + }, + onClear: () => { + domainKey.value = undefined + handleSiteChange() + } + }, () => (trendDomainOptions.value || [])?.map( + hostInfo => h(ElOption, { + value: keyOfHostInfo(hostInfo), + label: labelOfHostInfo(hostInfo), + }, () => renderHostLabel(hostInfo)) + )), + h(SelectFilterItem, { + historyName: 'timeFormat', + defaultValue: timeFormat.value, + options: TIME_FORMAT_LABELS, + onSelect: (newVal: timer.app.TimeFormat) => ctx.emit('timeFormatChange', timeFormat.value = newVal) + }) + ] + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/summary/index.ts b/src/app/components/analysis/components/summary/index.ts new file mode 100644 index 000000000..6ad21f20c --- /dev/null +++ b/src/app/components/analysis/components/summary/index.ts @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import type { PropType, Ref, VNode } from "vue" + +import { defineComponent, h, ref, watch } from "vue" +import siteService from "@service/site-service" +import Site from "./site" +import RowCard from "../common/row-card" +import Indicator from "../common/indicator" +import "./summary.sass" +import { ElCol, ElRow } from "element-plus" +import { t } from "@app/locale" +import { cvt2LocaleTime, periodFormatter } from "@app/util/time" + +type Summary = { + focus: number + visit: number + day: number + firstDay?: string +} + +function computeSummary(site: timer.site.SiteKey, rows: timer.stat.Row[]): Summary { + if (!site) return undefined + + const summary: Summary = { focus: 0, visit: 0, day: 0 } + summary.firstDay = rows?.[0]?.date + rows.forEach(({ focus, time: visit }) => { + summary.focus += focus + summary.visit += visit + focus && (summary.day += 1) + }) + return summary +} + +const DAYS_LABEL = t(msg => msg.analysis.summary.day) +const FOCUS_LABEL = t(msg => msg.analysis.common.focusTotal) +const VISIT_LABEL = t(msg => msg.analysis.common.visitTotal) + +function renderContent(siteInfo: timer.site.SiteInfo, summary: Summary, timeFormat: timer.app.TimeFormat): VNode { + const { day, firstDay, focus, visit } = summary || {} + return h(ElRow, { class: "analysis-summary-container" }, () => [ + h(ElCol, { span: 6 }, () => h(Site, { site: siteInfo })), + h(ElCol, { span: 6 }, () => h(Indicator, { + mainName: DAYS_LABEL, + mainValue: day?.toString() || '-', + subTips: msg => msg.analysis.summary.firstDay, + subValue: firstDay ? `@${cvt2LocaleTime(firstDay)}` : '' + })), + h(ElCol, { span: 6 }, () => h(Indicator, { + mainName: FOCUS_LABEL, + mainValue: focus === undefined ? '-' : periodFormatter(focus, timeFormat, false), + })), + h(ElCol, { span: 6 }, () => h(Indicator, { + mainName: VISIT_LABEL, + mainValue: visit?.toString() || '-', + })), + ]) +} + +const _default = defineComponent({ + props: { + site: Object as PropType, + timeFormat: String as PropType, + rows: Array as PropType, + }, + setup(props) { + const siteInfo: Ref = ref() + const timeFormat: Ref = ref(props.timeFormat) + const summaryInfo: Ref = ref(computeSummary(props.site, props.rows)) + + const querySiteInfo = async () => { + const siteKey = props.site + if (!siteKey) { + siteInfo.value = undefined + } else { + siteInfo.value = (await siteService.get(siteKey)) || siteKey + } + } + + watch(() => props.timeFormat, () => timeFormat.value = props.timeFormat) + watch(() => props.site, querySiteInfo) + watch(() => props.rows, () => summaryInfo.value = computeSummary(props.site, props.rows)) + + querySiteInfo() + + return () => h(RowCard, { + title: t(msg => msg.analysis.summary.title) + }, () => renderContent(siteInfo.value, summaryInfo.value, timeFormat.value)) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/summary/site.ts b/src/app/components/analysis/components/summary/site.ts new file mode 100644 index 000000000..86fd3c8c4 --- /dev/null +++ b/src/app/components/analysis/components/summary/site.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { PropType, defineComponent, h } from "vue" +import { labelOfHostInfo } from "../../util" +import { t } from "@app/locale" + +const renderIcon = (iconUrl: string) => h('img', { src: iconUrl, width: 24, height: 24 }) +const renderTitle = (title: string) => h('h1', { class: 'site-alias' }, title) +const renderSubtitle = (subtitle: string) => h('p', { class: 'site-host' }, subtitle) + +const EMPTY_DESC = t(msg => msg.analysis.common.emptyDesc) + +function renderChildren(site: timer.site.SiteInfo) { + if (!site) { + return renderTitle(EMPTY_DESC) + } + const result = [] + + const { iconUrl, alias } = site + const label = labelOfHostInfo(site) + const title: string = alias ? alias : label + const subtitle: string = alias ? label : undefined + + iconUrl && result.push(renderIcon(iconUrl)) + result.push(renderTitle(title)) + subtitle && result.push(renderSubtitle(subtitle)) + return result +} + +const _default = defineComponent({ + props: { + site: Object as PropType, + }, + setup(props) { + return () => h('div', { class: 'site-container' }, renderChildren(props.site)) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/summary/summary.sass b/src/app/components/analysis/components/summary/summary.sass new file mode 100644 index 000000000..238b3a0b3 --- /dev/null +++ b/src/app/components/analysis/components/summary/summary.sass @@ -0,0 +1,22 @@ + +.analysis-summary-container + height: 140px + >.el-col:not(:first-child) + border-left: 1px var(--el-border-color) var(--el-border-style) + .site-container + position: relative + top: 50% + transform: translateY(-50%) + text-align: center + padding: 0 25px + .site-alias + font-size: 26px + margin-block-start: 0.2em + margin-block-end: 0.5em + .site-host + font-size: 14px + color: var(--el-text-color-secondary) + .site-host,.site-alias + white-space: nowrap + overflow: hidden + text-overflow: ellipsis diff --git a/src/app/components/analysis/components/trend/dimension/chart.ts b/src/app/components/analysis/components/trend/dimension/chart.ts new file mode 100644 index 000000000..5017b5672 --- /dev/null +++ b/src/app/components/analysis/components/trend/dimension/chart.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { PropType, Ref } from "vue" +import type { DimensionEntry, ValueFormatter } from "@app/components/analysis/util" + +import { defineComponent, h, watch, onMounted, ref } from "vue" +import ChartWrapper from "./wrapper" + +const _default = defineComponent({ + props: { + data: Array as PropType, + title: String, + valueFormatter: Function as PropType + }, + setup(props) { + const elRef: Ref = ref() + const wrapper: ChartWrapper = new ChartWrapper() + const render = () => wrapper.render(props.data, props.title, props.valueFormatter) + + watch(() => props.data, render) + watch(() => props.valueFormatter, render) + + onMounted(() => { + wrapper.init(elRef.value) + render() + }) + + return () => h('div', { class: 'analysis-trend-dimension-chart', ref: elRef }) + } +}) + +export default _default diff --git a/src/app/components/analysis/components/trend/dimension/index.ts b/src/app/components/analysis/components/trend/dimension/index.ts new file mode 100644 index 000000000..984137ae6 --- /dev/null +++ b/src/app/components/analysis/components/trend/dimension/index.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { DimensionEntry, RingValue, ValueFormatter } from "@app/components/analysis/util" +import type { PropType } from "vue" + +import { computeRingText, formatValue } from "@app/components/analysis/util" +import { defineComponent, h } from "vue" +import Indicator from "../../common/indicator" +import Chart from "./chart" +import { cvt2LocaleTime } from "@app/util/time" + +type RenderProps = { + maxLabel: string + maxValue: number + averageLabel: string + average: RingValue + maxDate: string + valueFormatter: ValueFormatter +} + +const renderMax = ({ maxLabel, maxValue, valueFormatter: formatter, maxDate }: RenderProps) => + h('div', { class: 'analysis-trend-dimension-indicator-item' }, h(Indicator, { + mainName: maxLabel, + mainValue: formatter ? formatter(maxValue) : maxValue?.toString() || '-', + subValue: maxDate ? `@${cvt2LocaleTime(maxDate)}` : '', + })) + +const renderAverage = ({ averageLabel, valueFormatter, average }: RenderProps) => { + const currentAverage = average?.[0] + return h('div', { class: 'analysis-trend-dimension-indicator-item' }, h(Indicator, { + mainName: averageLabel, + mainValue: formatValue(currentAverage, valueFormatter), + subTips: msg => msg.analysis.common.ringGrowth, + subValue: computeRingText(average, valueFormatter), + })) +} + + +const _default = defineComponent({ + props: { + maxLabel: String, + maxValue: Number, + averageLabel: String, + averageValue: String, + maxDate: String, + average: [Object, Object] as PropType, + data: Array as PropType, + valueFormatter: Function as PropType, + dateRange: [Object, Object] as PropType<[Date, Date]>, + chartTitle: String, + }, + setup(props) { + return () => h('div', { class: "analysis-trend-dimension-container" }, [ + h('div', { class: 'analysis-trend-dimension-indicator-container' }, [ + renderMax(props), + renderAverage(props), + ]), + h('div', { class: 'analysis-trend-dimension-chart-container' }, + h(Chart, { data: props.data, valueFormatter: props.valueFormatter, title: props.chartTitle }) + ), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/trend/dimension/wrapper.ts b/src/app/components/analysis/components/trend/dimension/wrapper.ts new file mode 100644 index 000000000..ee8d75307 --- /dev/null +++ b/src/app/components/analysis/components/trend/dimension/wrapper.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { DimensionEntry } from "../../../util" +import type { ECharts, ComposeOption } from "echarts/core" +import type { LineSeriesOption } from "echarts/charts" +import type { + TitleComponentOption, + TooltipComponentOption, + ToolboxComponentOption, + GridComponentOption, +} from "echarts/components" + +import { init, use } from "@echarts/core" +import LineChart from "@echarts/chart/line" +import SVGRenderer from "@echarts/svg-renderer" +import TitleComponent from "@echarts/component/title" +import TooltipComponent from "@echarts/component/tooltip" +import GridComponent from '@echarts/component/grid' + +import { ValueFormatter } from "@app/components/analysis/util" +import { getSecondaryTextColor } from "@util/style" + +use([ + LineChart, + TitleComponent, + TooltipComponent, + GridComponent, + SVGRenderer, +]) + +type EcOption = ComposeOption< + | LineSeriesOption + | TitleComponentOption + | ToolboxComponentOption + | TooltipComponentOption + | GridComponentOption +> + +class ChartWrapper { + instance: ECharts + + init(container: HTMLDivElement) { + this.instance = init(container) + } + + async render(entries: DimensionEntry[], title: string, valueFormatter: ValueFormatter) { + const xAxis = entries.map(r => r.date) + const yAxis = entries.map(r => r.value) + + const secondaryTextColor = getSecondaryTextColor() + const option: EcOption = { + backgroundColor: 'rgba(0,0,0,0)', + title: { + text: title, + textStyle: { + color: secondaryTextColor, + fontSize: '14px', + fontWeight: 'normal', + }, + left: 'center', + top: '9%', + }, + grid: { + top: '30%', + bottom: '10px', + left: '5%', + right: '5%', + }, + tooltip: { + trigger: 'axis', + formatter(params: any) { + const format = params instanceof Array ? params[0] : params + const { name, value } = format + const valStr = valueFormatter?.(value) || value?.toString() || "NaN" + return `${name}
${valStr}` + }, + }, + xAxis: { + type: 'category', + data: xAxis, + show: false, + }, + yAxis: { + type: 'value', + axisLabel: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + series: { + data: yAxis, + type: 'line', + symbol: 'none', + areaStyle: {}, + smooth: true, + } + } + this.instance?.setOption(option) + } +} + +export default ChartWrapper diff --git a/src/app/components/analysis/components/trend/filter.ts b/src/app/components/analysis/components/trend/filter.ts new file mode 100644 index 000000000..ceb940422 --- /dev/null +++ b/src/app/components/analysis/components/trend/filter.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { ElementDatePickerShortcut } from "@src/element-ui/date" +import type { PropType, Ref } from "vue" + +import { t } from "@app/locale" +import { AnalysisMessage } from "@i18n/message/app/analysis" +import { ElDatePicker } from "element-plus" +import { defineComponent, h, ref } from "vue" +import { daysAgo } from "@util/time" + +function datePickerShortcut(msgKey: keyof AnalysisMessage['trend'], agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { + return { + text: t(msg => msg.analysis.trend[msgKey]), + value: daysAgo(agoOfStart - 1 || 0, agoOfEnd || 0) + } +} + +const SHORTCUTS = [ + datePickerShortcut('lastWeek', 7), + datePickerShortcut('last15Days', 15), + datePickerShortcut('last30Days', 30), + datePickerShortcut("last90Days", 90) +] + +const _default = defineComponent({ + props: { + dateRange: [Object, Object] as PropType<[Date, Date]> + }, + emits: { + dateRangeChange: (_val: [Date, Date]) => true + }, + setup(props, ctx) { + const dateFormat = t(msg => msg.calendar.dateFormat, { + y: 'YYYY', + m: 'MM', + d: 'DD' + }) + const dateRange: Ref<[Date, Date]> = ref(props.dateRange) + return () => h('div', { class: 'analysis-trend-filter' }, [ + h(ElDatePicker, { + modelValue: dateRange.value, + disabledDate: (date: Date) => date.getTime() > new Date().getTime(), + format: dateFormat, + type: 'daterange', + shortcuts: SHORTCUTS, + rangeSeparator: '-', + clearable: false, + 'onUpdate:modelValue': (newVal: [Date, Date]) => ctx.emit("dateRangeChange", dateRange.value = newVal), + }) + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/trend/index.ts b/src/app/components/analysis/components/trend/index.ts new file mode 100644 index 000000000..ba1b3e0a3 --- /dev/null +++ b/src/app/components/analysis/components/trend/index.ts @@ -0,0 +1,227 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import type { DimensionEntry, ValueFormatter } from "@app/components/analysis/util" +import type { PropType, Ref, ComputedRef } from "vue" + +import { defineComponent, h, ref, watch, computed } from "vue" +import RowCard from "../common/row-card" +import Filter from "./filter" +import Total from "./total" +import Dimension from "./dimension" +import { t } from "@app/locale" +import './style.sass' +import { MILL_PER_DAY, daysAgo, getAllDatesBetween, getDayLenth } from "@util/time" +import { ElRow } from "element-plus" +import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { groupBy } from "@util/array" + +type DailyIndicator = { + value: number + date: string +} + +type GlobalIndicator = number + +type DimensionType = 'focus' | 'visit' + +type IndicatorSet = Record & { + activeDay: number +} + +type SourceParam = { + dateRange: [Date, Date] + rows?: timer.stat.Row[] +} + +type EffectParam = { + indicators: Ref + lastIndicators: Ref + focusData: Ref + visitData: Ref +} + +const VISIT_MAX = t(msg => msg.analysis.trend.maxVisit) +const VISIT_AVE = t(msg => msg.analysis.trend.averageVisit) +const VISIT_CHART_TITLE = t(msg => msg.analysis.trend.visitTitle) +const FOCUS_MAX = t(msg => msg.analysis.trend.maxFocus) +const FOCUS_AVE = t(msg => msg.analysis.trend.averageFocus) +const FOCUS_CHART_TITLE = t(msg => msg.analysis.trend.focusTitle) + +function computeIndicatorSet(rows: timer.stat.Row[], dateRange: [Date, Date]): [IndicatorSet, Record] { + const [start, end] = dateRange || [] + const allDates = start && end ? getAllDatesBetween(start, end) : [] + if (!rows) { + // No data + return [undefined, groupBy(allDates, date => date, _l => undefined)] + } + + const days = allDates.length + const periodRows = rows.filter(({ date }) => allDates.includes(date)) + const periodRowMap = groupBy(periodRows, r => r.date, a => a[0]) + let focusMax: DailyIndicator + let visitMax: DailyIndicator + let focusTotal: number, visitTotal: number, activeDay: number + focusMax = visitMax = { date: undefined, value: undefined } + activeDay = focusTotal = visitTotal = 0 + + const fullPeriodRow: Record = {} + allDates.forEach(date => { + const row = periodRowMap[date] + if (!(fullPeriodRow[date] = row)) return + const { focus, time: visit } = row + focus > (focusMax.value ?? Number.MIN_SAFE_INTEGER) && (focusMax = { value: focus, date }) + visit > (visitMax.value ?? Number.MIN_SAFE_INTEGER) && (visitMax = { value: visit, date }) + focusTotal += focus + visitTotal += visit + focus && (activeDay += 1) + }) + + const indicators: IndicatorSet = { + activeDay, + focus: { max: focusMax, total: focusTotal, average: days == 0 ? undefined : focusTotal / days }, + visit: { max: visitMax, total: visitTotal, average: days == 0 ? undefined : visitTotal / days }, + } + return [indicators, fullPeriodRow] +} + +function lastRange(dateRange: [Date, Date]): [Date, Date] { + const [start, end] = dateRange || [] + if (!start || !end) return undefined + const dayLength = getDayLenth(start, end) + const newEnd = new Date(start.getTime() - MILL_PER_DAY) + const newStart = new Date(start.getTime() - MILL_PER_DAY * dayLength) + return [newStart, newEnd] +} + +const visitFormatter: ValueFormatter = (val: number) => { + if (Number.isInteger(val)) { + return val.toString() + } else { + return val?.toFixed(1) || '-' + } +} + +function handleDataChange(source: SourceParam, effect: EffectParam) { + const { dateRange, rows } = source + const { indicators, lastIndicators, focusData, visitData } = effect + // 1. this period + const [newIndicators, periodRows] = computeIndicatorSet(rows, dateRange) + indicators.value = newIndicators + const newFocusData: DimensionEntry[] = [] + const newVisitData: DimensionEntry[] = [] + Object.entries(periodRows) + .forEach(([rowDate, row]) => { + const { time, focus } = row || {} + const date = cvt2LocaleTime(rowDate) + newFocusData.push({ date, value: focus || 0 }) + newVisitData.push({ date, value: time || 0 }) + }) + focusData.value = newFocusData + visitData.value = newVisitData + // 2. last period + lastIndicators.value = computeIndicatorSet(rows, lastRange(dateRange))[0] +} + +const renderTotal = ( + indicators: IndicatorSet, + lastIndicators: IndicatorSet, + timeFormat: timer.app.TimeFormat, + rangeLength: number, +) => h('div', + { class: 'analysis-trend-content-col0' }, + h(Total, { + activeDay: [indicators?.activeDay, lastIndicators?.activeDay], + rangeLength: rangeLength, + visit: [indicators?.visit?.total, lastIndicators?.visit?.total], + focus: [indicators?.focus?.total, lastIndicators?.focus?.total], + timeFormat, + }) +) + +const renderFocusTrend = ( + indicators: IndicatorSet, + lastIndicators: IndicatorSet, + timeFormat: timer.app.TimeFormat, + data: DimensionEntry[], +) => h('div', + { class: 'analysis-trend-content-col1' }, + h(Dimension, { + maxLabel: FOCUS_MAX, + maxValue: indicators?.focus?.max?.value, + maxDate: indicators?.focus?.max?.date, + averageLabel: FOCUS_AVE, + average: [indicators?.focus?.average, lastIndicators?.focus?.average], + valueFormatter: (val: number) => val === undefined ? '-' : periodFormatter(val, timeFormat), + data, + chartTitle: FOCUS_CHART_TITLE, + }) +) + +const renderVisitTrend = ( + indicators: IndicatorSet, + lastIndicators: IndicatorSet, + data: DimensionEntry[], +) => h('div', + { class: 'analysis-trend-content-col2' }, + h(Dimension, { + maxLabel: VISIT_MAX, + maxValue: indicators?.visit?.max?.value, + maxDate: indicators?.visit?.max?.date, + averageLabel: VISIT_AVE, + average: [indicators?.visit?.average, lastIndicators?.visit?.average], + valueFormatter: visitFormatter, + data, + chartTitle: VISIT_CHART_TITLE, + }) +) + +const _default = defineComponent({ + props: { + rows: Array as PropType, + timeFormat: String as PropType, + }, + setup(props) { + const dateRange: Ref<[Date, Date]> = ref(daysAgo(29, 0)) + const visitData: Ref = ref([]) + const focusData: Ref = ref([]) + const indicators: Ref = ref() + const lastIndicators: Ref = ref() + const timeFormat: Ref = ref(props.timeFormat) + const rangeLength: ComputedRef = computed(() => getDayLenth(dateRange.value?.[0], dateRange.value?.[1])) + + const compute = () => handleDataChange( + { dateRange: dateRange.value, rows: props.rows }, + { indicators, lastIndicators, visitData, focusData } + ) + + watch(() => props.rows, compute) + watch(dateRange, compute) + watch(() => props.timeFormat, () => timeFormat.value = props.timeFormat) + + compute() + return () => h(RowCard, { + title: t(msg => msg.analysis.trend.title), + class: 'analysis-trend-container', + }, () => [ + h(Filter, { + dateRange: dateRange.value, + onDateRangeChange: (newVal: [Date, Date]) => dateRange.value = newVal, + }), + h(ElRow, { class: 'analysis-trend-content' }, () => [ + renderTotal(indicators.value, lastIndicators.value, timeFormat.value, rangeLength.value), + renderFocusTrend(indicators.value, lastIndicators.value, timeFormat.value, focusData.value), + renderVisitTrend(indicators.value, lastIndicators.value, visitData.value), + ]) + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/components/trend/style.sass b/src/app/components/analysis/components/trend/style.sass new file mode 100644 index 000000000..18b95eef7 --- /dev/null +++ b/src/app/components/analysis/components/trend/style.sass @@ -0,0 +1,43 @@ +$divider: 1px var(--el-border-color) var(--el-border-style) + +.analysis-trend-container + .analysis-trend-filter + padding-top: 10px + padding-bottom: 14px + border-bottom: $divider + .analysis-trend-content + height: 380px + display: flex + .analysis-trend-content-col0 + flex: 1 1 0 + .analysis-trend-content-col1,.analysis-trend-content-col2 + flex: 2 1 0 + +.analysis-trend-total-container + display: flex + flex-direction: column + height: 100% + >div + width: 100% + flex: 1 + >div:not(:first-child) + border-top: $divider +.analysis-trend-dimension-container + display: flex + height: 100% + flex-direction: column + border-left: $divider + .analysis-trend-dimension-indicator-container + display: flex + flex: 1 1 0 + border-bottom: $divider + .analysis-trend-dimension-indicator-item + flex: 1 0 0 + >.analysis-trend-dimension-indicator-item:first-child + border-right: $divider + .analysis-trend-dimension-chart-container + display: flex + flex: 2 1 0 + .analysis-trend-dimension-chart + width: 100% + height: 100% diff --git a/src/app/components/analysis/components/trend/total.ts b/src/app/components/analysis/components/trend/total.ts new file mode 100644 index 000000000..1bf2ee4e0 --- /dev/null +++ b/src/app/components/analysis/components/trend/total.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { RingValue } from "@app/components/analysis/util" +import type { PropType } from "vue" +import type { I18nKey } from "@app/locale" +import type { IndicatorProps } from "../common/indicator" + +import { defineComponent, h } from "vue" +import Indicator from "../common/indicator" +import { t } from "@app/locale" +import { periodFormatter } from "@app/util/time" +import { computeRingText } from "@app/components/analysis/util" + +type Props = { + activeDay: RingValue + rangeLength: number + visit: RingValue + focus: RingValue + timeFormat: timer.app.TimeFormat +} + +const DAY_LABEL = `${t(msg => msg.analysis.trend.activeDay)}/${t(msg => msg.analysis.trend.totalDay)}` +const VISIT_LABEL = t(msg => msg.analysis.common.visitTotal) +const FOCUS_LABEL = t(msg => msg.analysis.common.focusTotal) +const RING_TIP: I18nKey = msg => msg.analysis.common.ringGrowth + +const computeDayValue = (props: Props) => { + const { activeDay, rangeLength } = props + const thisActiveDay = activeDay?.[0] + return `${thisActiveDay?.toString() || '-'}/${rangeLength?.toString() || '-'}` +} + +const renderIndicator = (props: IndicatorProps) => h('div', h(Indicator, props)) + +const computeFocusText = (focusRing: RingValue, format: timer.app.TimeFormat) => { + const current = focusRing?.[0] + return current === undefined ? '-' : periodFormatter(current, format) +} + +const _default = defineComponent({ + props: { + activeDay: [Object, Object] as PropType, + rangeLength: Number, + visit: [Object, Object] as PropType, + focus: [Object, Object] as PropType, + timeFormat: String as PropType + }, + setup(props) { + return () => h('div', { class: 'analysis-trend-total-container' }, [ + renderIndicator({ + mainName: DAY_LABEL, + mainValue: computeDayValue(props), + subTips: RING_TIP, + subValue: computeRingText(props.activeDay) + }), + renderIndicator({ + mainName: FOCUS_LABEL, + mainValue: computeFocusText(props.focus, props.timeFormat), + subTips: RING_TIP, + subValue: computeRingText(props.focus, delta => periodFormatter(delta, props.timeFormat)) + }), + renderIndicator({ + mainName: VISIT_LABEL, + mainValue: props.visit?.[0]?.toString() || '-', + subTips: RING_TIP, + subValue: computeRingText(props.visit), + }), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/analysis/index.ts b/src/app/components/analysis/index.ts new file mode 100644 index 000000000..71241f3f0 --- /dev/null +++ b/src/app/components/analysis/index.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { Ref } from "vue" + +import { defineComponent, h, onMounted, watch, ref } from "vue" +import { useRoute, useRouter } from "vue-router" +import ContentContainer from "../common/content-container" +import Trend from "./components/trend" +import Filter from "./components/filter" +import Summary from "./components/summary" +import statService, { StatQueryParam } from "@service/stat-service" +import './style.sass' +import { judgeVirtualFast } from "@util/pattern" + +type _Queries = { + host: string + merge?: '1' | '0' +} + +function getSiteFromQuery(): timer.site.SiteInfo { + // Process the query param + const query: _Queries = useRoute().query as unknown as _Queries + useRouter().replace({ query: {} }) + const { host, merge } = query + // Init with queries + if (!host) { + return undefined + } + return { host, merged: merge === "1", virtual: judgeVirtualFast(host) } +} + +async function query(site: timer.site.SiteKey): Promise { + if (!site?.host) { + return [] + } + const param: StatQueryParam = { + host: site.host, + mergeHost: site?.merged || false, + fullHost: true, + sort: 'date', + sortOrder: 'ASC' + } + return await statService.select(param) +} + +const _default = defineComponent(() => { + const siteFromQuery = getSiteFromQuery() + const site: Ref = ref(siteFromQuery) + const timeFormat: Ref = ref('default') + const rows: Ref = ref() + const filter: Ref = ref() + + const queryInner = async () => { + const siteKey = site.value + rows.value = siteKey ? (await query(siteKey)) || [] : undefined + } + + onMounted(() => queryInner()) + watch(site, queryInner) + + return () => h(ContentContainer, {}, { + filter: () => h(Filter, { + site: site.value, + timeFormat: timeFormat.value, + ref: filter, + onSiteChange: (newSite: timer.site.SiteKey) => site.value = newSite, + onTimeFormatChange: (newFormat: timer.app.TimeFormat) => timeFormat.value = newFormat + }), + default: () => [ + h(Summary, { site: site.value, rows: rows.value, timeFormat: timeFormat.value }), + h(Trend, { rows: rows.value, timeFormat: timeFormat.value }), + ] + }) +}) + +export default _default diff --git a/src/app/components/analysis/style.sass b/src/app/components/analysis/style.sass new file mode 100644 index 000000000..f8b15cea2 --- /dev/null +++ b/src/app/components/analysis/style.sass @@ -0,0 +1,39 @@ +.analysis-row-card + margin-bottom: 15px + >.el-card__body + position: relative + .analysis-row-title + position: absolute + top: 5px + left: 10px + font-size: 12px + color: var(--el-text-color-regular) + z-index: 1000 + .analysis-row-body + padding-left: 5px + +.analysis-indicator-container + position: relative + top: 50% + transform: translateY(-50%) + padding-left: 40px + padding-top: 10px + padding-bottom: 10px + .indicator-name + font-size: 14px + color: var(--el-text-color-secondary) + .indicator-value + font-size: 24px + margin-block-end: 0.6em + margin-block-start: 0.25em + .indicator-sub-tip + height: 17px + line-height: 17px + font-size: 12px + word-break: break-word + color: var(--el-text-color-secondary) + .indicator-sub-value,.indicator-ring-growth-value + color: var(--el-text-color-primary) +.filter-container + .select-trigger + width: 240px diff --git a/src/app/components/analysis/util.ts b/src/app/components/analysis/util.ts new file mode 100644 index 000000000..c640b88fe --- /dev/null +++ b/src/app/components/analysis/util.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" + +/** + * Transfer host info to label + */ +export function labelOfHostInfo(site: timer.site.SiteKey): string { + if (!site) return '' + const { host, merged, virtual } = site + if (!host) return '' + let label = '' + merged && (label = `[${t(msg => msg.analysis.common.merged)}]`) + virtual && (label = `[${t(msg => msg.analysis.common.virtual)}]`) + return `${host}${label}` +} + +export type RingValue = [number, number] + +/** + * Compute ring text + * + * @param ring ring value + * @param fromatter formatter + * @returns text or '-' + */ +export function computeRingText(ring: RingValue, fromatter?: ValueFormatter): string { + if (!ring) { + return '-' + } + const [current, last] = ring + if (current === undefined && last === undefined) { + // return undefined if both are undefined + return '-' + } + const delta = (current || 0) - (last || 0) + let result = fromatter ? fromatter(delta) : delta?.toString() + delta >= 0 && (result = '+' + result) + return result +} + +export type ValueFormatter = (val: number) => string + +export const formatValue = (val: number, formatter?: ValueFormatter) => formatter ? formatter(val) : val?.toString() || '-' + +export type DimensionEntry = { + date: string + value: number +} \ No newline at end of file diff --git a/src/app/components/common/content-card.ts b/src/app/components/common/content-card.ts new file mode 100644 index 000000000..dfd1dddb8 --- /dev/null +++ b/src/app/components/common/content-card.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElCard } from "element-plus" +import { defineComponent, h, useSlots } from "vue" + +const _default = defineComponent(() => { + const { default: default_ } = useSlots() + return () => h(ElCard, { class: 'container-card' }, () => h(default_)) +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/common/content-container.ts b/src/app/components/common/content-container.ts index 0d4565e9e..1a255a98a 100644 --- a/src/app/components/common/content-container.ts +++ b/src/app/components/common/content-container.ts @@ -6,31 +6,20 @@ */ import { ElCard } from "element-plus" +import ContentCard from "./content-card" import { defineComponent, h, useSlots } from "vue" -const _default = defineComponent({ - name: "ContentContainer", - setup() { - const slots = useSlots() - const children = [] - const hasDefault = !!slots.default - if (hasDefault) { - // Only one content - children.push(h(slots.default)) - } else { - // Else filter and content - const hasFilter = !!slots.filter - if (hasFilter) { - children.push(h(ElCard, { class: "filter-container" }, () => h(slots.filter))) - } - slots.content && children.push(h(ElCard, { class: 'container-card' }, () => h(slots.content))) - } - - return () => h("div", - { class: "content-container" }, - children - ) +const _default = defineComponent(() => { + const slots = useSlots() + const children = [] + const { default: default_, filter, content } = slots + filter && children.push(h(ElCard, { class: "filter-container" }, () => h(filter))) + if (default_) { + children.push(h(slots.default)) + } else { + content && children.push(h(ContentCard, () => h(content))) } + return () => h("div", { class: "content-container" }, children) }) export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/indicator/index.ts b/src/app/components/dashboard/components/indicator/index.ts index 1cf3b0097..b76b66970 100644 --- a/src/app/components/dashboard/components/indicator/index.ts +++ b/src/app/components/dashboard/components/indicator/index.ts @@ -36,7 +36,7 @@ function calculateInstallDays(installTime: Date, now: Date): number { } async function query(): Promise<_Value> { - const allData: timer.stat.Row[] = await statService.select() + const allData: timer.stat.Row[] = await statService.select({ exlcusiveVirtual: true }) const hostSet = new Set() let visits = 0 let browsingTime = 0 @@ -135,37 +135,34 @@ function renderMostUse(most2Hour: number) { ) } -const _default = defineComponent({ - name: "Indicator", - setup() { - const installedDays: Ref = ref() - const siteCount: Ref = ref(0) - const visitCount: Ref = ref(0) - const browsingMinutes: Ref = ref(0) - const most2Hour: Ref = ref(0) - query().then(value => { - installedDays.value = value.installedDays - siteCount.value = value.sites - visitCount.value = value.visits - browsingMinutes.value = Math.floor(value.browsingTime / MILL_PER_MINUTE) - most2Hour.value = value.most2Hour - }) - return () => { - const items = [ - renderVisits(siteCount.value, visitCount.value), - renderBrowsingMinute(browsingMinutes.value), - renderMostUse(most2Hour.value) - ] - const installedDaysVal = installedDays.value - installedDaysVal && items.splice(0, 0, renderInstalledDays(installedDaysVal)) - return h('div', { - id: CONTAINER_ID, - class: 'chart-container' - }, [ - h(IndicatorHeaderIcon), - ...items - ]) - } +const _default = defineComponent(() => { + const installedDays: Ref = ref() + const siteCount: Ref = ref(0) + const visitCount: Ref = ref(0) + const browsingMinutes: Ref = ref(0) + const most2Hour: Ref = ref(0) + query().then(value => { + installedDays.value = value.installedDays + siteCount.value = value.sites + visitCount.value = value.visits + browsingMinutes.value = Math.floor(value.browsingTime / MILL_PER_MINUTE) + most2Hour.value = value.most2Hour + }) + return () => { + const items = [ + renderVisits(siteCount.value, visitCount.value), + renderBrowsingMinute(browsingMinutes.value), + renderMostUse(most2Hour.value) + ] + const installedDaysVal = installedDays.value + installedDaysVal && items.splice(0, 0, renderInstalledDays(installedDaysVal)) + return h('div', { + id: CONTAINER_ID, + class: 'chart-container' + }, [ + h(IndicatorHeaderIcon), + ...items + ]) } }) diff --git a/src/app/components/habit/component/filter.ts b/src/app/components/habit/component/filter.ts index c55df34a1..889ab5d5b 100644 --- a/src/app/components/habit/component/filter.ts +++ b/src/app/components/habit/component/filter.ts @@ -36,8 +36,6 @@ function datePickerShortcut(msg: keyof HabitMessage['dateRange'], agoOfStart: nu const SHORTCUTS: ElementDatePickerShortcut[] = shortcutProps.map(([label, dayAgo]) => datePickerShortcut(label, dayAgo)) const AVERAGE_LABEL = t(msg => msg.habit.average.label) -const DATE_RANGE_START_PLACEHOLDER = t(msg => msg.trend.startDate) -const DATE_RANGE_END_PLACEHOLDER = t(msg => msg.trend.endDate) // [value, label] type _SizeOption = [number, keyof HabitMessage['sizes']] @@ -95,8 +93,6 @@ const _default = defineComponent({ }), // Date range picker h(DateRangeFilterItem, { - startPlaceholder: DATE_RANGE_START_PLACEHOLDER, - endPlaceholder: DATE_RANGE_END_PLACEHOLDER, clearable: false, disabledDate: (date: Date) => date.getTime() > new Date().getTime(), defaultRange: dateRange.value, diff --git a/src/app/components/limit/index.ts b/src/app/components/limit/index.ts index bb7f7dc9b..80deb65f1 100644 --- a/src/app/components/limit/index.ts +++ b/src/app/components/limit/index.ts @@ -17,64 +17,61 @@ import { t } from "@app/locale" import { ElMessage } from "element-plus" import { handleWindowVisibleChange } from "@util/window" -const _default = defineComponent({ - name: "Limit", - setup() { - const url: Ref = ref('') - const onlyEnabled: Ref = ref(false) - const data: Ref = ref([]) - // Init and query - const queryData = async () => { - const list = await limitService.select({ filterDisabled: onlyEnabled.value, url: url.value || '' }) - data.value = list - } - queryData() - // Query data if the window become visible - handleWindowVisibleChange(queryData) - // Init with url parameter - const urlParam = useRoute().query['url'] as string - useRouter().replace({ query: {} }) - urlParam && (url.value = decodeURIComponent(urlParam)) +const _default = defineComponent(() => { + const url: Ref = ref('') + const onlyEnabled: Ref = ref(false) + const data: Ref = ref([]) + // Init and query + const queryData = async () => { + const list = await limitService.select({ filterDisabled: onlyEnabled.value, url: url.value || '' }) + data.value = list + } + queryData() + // Query data if the window become visible + handleWindowVisibleChange(queryData) + // Init with url parameter + const urlParam = useRoute().query['url'] as string + useRouter().replace({ query: {} }) + urlParam && (url.value = decodeURIComponent(urlParam)) - const modify: Ref = ref() - const test: Ref = ref() + const modify: Ref = ref() + const test: Ref = ref() - return () => h(ContentContainer, {}, { - filter: () => h(LimitFilter, { - url: url.value, - onlyEnabled: onlyEnabled.value, - onChange(option: LimitFilterOption) { - url.value = option.url - onlyEnabled.value = option.onlyEnabled + return () => h(ContentContainer, {}, { + filter: () => h(LimitFilter, { + url: url.value, + onlyEnabled: onlyEnabled.value, + onChange(option: LimitFilterOption) { + url.value = option.url + onlyEnabled.value = option.onlyEnabled + queryData() + }, + onCreate: () => modify.value?.create?.(), + onTest: () => test.value?.show?.(), + }), + content: () => [ + h(LimitTable, { + data: data.value, + onDelayChange: (row: timer.limit.Item) => limitService.updateDelay(row), + onEnabledChange: (row: timer.limit.Item) => limitService.updateEnabled(row), + async onDelete(row: timer.limit.Item) { + await limitService.remove(row) + ElMessage.success(t(msg => msg.limit.message.deleted)) queryData() }, - onCreate: () => modify.value?.create?.(), - onTest: () => test.value?.show?.(), + async onModify(row: timer.limit.Item) { + modify.value?.modify?.(row) + } }), - content: () => [ - h(LimitTable, { - data: data.value, - onDelayChange: (row: timer.limit.Item) => limitService.updateDelay(row), - onEnabledChange: (row: timer.limit.Item) => limitService.updateEnabled(row), - async onDelete(row: timer.limit.Item) { - await limitService.remove(row) - ElMessage.success(t(msg => msg.limit.message.deleted)) - queryData() - }, - async onModify(row: timer.limit.Item) { - modify.value?.modify?.(row) - } - }), - h(LimitModify, { - ref: modify, - onSave: queryData - }), - h(LimitTest, { - ref: test - }), - ] - }) - } + h(LimitModify, { + ref: modify, + onSave: queryData + }), + h(LimitTest, { + ref: test + }), + ] + }) }) export default _default diff --git a/src/app/components/limit/modify/index.ts b/src/app/components/limit/modify/index.ts index 78994723b..9f5e47b23 100644 --- a/src/app/components/limit/modify/index.ts +++ b/src/app/components/limit/modify/index.ts @@ -61,7 +61,7 @@ const _default = defineComponent({ ElMessage.warning(noTimeError) return } - const toSave: timer.limit.Rule = { cond: condition, time: timeLimit, enabled: true, allowDelay: true } + const toSave: timer.limit.Rule = { cond: condition, time: timeLimit, enabled: true, allowDelay: false } if (mode.value === 'modify' && modifyingItem) { toSave.enabled = modifyingItem.enabled toSave.allowDelay = modifyingItem.allowDelay diff --git a/src/app/components/option/components/backup/footer.ts b/src/app/components/option/components/backup/footer.ts new file mode 100644 index 000000000..24b300a10 --- /dev/null +++ b/src/app/components/option/components/backup/footer.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { PropType, Ref, watch } from "vue" + +import { t } from "@app/locale" +import { UploadFilled } from "@element-plus/icons-vue" +import { ElButton, ElLoading, ElMessage, ElText } from "element-plus" +import { defineComponent, h, ref } from "vue" +import metaService from "@service/meta-service" +import processor from "@src/common/backup/processor" +import { formatTime } from "@util/time" + +async function handleBackup(lastTime: Ref) { + const loading = ElLoading.service({ + text: "Doing backup...." + }) + const result = await processor.syncData() + loading.close() + if (result.success) { + ElMessage.success('Successfully!') + lastTime.value = result.data || Date.now() + } else { + ElMessage.error(result.errorMsg || 'Unknown error') + } +} + +const TIME_FORMAT = t(msg => msg.calendar.timeFormat) + +const _default = defineComponent({ + props: { + type: { + type: String as PropType, + required: false, + } + }, + setup(props) { + const lastTime: Ref = ref(undefined) + + const queryLastTime = async () => { + const backInfo = await metaService.getLastBackUp(props.type) + lastTime.value = backInfo?.ts + } + + queryLastTime() + watch(() => props.type, queryLastTime) + + return () => { + const children = [ + h(ElButton, { + type: 'primary', + icon: UploadFilled, + onClick: () => handleBackup(lastTime) + }, () => t(msg => msg.option.backup.operation)) + ] + const lastTimeVal = lastTime.value + if (lastTimeVal) { + const tips = t(msg => msg.option.backup.lastTimeTip, { + lastTime: formatTime(lastTimeVal, TIME_FORMAT) + }) + const tipText = h(ElText, { style: { marginLeft: '20px' } }, () => tips) + children.push(tipText) + } + lastTime.value && children.push() + return h('div', {}, children) + } + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/option/components/backup/index.ts b/src/app/components/option/components/backup/index.ts index 0cbd73441..20b3b8a96 100644 --- a/src/app/components/option/components/backup/index.ts +++ b/src/app/components/option/components/backup/index.ts @@ -14,8 +14,8 @@ import { defaultBackup } from "@util/constant/option" import { ElInput, ElOption, ElSelect, ElDivider, ElAlert, ElButton, ElMessage, ElLoading } from "element-plus" import { defineComponent, ref, h } from "vue" import { renderOptionItem, tooltip } from "../../common" -import { UploadFilled } from "@element-plus/icons-vue" import BackUpAutoInput from "./auto-input" +import Footer from "./footer" const ALL_TYPES: timer.backup.Type[] = [ 'none', @@ -114,19 +114,6 @@ const _default = defineComponent({ } } - async function handleBackup() { - const loading = ElLoading.service({ - text: "Doing backup...." - }) - const result = await processor.syncData() - loading.close() - if (result.success) { - ElMessage.success('Successfully!') - } else { - ElMessage.error(result.errorMsg || 'Unknown error') - } - } - ctx.expose({ async reset() { // Only reset type and auto flag @@ -178,11 +165,7 @@ const _default = defineComponent({ msg => msg.backup.client ), h(ElDivider), - h(ElButton, { - type: 'primary', - icon: UploadFilled, - onClick: handleBackup - }, () => t(msg => msg.option.backup.operation)), + h(Footer, { type: type.value }), ) return h('div', nodes) } diff --git a/src/app/components/report/file-export.ts b/src/app/components/report/file-export.ts index 89dd325aa..7a6a87181 100644 --- a/src/app/components/report/file-export.ts +++ b/src/app/components/report/file-export.ts @@ -7,7 +7,7 @@ import { t } from "@app/locale" import { formatTime } from "@util/time" -import { periodFormatter } from "./formatter" +import { periodFormatter } from "@app/util/time" import { exportCsv as exportCsv_, exportJson as exportJson_, diff --git a/src/app/components/report/table/columns/date.ts b/src/app/components/report/table/columns/date.ts index baee04571..b51e1cf2e 100644 --- a/src/app/components/report/table/columns/date.ts +++ b/src/app/components/report/table/columns/date.ts @@ -11,7 +11,7 @@ import { ElTableColumn } from "element-plus" import { defineComponent, h } from "vue" import { t } from "@app/locale" -import { dateFormatter } from "../../formatter" +import { cvt2LocaleTime } from "@app/util/time" const columnLabel = t(msg => msg.item.date) @@ -25,7 +25,7 @@ const _default = defineComponent({ align: "center", sortable: "custom" }, { - default: ({ row }: { row: timer.stat.Row }) => h('span', dateFormatter(row.date)) + default: ({ row }: { row: timer.stat.Row }) => h('span', cvt2LocaleTime(row.date)) }) } }) diff --git a/src/app/components/report/table/columns/focus.ts b/src/app/components/report/table/columns/focus.ts index 4389c637f..927d19e1f 100644 --- a/src/app/components/report/table/columns/focus.ts +++ b/src/app/components/report/table/columns/focus.ts @@ -10,7 +10,7 @@ import type { PropType } from "vue" import { t } from "@app/locale" import { Effect, ElTableColumn, ElTooltip } from "element-plus" import { defineComponent, h } from "vue" -import { periodFormatter } from "../../formatter" +import { periodFormatter } from "@app/util/time" import CompositionTable from './composition-table' const columnLabel = t(msg => msg.item.focus) diff --git a/src/app/components/report/table/columns/operation-delete-button.ts b/src/app/components/report/table/columns/operation-delete-button.ts index 8ffd44e54..ad0bff940 100644 --- a/src/app/components/report/table/columns/operation-delete-button.ts +++ b/src/app/components/report/table/columns/operation-delete-button.ts @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import type { PropType } from "vue" +import type { PropType, Ref } from "vue" -import { computed, defineComponent, h, Ref } from "vue" +import { computed, defineComponent, h } from "vue" import OperationPopupConfirmButton from "@app/components/common/popup-confirm-button" import { Delete } from "@element-plus/icons-vue" import { t } from "@app/locale" -import { dateFormatter } from "../../formatter" import { formatTime } from "@util/time" +import { cvt2LocaleTime } from "@app/util/time" const deleteButtonText = t(msg => msg.item.operation.delete) @@ -22,7 +22,7 @@ const deleteButtonText = t(msg => msg.item.operation.delete) * @param date item date */ function computeSingleConfirmText(url: string, date: string): string { - const formatDate = dateFormatter(date) + const formatDate = cvt2LocaleTime(date) return t(msg => msg.item.operation.deleteConfirmMsg, { url, date: formatDate }) } diff --git a/src/app/components/report/table/columns/operation.ts b/src/app/components/report/table/columns/operation.ts index 42cf1a773..db456a8f7 100644 --- a/src/app/components/report/table/columns/operation.ts +++ b/src/app/components/report/table/columns/operation.ts @@ -16,7 +16,7 @@ import StatDatabase from "@db/stat-database" import whitelistService from "@service/whitelist-service" import { t } from "@app/locale" import { LocationQueryRaw, Router, useRouter } from "vue-router" -import { TREND_ROUTE } from "@app/router/constants" +import { ANALYSIS_ROUTE } from "@app/router/constants" import { Open, Plus, Stopwatch } from "@element-plus/icons-vue" import OperationPopupConfirmButton from "@app/components/common/popup-confirm-button" import OperationDeleteButton from "./operation-delete-button" @@ -35,14 +35,20 @@ async function handleDeleteByRange(itemHost2Delete: string, dateRange: Array msg.item.operation.label) -const trendButtonText = t(msg => msg.item.operation.jumpToTrend) - +const COL_LABEL = t(msg => msg.item.operation.label) +const ANALYSIS = t(msg => msg.item.operation.analysis) // Whitelist texts -const add2WhitelistButtonText = t(msg => msg.item.operation.add2Whitelist) -const add2WhitelistSuccessMsg = t(msg => msg.report.added2Whitelist) -const removeFromWhitelistButtonText = t(msg => msg.item.operation.removeFromWhitelist) -const removeFromWhitelistSuccessMsg = t(msg => msg.report.removeFromWhitelist) +const ADD_WHITE = t(msg => msg.item.operation.add2Whitelist) +const ADD_WHITE_SUCC = t(msg => msg.report.added2Whitelist) +const REMOVE_WHITE = t(msg => msg.item.operation.removeFromWhitelist) +const REMOVE_WHITE_SUCC = t(msg => msg.report.removeFromWhitelist) + +const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { + en: 330, + zh_CN: 290, + ja: 360, + zh_TW: 290, +} const _default = defineComponent({ name: "OperationColumn", props: { @@ -57,15 +63,15 @@ const _default = defineComponent({ }, setup(props, ctx) { const canOperate = computed(() => !props.mergeHost) - const width = computed(() => props.mergeHost ? 110 : locale === "zh_CN" ? 290 : 330) + const width = computed(() => props.mergeHost ? 110 : LOCALE_WIDTH[locale]) const router: Router = useRouter() return () => h(ElTableColumn, { width: width.value, - label: columnLabel, + label: COL_LABEL, align: "center" }, { default: ({ row }: { row: timer.stat.Row }) => [ - // Trend + // Analysis h(ElButton, { icon: Stopwatch, size: 'small', @@ -75,9 +81,9 @@ const _default = defineComponent({ host: row.host, merge: props.mergeHost ? '1' : '0', } - router.push({ path: TREND_ROUTE, query }) + router.push({ path: ANALYSIS_ROUTE, query }) } - }, () => trendButtonText), + }, () => ANALYSIS), // Delete button h(OperationDeleteButton, { mergeDate: props.mergeDate, @@ -97,12 +103,12 @@ const _default = defineComponent({ h(OperationPopupConfirmButton, { buttonIcon: Plus, buttonType: "warning", - buttonText: add2WhitelistButtonText, + buttonText: ADD_WHITE, confirmText: t(msg => msg.whitelist.addConfirmMsg, { url: row.host }), visible: canOperate.value && !props.whitelist?.includes(row.host), async onConfirm() { await whitelistService.add(row.host) - ElMessage({ message: add2WhitelistSuccessMsg, type: 'success' }) + ElMessage({ message: ADD_WHITE_SUCC, type: 'success' }) ctx.emit("whitelistChange", row.host, true) } }), @@ -110,12 +116,12 @@ const _default = defineComponent({ h(OperationPopupConfirmButton, { buttonIcon: Open, buttonType: "primary", - buttonText: removeFromWhitelistButtonText, + buttonText: REMOVE_WHITE, confirmText: t(msg => msg.whitelist.removeConfirmMsg, { url: row.host }), visible: canOperate.value && props.whitelist?.includes(row.host), async onConfirm() { await whitelistService.remove(row.host) - ElMessage({ message: removeFromWhitelistSuccessMsg, type: 'success' }) + ElMessage({ message: REMOVE_WHITE_SUCC, type: 'success' }) ctx.emit("whitelistChange", row.host, false) } }) diff --git a/src/app/components/site-manage/table/column/alias.ts b/src/app/components/site-manage/table/column/alias.ts index 768c0251f..9dfdf4885 100644 --- a/src/app/components/site-manage/table/column/alias.ts +++ b/src/app/components/site-manage/table/column/alias.ts @@ -25,27 +25,23 @@ function handleChange(newAlias: string, row: timer.site.SiteInfo) { } } -const _default = defineComponent({ - name: "AliasColumn", - setup() { - return () => h(ElTableColumn, { - prop: 'host', - minWidth: 100, - align: 'center', - }, { - default: ({ row }: { row: timer.site.SiteInfo }) => h(Editable, { - modelValue: row.alias, - onChange: (newAlias: string) => handleChange(newAlias, row) - }), - header: () => { - const infoTooltip = h(ElTooltip, - { content: tooltip, placement: 'top' }, - () => h(ElIcon, { size: 11 }, () => h(InfoFilled)) - ) - return [label, ' ', infoTooltip] - } - }) - } +const renderTooltip = () => h(ElTooltip, + { content: tooltip, placement: 'top' }, + () => h(ElIcon, { size: 11 }, () => h(InfoFilled)) +) + +const render = () => h(ElTableColumn, { + prop: 'host', + minWidth: 100, + align: 'center', +}, { + default: ({ row }: { row: timer.site.SiteInfo }) => h(Editable, { + modelValue: row.alias, + onChange: (newAlias: string) => handleChange(newAlias, row) + }), + header: () => [label, ' ', renderTooltip()] }) +const _default = defineComponent({ render }) + export default _default \ No newline at end of file diff --git a/src/app/components/site-manage/table/column/source.ts b/src/app/components/site-manage/table/column/source.ts index 525f61ea0..0994ca1ed 100644 --- a/src/app/components/site-manage/table/column/source.ts +++ b/src/app/components/site-manage/table/column/source.ts @@ -19,18 +19,15 @@ function renderSource(source: timer.site.AliasSource) { return h(ElTag, { type, size: 'small' }, () => SOURCE_DESC[source]) } -const _default = defineComponent({ - name: "SourceColumn", - setup() { - return () => h(ElTableColumn, { - prop: 'source', - label: t(msg => msg.siteManage.column.source), - minWidth: 70, - align: 'center', - }, { - default: ({ row }: { row: timer.site.SiteInfo }) => row.source ? renderSource(row.source) : '' - }) - } +const render = () => h(ElTableColumn, { + prop: 'source', + label: t(msg => msg.siteManage.column.source), + minWidth: 70, + align: 'center', +}, { + default: ({ row }: { row: timer.site.SiteInfo }) => row.source ? renderSource(row.source) : '' }) +const _default = defineComponent({ render }) + export default _default \ No newline at end of file diff --git a/src/app/components/site-manage/table/column/type.ts b/src/app/components/site-manage/table/column/type.ts index cf5c34ee0..b65fb4596 100644 --- a/src/app/components/site-manage/table/column/type.ts +++ b/src/app/components/site-manage/table/column/type.ts @@ -1,21 +1,24 @@ import { t } from "@app/locale" -import { ElTableColumn, ElTag } from "element-plus" +import { ElIcon, ElTableColumn, ElTag, ElTooltip } from "element-plus" +import { InfoFilled } from "@element-plus/icons-vue" import { defineComponent, h } from "vue" +import { SiteManageMessage } from "@i18n/message/app/site-manage" -const label = t(msg => msg.siteManage.column.type) +type Type = keyof SiteManageMessage['type'] +const ALL_TYPES: Type[] = ['normal', 'merged', 'virtual'] -const normalType = t(msg => msg.siteManage.type.normal) -const mergedType = t(msg => msg.siteManage.type.merged) -const virtualType = t(msg => msg.siteManage.type.virtual) +const label = t(msg => msg.siteManage.column.type) function cumputeText({ merged, virtual }: timer.site.SiteInfo): string { + let type: Type = undefined if (merged) { - return mergedType + type = 'merged' } else if (virtual) { - return virtualType + type = 'virtual' } else { - return normalType + type = 'normal' } + return t(msg => msg.siteManage.type[type].name) } function computeType({ merged, virtual }: timer.site.SiteInfo): 'info' | 'success' | '' { @@ -28,21 +31,29 @@ function computeType({ merged, virtual }: timer.site.SiteInfo): 'info' | 'succes } } -const _default = defineComponent({ - name: "SiteType", - setup() { - return () => h(ElTableColumn, { - prop: 'host', - label, - minWidth: 60, - align: 'center', - }, { - default: ({ row }: { row: timer.site.SiteInfo }) => h(ElTag, { - size: 'small', - type: computeType(row), - }, () => cumputeText(row)) - }) - } +const renderTooltip = () => h(ElTooltip, { placement: 'top' }, { + content: () => ALL_TYPES + .map((type: Type) => `${t(msg => msg.siteManage.type[type].name)} - ${t(msg => msg.siteManage.type[type].info)}`) + .reduce((a, b) => { + a.length && a.push(h('br')) + a.push(b) + return a + }, []), + default: () => h(ElIcon, { size: 11 }, () => h(InfoFilled)), }) +const renderContent = (row: timer.site.SiteInfo) => h(ElTag, { + size: 'small', + type: computeType(row), +}, () => cumputeText(row)) + +const _default = defineComponent(() => () => h(ElTableColumn, { + prop: 'host', + minWidth: 60, + align: 'center', +}, { + header: () => [label, ' ', renderTooltip()], + default: ({ row }: { row: timer.site.SiteInfo }) => renderContent(row) +})) + export default _default \ No newline at end of file diff --git a/src/app/components/trend/components/chart/index.ts b/src/app/components/trend/components/chart/index.ts deleted file mode 100644 index 1ff38457b..000000000 --- a/src/app/components/trend/components/chart/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { Ref } from "vue" - -import { defineComponent, h, onMounted, ref } from "vue" -import ChartWrapper from "./wrapper" - -const _default = defineComponent({ - name: "TrendChart", - setup(_, ctx) { - const elRef: Ref = ref() - const chartWrapper: ChartWrapper = new ChartWrapper() - - function render(filterOption: TrendFilterOption, isOnMounted: boolean, row: timer.stat.Row[]) { - chartWrapper.render({ ...filterOption, isFirst: isOnMounted }, row) - } - - ctx.expose({ - render, - }) - - onMounted(() => chartWrapper.init(elRef.value)) - - return () => h('div', { class: 'chart-container', ref: elRef }) - } -}) - -export default _default diff --git a/src/app/components/trend/components/chart/wrapper.ts b/src/app/components/trend/components/chart/wrapper.ts deleted file mode 100644 index 75ec9fa73..000000000 --- a/src/app/components/trend/components/chart/wrapper.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { ECharts, ComposeOption } from "echarts/core" -import type { LineSeriesOption } from "echarts/charts" -import type { - GraphicComponentOption, - GridComponentOption, - LegendComponentOption, - TitleComponentOption, - TooltipComponentOption, - ToolboxComponentOption, -} from "echarts/components" - -import { init, use } from "@echarts/core" -import LineChart from "@echarts/chart/line" -import GridComponent from "@echarts/component/grid" -import SVGRenderer from "@echarts/svg-renderer" -import LegendComponent from "@echarts/component/legend" -import TitleComponent from "@echarts/component/title" -import ToolboxComponent from "@echarts/component/toolbox" -import TooltipComponent from "@echarts/component/tooltip" - -import { t } from "@app/locale" -import { formatPeriodCommon, formatTime, MILL_PER_DAY } from "@util/time" -import siteService from "@service/site-service" -import { getPrimaryTextColor, getSecondaryTextColor } from "@util/style" -import { labelOfHostInfo } from "../common" - -use([ - LineChart, - GridComponent, - LegendComponent, - TitleComponent, - ToolboxComponent, - TooltipComponent, - SVGRenderer, -]) - -type EcOption = ComposeOption< - | LineSeriesOption - | GraphicComponentOption - | GridComponentOption - | LegendComponentOption - | TitleComponentOption - | ToolboxComponentOption - | TooltipComponentOption -> - -const TITLE = t(msg => msg.trend.history.title) -const DEFAULT_SUB_TITLE = t(msg => msg.trend.defaultSubTitle) -const SAVE_AS_IMAGE = t(msg => msg.trend.saveAsImageTitle) - -const NUMBER_UNIT = t(msg => msg.trend.history.numberUnit) - -const MILL_CONVERTERS: { [timeFormat in timer.app.TimeFormat]: (mill: number) => number } = { - default: mill => Math.floor(mill / 1000), - second: mill => Math.floor(mill / 1000), - minute: mill => mill / 1000 / 60, - hour: mill => mill / 1000 / 3600 -} - -function formatTimeOfEchart(params: any, timeFormat: timer.app.TimeFormat): string { - const format = params instanceof Array ? params[0] : params - const { seriesName, name, value } = format - let timeStr = '' - if (timeFormat === 'second') { - timeStr = (typeof value === 'number' ? value : 0).toFixed(0) + ' s' - } else if (timeFormat === 'minute') { - timeStr = (typeof value === 'number' ? value : 0).toFixed(1) + ' m' - } else if (timeFormat === 'hour') { - timeStr = (typeof value === 'number' ? value : 0).toFixed(2) + ' h' - } else { - const mills = (typeof value === 'number' ? value : 0) * 1000 - timeStr = formatPeriodCommon(mills) - } - return `${seriesName}
${name} - ${timeStr}` -} - -function optionOf( - xAxisData: string[], - subtext: string, - timeFormat: timer.app.TimeFormat, - [focusData, timeData]: [number[], number[]] -) { - const textColor = getPrimaryTextColor() - const secondaryTextColor = getSecondaryTextColor() - const option: EcOption = { - backgroundColor: 'rgba(0,0,0,0)', - grid: { top: '100' }, - title: { - text: TITLE, - textStyle: { color: textColor }, - subtext, - subtextStyle: { color: secondaryTextColor }, - left: 'center', - }, - tooltip: { trigger: 'item' }, - toolbox: { - feature: { - saveAsImage: { - show: true, - title: SAVE_AS_IMAGE, - excludeComponents: ['toolbox'], - pixelRatio: 1, - backgroundColor: '#fff', - iconStyle: { - borderColor: secondaryTextColor - } - } - } - }, - xAxis: { - type: 'category', - data: xAxisData, - axisLabel: { color: textColor }, - }, - yAxis: [ - { - name: t(msg => msg.trend.history.timeUnit[timeFormat || 'default']), - nameTextStyle: { color: textColor }, - type: 'value', - axisLabel: { color: textColor }, - }, - { - name: NUMBER_UNIT, - nameTextStyle: { color: textColor }, - type: 'value', - axisLabel: { color: textColor }, - } - ], - legend: [{ - left: 'left', - data: [t(msg => msg.item.focus), t(msg => msg.item.time)], - textStyle: { color: textColor }, - }], - series: [{ - name: t(msg => msg.item.focus), - data: focusData, - yAxisIndex: 0, - type: 'line', - smooth: true, - tooltip: { formatter: (params: any) => formatTimeOfEchart(params, timeFormat) } - }, { - name: t(msg => msg.item.time), - data: timeData, - yAxisIndex: 1, - type: 'line', - smooth: true, - tooltip: { - formatter: (params: any) => { - const format = params instanceof Array ? params[0] : params - const { seriesName, name, value } = format - return `${seriesName}
${name} - ${value}` - } - } - }] - } - return option -} - - -// Get the timestamp of one timestamp of date -const timestampOf = (d: Date) => d.getTime() - -/** -* Get the x-axis of date -*/ -function getAxias(format: string, dateRange: Date[] | undefined): string[] { - if (!dateRange || !dateRange.length) { - // @since 0.0.9 - // The dateRange is cleared, return empty data - return [] - } - const xAxisData = [] - const startTime = timestampOf(dateRange[0]) - const endTime = timestampOf(dateRange[1]) - for (let time = startTime; time <= endTime; time += MILL_PER_DAY) { - xAxisData.push(formatTime(time, format)) - } - return xAxisData -} - -async function processSubtitle(host: TrendHostInfo) { - let subtitle = labelOfHostInfo(host) - if (!subtitle) { - return DEFAULT_SUB_TITLE - } - if (!host.merged) { - // If not merged, append the site name to the original subtitle - // @since 0.9.0 - const siteInfo: timer.site.SiteInfo = await siteService.get(host) - const siteName = siteInfo?.alias - siteName && (subtitle += ` / ${siteName}`) - } - return subtitle -} - -function processDataItems(allDates: string[], timeFormat: timer.app.TimeFormat, rows: timer.stat.Row[]): [number[], number[]] { - timeFormat = timeFormat || 'default' - const millConverter = MILL_CONVERTERS[timeFormat] - const focusData: number[] = [] - const timeData: number[] = [] - - const dateInfoMap: Record = {} - rows.forEach(row => dateInfoMap[row.date] = row) - - allDates.forEach(date => { - const row = dateInfoMap[date] - focusData.push(millConverter(row?.focus || 0)) - timeData.push(row?.time || 0) - }) - return [focusData, timeData] -} - -class ChartWrapper { - instance: ECharts - - init(container: HTMLDivElement) { - this.instance = init(container) - } - - async render(renderOption: TrendRenderOption, rows: timer.stat.Row[]) { - const { host, dateRange, timeFormat } = renderOption - // 1. x-axis data - let xAxisData: string[], allDates: string[] - if (!dateRange || dateRange.length !== 2) { - xAxisData = [] - allDates = [] - } else { - xAxisData = getAxias('{m}/{d}', dateRange) - allDates = getAxias('{y}{m}{d}', dateRange) - } - - // 2. subtitle - const subtitle = await processSubtitle(host) - - // 3. series data - const dataItems = processDataItems(allDates, timeFormat, rows) - - const option: EcOption = optionOf(xAxisData, subtitle, timeFormat, dataItems) - - this.instance?.setOption(option) - } -} - -export default ChartWrapper diff --git a/src/app/components/trend/components/common.ts b/src/app/components/trend/components/common.ts deleted file mode 100644 index 688230561..000000000 --- a/src/app/components/trend/components/common.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { t } from "@app/locale" - -/** - * Transfer host info to label - */ -export function labelOfHostInfo(hostInfo: TrendHostInfo): string { - if (!hostInfo) return '' - const { host, merged, virtual } = hostInfo - if (!host) return '' - let label = '' - merged && (label = `[${t(msg => msg.trend.merged)}]`) - virtual && (label = `[${t(msg => msg.trend.virtual)}]`) - return `${host}${label}` -} diff --git a/src/app/components/trend/components/filter.ts b/src/app/components/trend/components/filter.ts deleted file mode 100644 index f9ac3de09..000000000 --- a/src/app/components/trend/components/filter.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { Ref, PropType, VNode } from "vue" - -import { ElOption, ElSelect, ElTag } from "element-plus" -import { ref, h, defineComponent } from "vue" -import statService, { HostSet } from "@service/stat-service" -import { daysAgo } from "@util/time" -import { t } from "@app/locale" -import { TrendMessage } from "@i18n/message/app/trend" -import DateRangeFilterItem from "@app/components/common/date-range-filter-item" -import SelectFilterItem from "@app/components/common/select-filter-item" -import { ElementDatePickerShortcut } from "@src/element-ui/date" -import { labelOfHostInfo } from "./common" - -async function handleRemoteSearch(queryStr: string, trendDomainOptions: Ref, searching: Ref) { - if (!queryStr) { - trendDomainOptions.value = [] - return - } - searching.value = true - const domains: HostSet = await statService.listHosts(queryStr) - const options: TrendHostInfo[] = [] - const { origin, merged, virtual } = domains - origin.forEach(host => options.push({ host })) - merged.forEach(host => options.push({ host, merged: true })) - virtual.forEach(host => options.push({ host, virtual: true })) - trendDomainOptions.value = options - searching.value = false -} - -function datePickerShortcut(msg: keyof TrendMessage, agoOfStart?: number, agoOfEnd?: number): ElementDatePickerShortcut { - return { - text: t(messages => messages.trend[msg]), - value: daysAgo(agoOfStart || 0, agoOfEnd || 0) - } -} - -const SHORTCUTS = [ - datePickerShortcut('lastWeek', 7), - datePickerShortcut('last15Days', 15), - datePickerShortcut('last30Days', 30), - datePickerShortcut("last90Days", 90) -] - -const HOST_PLACEHOLDER = t(msg => msg.trend.hostPlaceholder) -// Date picker -const START_DATE_PLACEHOLDER = t(msg => msg.trend.startDate) -const END_DATE_PLACEHOLDER = t(msg => msg.trend.endDate) - -const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { - default: t(msg => msg.timeFormat.default), - second: t(msg => msg.timeFormat.second), - minute: t(msg => msg.timeFormat.minute), - hour: t(msg => msg.timeFormat.hour) -} - -function keyOfHostInfo(option: TrendHostInfo): string { - const { merged, virtual, host } = option - let prefix = '_' - merged && (prefix = 'm') - virtual && (prefix = 'v') - return `${prefix}${host || ''}` -} - -function hostInfoOfKey(key: string): TrendHostInfo { - if (!key || !key.length) return { host: '', merged: false, virtual: false } - const prefix = key.charAt(0) - return { host: key.substring(1), merged: prefix === 'm', virtual: prefix === 'v' } -} - -const MERGED_TAG_TXT = t(msg => msg.trend.merged) -const VIRTUAL_TAG_TXT = t(msg => msg.trend.virtual) -function renderHostLabel(hostInfo: TrendHostInfo): VNode[] { - const result = [ - h('span', {}, hostInfo.host) - ] - hostInfo.merged && result.push( - h(ElTag, { size: 'small' }, () => MERGED_TAG_TXT) - ) - hostInfo.virtual && result.push( - h(ElTag, { size: 'small' }, () => VIRTUAL_TAG_TXT) - ) - return result -} - -const _default = defineComponent({ - name: "TrendFilter", - props: { - dateRange: Object as PropType, - defaultValue: Object as PropType, - timeFormat: String as PropType - }, - emits: { - change: (_option: TrendFilterOption) => true - }, - setup(props, ctx) { - // @ts-ignore - const dateRange: Ref = ref(props.dateRange) - const domainKey: Ref = ref('') - const trendSearching: Ref = ref(false) - const trendDomainOptions: Ref = ref([]) - const defaultOption: TrendHostInfo = props.defaultValue - const timeFormat: Ref = ref(props.timeFormat) - if (defaultOption) { - domainKey.value = keyOfHostInfo(defaultOption) - trendDomainOptions.value.push(defaultOption) - } - - function handleChange() { - const hostInfo: TrendHostInfo = hostInfoOfKey(domainKey.value) - const option: TrendFilterOption = { - host: hostInfo, - dateRange: dateRange.value, - timeFormat: timeFormat.value - } - ctx.emit('change', option) - } - - return () => [h(ElSelect, { - placeholder: HOST_PLACEHOLDER, - class: 'filter-item', - modelValue: domainKey.value, - clearable: true, - filterable: true, - remote: true, - loading: trendSearching.value, - remoteMethod: (query: string) => handleRemoteSearch(query, trendDomainOptions, trendSearching), - onChange: (key: string) => { - domainKey.value = key - handleChange() - }, - onClear: () => { - domainKey.value = '' - handleChange() - } - }, () => (trendDomainOptions.value || [])?.map( - hostInfo => h(ElOption, { - value: keyOfHostInfo(hostInfo), - label: labelOfHostInfo(hostInfo), - }, () => renderHostLabel(hostInfo)) - )), - h(DateRangeFilterItem, { - defaultRange: dateRange.value, - startPlaceholder: START_DATE_PLACEHOLDER, - endPlaceholder: END_DATE_PLACEHOLDER, - shortcuts: SHORTCUTS, - onChange: (newVal: Date[]) => { - dateRange.value = newVal - handleChange() - }, - clearable: false, - disabledDate: (date: Date) => date.getTime() > new Date().getTime(), - }), - h(SelectFilterItem, { - historyName: 'timeFormat', - defaultValue: timeFormat.value, - options: TIME_FORMAT_LABELS, - onSelect(newVal: timer.app.TimeFormat) { - timeFormat.value = newVal - handleChange() - } - })] - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/trend/index.ts b/src/app/components/trend/index.ts deleted file mode 100644 index df4c9637e..000000000 --- a/src/app/components/trend/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { Ref } from "vue" - -import { defineComponent, h, onMounted, ref } from "vue" -import { useRoute, useRouter } from "vue-router" -import { daysAgo } from "@util/time" -import ContentContainer from "../common/content-container" -import TrendChart from "./components/chart" -import TrendFilter from "./components/filter" -import statService, { StatQueryParam } from "@service/stat-service" - -type _Queries = { - host: string - merge: '1' | '0' | undefined -} - -function initWithQuery(hostOption: Ref) { - // Process the query param - const query: _Queries = useRoute().query as unknown as _Queries - useRouter().replace({ query: {} }) - const { host, merge } = query - // Init with queries - host && (hostOption.value = { host, merged: merge === "1" }) -} - -async function query(hostOption: Ref, dateRange: Ref): Promise { - const hostVal = hostOption.value?.host - if (!hostVal) { - return [] - } - const param: StatQueryParam = { - // If the host is empty, no result will be queried with this param. - host: hostVal, - mergeHost: hostOption.value?.merged || false, - date: dateRange.value, - fullHost: true, - sort: 'date', - sortOrder: 'ASC' - } - return await statService.select(param) -} - -const _default = defineComponent({ - name: "Trend", - setup() { - // @ts-ignore - const dateRange: Ref = ref(daysAgo(7, 0)) - const hostOption: Ref = ref() - const timeFormat: Ref = ref('default') - const chart: Ref = ref() - const filter: Ref = ref() - - initWithQuery(hostOption) - - async function queryAndRender(isOnMounted?: boolean) { - const row = await query(hostOption, dateRange) - const filterOption: TrendFilterOption = { - host: hostOption.value, - dateRange: dateRange.value, - timeFormat: timeFormat.value - } - chart.value?.render(filterOption, !!isOnMounted, row) - } - - onMounted(() => queryAndRender(true)) - - return () => h(ContentContainer, {}, { - filter: () => h(TrendFilter, { - defaultValue: hostOption.value, - timeFormat: timeFormat.value, - dateRange: dateRange.value, - ref: filter, - onChange(option: TrendFilterOption) { - hostOption.value = option.host - dateRange.value = option.dateRange - timeFormat.value = option.timeFormat - queryAndRender() - } - }), - content: () => h(TrendChart, { ref: chart }) - }) - } -}) - -export default _default diff --git a/src/app/components/trend/trend.d.ts b/src/app/components/trend/trend.d.ts deleted file mode 100644 index 69fc5c1e4..000000000 --- a/src/app/components/trend/trend.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare type TrendHostInfo = { - host: string - merged?: boolean - virtual?: boolean -} - -declare type TrendFilterOption = { - host: TrendHostInfo, - dateRange: Date[], - timeFormat: timer.app.TimeFormat -} - -declare type TrendRenderOption = TrendFilterOption & { - /** - * Whether render firstly - */ - isFirst: boolean -} \ No newline at end of file diff --git a/src/app/layout/menu.ts b/src/app/layout/menu.ts index 1bb13eab6..624db8210 100644 --- a/src/app/layout/menu.ts +++ b/src/app/layout/menu.ts @@ -20,6 +20,7 @@ import { Aim, Calendar, ChatSquare, Folder, HelpFilled, HotWater, Memo, Rank, Se import { locale } from "@i18n" import TrendIcon from "./icon/trend-icon" import { createTab } from "@api/chrome/tab" +import { ANALYSIS_ROUTE } from "@app/router/constants" type _MenuItem = { title: keyof MenuMessage @@ -79,8 +80,8 @@ function generateMenus(): _MenuGroup[] { route: '/data/report', icon: Calendar }, { - title: 'dataHistory', - route: '/data/history', + title: 'siteAnalysis', + route: ANALYSIS_ROUTE, icon: TrendIcon }, { title: 'dataClear', diff --git a/src/app/router/constants.ts b/src/app/router/constants.ts index 07cf2127f..242c15216 100644 --- a/src/app/router/constants.ts +++ b/src/app/router/constants.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -export const TREND_ROUTE = '/data/history' +export const ANALYSIS_ROUTE = '/data/analysis' export const OPTION_ROUTE = '/additional/option' diff --git a/src/app/router/index.ts b/src/app/router/index.ts index dace0236a..f11dd70e1 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -9,7 +9,7 @@ import type { App } from "vue" import type { RouteRecordRaw } from "vue-router" import { createRouter, createWebHashHistory } from "vue-router" -import { OPTION_ROUTE, TREND_ROUTE, LIMIT_ROUTE, REPORT_ROUTE } from "./constants" +import { OPTION_ROUTE, ANALYSIS_ROUTE, LIMIT_ROUTE, REPORT_ROUTE } from "./constants" import metaService from "@service/meta-service" const dataRoutes: RouteRecordRaw[] = [ @@ -26,8 +26,8 @@ const dataRoutes: RouteRecordRaw[] = [ path: REPORT_ROUTE, component: () => import('../components/report') }, { - path: TREND_ROUTE, - component: () => import('../components/trend') + path: ANALYSIS_ROUTE, + component: () => import('../components/analysis') }, { path: '/data/manage', component: () => import('../components/data-manage') diff --git a/src/app/styles/index.sass b/src/app/styles/index.sass index 5e66a628e..e5cce78d7 100644 --- a/src/app/styles/index.sass +++ b/src/app/styles/index.sass @@ -54,6 +54,7 @@ a overflow-y: auto .filter-container + margin-bottom: 15px display: flex align-items: center user-select: none @@ -110,10 +111,7 @@ a align-items: center margin-top: 23px -// charts - .container-card - margin-top: 15px min-height: 640px .el-card__body height: 100% diff --git a/src/app/components/report/formatter.ts b/src/app/util/time.ts similarity index 90% rename from src/app/components/report/formatter.ts rename to src/app/util/time.ts index c45cb48f2..2022b1319 100644 --- a/src/app/components/report/formatter.ts +++ b/src/app/util/time.ts @@ -9,10 +9,12 @@ import { t } from "@app/locale" import { formatPeriodCommon, MILL_PER_MINUTE } from "@util/time" /** - * @param date date string {yyyy}{mm}{dd} - * @returns the msg + * Convert {yyyy}{mm}{dd} to locale time + * + * @param date {yyyy}{mm}{dd} */ -export function dateFormatter(date: string): string { +export function cvt2LocaleTime(date: string) { + if (!date) return '-' const y = date.substring(0, 4) const m = date.substring(4, 6) const d = date.substring(6, 8) diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 99d079a86..69b20a560 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -20,8 +20,15 @@ import MessageDispatcher from "./message-dispatcher" export default function init(dispatcher: MessageDispatcher) { dispatcher // Increase the visit time - .register('cs.incVisitCount', async host => { - statService.addOneTime(host) + .register('cs.incVisitCount', async (param) => { + let host: string, url: string = undefined + if (typeof param === 'string') { + host = param + } else { + host = param?.host + url = param?.url + } + statService.addOneTime(host, url) }) // Judge is in whitelist .register('cs.isInWhitelist', host => whitelistService.include(host)) @@ -31,10 +38,7 @@ export default function init(dispatcher: MessageDispatcher) { return !!option.printInConsole }) // Get today info - .register('cs.getTodayInfo', host => { - const now = new Date() - return statService.getResult(host, now) - }) + .register('cs.getTodayInfo', host => statService.getResult(host, new Date())) // More minutes .register('cs.moreMinutes', url => limitService.moreMinutes(url)) // cs.getLimitedRules diff --git a/src/common/backup/processor.ts b/src/common/backup/processor.ts index 07b4c0490..4a708a2bd 100644 --- a/src/common/backup/processor.ts +++ b/src/common/backup/processor.ts @@ -145,7 +145,7 @@ class Processor { } } - async syncData(): Promise> { + async syncData(): Promise> { const option = (await optionService.getAllOption()) as timer.option.BackupOption const auth = option?.backupAuths?.[option.backupType || 'none'] @@ -173,8 +173,9 @@ class Processor { clients.push(client) await coordinator.updateClients(context, clients) // Update time - metaService.updateBackUpTime(type, Date.now()) - return success() + const now = Date.now() + metaService.updateBackUpTime(type, now) + return success(now) } async query(type: timer.backup.Type, auth: string, start: Date, end: Date): Promise { diff --git a/src/content-script/index.ts b/src/content-script/index.ts index d155a6489..0f53c2956 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -19,7 +19,7 @@ async function main() { const isWhitelist = await sendMsg2Runtime('cs.isInWhitelist', host) if (isWhitelist) return - sendMsg2Runtime('cs.incVisitCount', host) + sendMsg2Runtime('cs.incVisitCount', { host, url }) await initLocale() const needPrintInfo = await sendMsg2Runtime('cs.printTodayInfo') diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts new file mode 100644 index 000000000..c625664ab --- /dev/null +++ b/src/database/stat-database/filter.ts @@ -0,0 +1,129 @@ +import { DATE_FORMAT } from "@db/common/constant" +import { judgeVirtualFast } from "@util/pattern" +import { formatTime } from "@util/time" +import StatDatabase, { StatCondition } from "." + +type _StatCondition = StatCondition & { + // Use exact date condition + useExactDate?: boolean + // date str + exactDateStr?: string + startDateStr?: string + endDateStr?: string + // time range + timeStart?: number + timeEnd?: number + focusStart?: number + focusEnd?: number +} + +type _FilterResult = { + host: string + date: string + value: timer.stat.Result +} + +function filterHost(host: string, condition: _StatCondition): boolean { + const paramHost = (condition.host || '').trim() + const exlcusiveVirtual = condition.exlcusiveVirtual + if (!paramHost) return true + if (!!condition.fullHost && host !== paramHost) return false + if (!condition.fullHost && !host.includes(paramHost)) return false + if (exlcusiveVirtual && judgeVirtualFast(host)) return false + return true +} + +function filterDate(date: string, condition: _StatCondition): boolean { + if (condition.useExactDate) { + if (condition.exactDateStr !== date) return false + } else { + const { startDateStr, endDateStr } = condition + if (startDateStr && startDateStr > date) return false + if (endDateStr && endDateStr < date) return false + } + return true +} + +function filterNumberRange(val: number, range: number[]): boolean { + const start = range[0] + const end = range[1] + if (start !== null && start !== undefined && start > val) return false + if (end !== null && end !== undefined && end < val) return false + return true +} + +/** + * Filter by query parameters + * + * @param date date of item + * @param host host of item + * @param val val of item + * @param condition query parameters + * @return true if valid, or false + */ +function filterByCond(result: _FilterResult, condition: _StatCondition): boolean { + const { host, date, value } = result + const { focus, time } = value + const { timeStart, timeEnd, focusStart, focusEnd } = condition + + return filterHost(host, condition) + && filterDate(date, condition) + && filterNumberRange(time, [timeStart, timeEnd]) + && filterNumberRange(focus, [focusStart, focusEnd]) +} + + +function processDateCondition(cond: _StatCondition, paramDate: Date | Date[]) { + if (!paramDate) return + + if (paramDate instanceof Date) { + cond.useExactDate = true + cond.exactDateStr = formatTime(paramDate as Date, DATE_FORMAT) + } else { + let startDate: Date = undefined + let endDate: Date = undefined + const dateArr = paramDate as Date[] + dateArr && dateArr.length >= 2 && (endDate = dateArr[1]) + dateArr && dateArr.length >= 1 && (startDate = dateArr[0]) + cond.useExactDate = false + startDate && (cond.startDateStr = formatTime(startDate, DATE_FORMAT)) + endDate && (cond.endDateStr = formatTime(endDate, DATE_FORMAT)) + } +} + +function processParamTimeCondition(cond: _StatCondition, paramTime: number[]) { + if (!paramTime) return + paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) + paramTime.length >= 1 && (cond.timeStart = paramTime[0]) +} + +function processParamFocusCondition(cond: _StatCondition, paramFocus: number[]) { + if (!paramFocus) return + paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) + paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) +} + + +function processCondition(condition: StatCondition): _StatCondition { + const result: _StatCondition = { ...condition } + processDateCondition(result, condition.date) + processParamTimeCondition(result, condition.timeRange) + processParamFocusCondition(result, condition.focusRange) + return result +} + +/** + * Filter by query parameters + */ +export async function filter(this: StatDatabase, condition?: StatCondition): Promise<_FilterResult[]> { + condition = condition || {} + const cond = processCondition(condition) + const items = await this.refresh() + return Object.entries(items).map( + ([key, value]) => { + const date = key.substring(0, 8) + const host = key.substring(8) + return { date, host, value: value as timer.stat.Result } + } + ).filter(item => filterByCond(item, cond)) +} \ No newline at end of file diff --git a/src/database/stat-database.ts b/src/database/stat-database/index.ts similarity index 57% rename from src/database/stat-database.ts rename to src/database/stat-database/index.ts index 6ba2fb632..23b64cf51 100644 --- a/src/database/stat-database.ts +++ b/src/database/stat-database/index.ts @@ -5,12 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { log } from "../common/logger" +import { log } from "../../common/logger" import { formatTime } from "@util/time" -import BaseDatabase from "./common/base-database" -import { DATE_FORMAT, REMAIN_WORD_PREFIX } from "./common/constant" +import BaseDatabase from "../common/base-database" +import { DATE_FORMAT, REMAIN_WORD_PREFIX } from "../common/constant" import { createZeroResult, mergeResult, isNotZeroResult } from "@util/stat" import { judgeVirtualFast } from "@util/pattern" +import { filter } from "./filter" export type StatCondition = { /** @@ -40,58 +41,12 @@ export type StatCondition = { * @since 0.0.8 */ fullHost?: boolean -} - -type _StatCondition = StatCondition & { - // Use exact date condition - useExactDate?: boolean - // date str - exactDateStr?: string - startDateStr?: string - endDateStr?: string - // time range - timeStart?: number - timeEnd?: number - focusStart?: number - focusEnd?: number -} - -function processDateCondition(cond: _StatCondition, paramDate: Date | Date[]) { - if (!paramDate) return - - if (paramDate instanceof Date) { - cond.useExactDate = true - cond.exactDateStr = formatTime(paramDate as Date, DATE_FORMAT) - } else { - let startDate: Date = undefined - let endDate: Date = undefined - const dateArr = paramDate as Date[] - dateArr && dateArr.length >= 2 && (endDate = dateArr[1]) - dateArr && dateArr.length >= 1 && (startDate = dateArr[0]) - cond.useExactDate = false - startDate && (cond.startDateStr = formatTime(startDate, DATE_FORMAT)) - endDate && (cond.endDateStr = formatTime(endDate, DATE_FORMAT)) - } -} - -function processParamTimeCondition(cond: _StatCondition, paramTime: number[]) { - if (!paramTime) return - paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) - paramTime.length >= 1 && (cond.timeStart = paramTime[0]) -} - -function processParamFocusCondition(cond: _StatCondition, paramFocus: number[]) { - if (!paramFocus) return - paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) - paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) -} - -function processCondition(condition: StatCondition): _StatCondition { - const result: _StatCondition = { ...condition } - processDateCondition(result, condition.date) - processParamTimeCondition(result, condition.timeRange) - processParamFocusCondition(result, condition.focusRange) - return result + /** + * Whether to exlcusive virtual sites + * + * @since 1.6.1 + */ + exlcusiveVirtual?: boolean } function mergeMigration(exist: timer.stat.Result | undefined, another: any) { @@ -175,6 +130,8 @@ class StatDatabase extends BaseDatabase { return afterUpdated } + filter = filter + /** * Select * @@ -182,69 +139,24 @@ class StatDatabase extends BaseDatabase { */ async select(condition?: StatCondition): Promise { log("select:{condition}", condition) - condition = condition || {} - const _cond: _StatCondition = processCondition(condition) - const items = await this.refresh() - let result: timer.stat.Row[] = [] - - for (let key in items) { - const date = key.substring(0, 8) - const host = key.substring(8) - const val: timer.stat.Result = items[key] - if (this.filterBefore(date, host, val, _cond)) { - const { focus, time } = val - result.push({ date, host, focus, time, mergedHosts: [], virtual: judgeVirtualFast(host) }) - } - } - - log('Result of select: ', result) - return result - } - - private filterHost(host: string, condition: _StatCondition): boolean { - const paramHost = (condition.host || '').trim() - if (!paramHost) return true - if (!!condition.fullHost && host !== paramHost) return false - if (!condition.fullHost && !host.includes(paramHost)) return false - return true - } - - private filterDate(date: string, condition: _StatCondition): boolean { - if (condition.useExactDate) { - if (condition.exactDateStr !== date) return false - } else { - const { startDateStr, endDateStr } = condition - if (startDateStr && startDateStr > date) return false - if (endDateStr && endDateStr < date) return false - } - return true - } - - private filterNumberRange(val: number, range: number[]): boolean { - const start = range[0] - const end = range[1] - if (start !== null && start !== undefined && start > val) return false - if (end !== null && end !== undefined && end < val) return false - return true + const filterResults = await this.filter(condition) + return filterResults.map(({ date, host, value }) => { + const { focus, time } = value + return { date, host, focus, time, mergedHosts: [], virtual: judgeVirtualFast(host) } + }) } /** - * Filter by query parameters + * Count by condition * - * @param date date of item - * @param host host of item - * @param val val of item - * @param condition query parameters - * @return true if valid, or false + * @param condition + * @returns count + * @since 1.0.2 */ - private filterBefore(date: string, host: string, val: timer.stat.Result, condition: _StatCondition): boolean { - const { focus, time } = val - const { timeStart, timeEnd, focusStart, focusEnd } = condition - - return this.filterHost(host, condition) - && this.filterDate(date, condition) - && this.filterNumberRange(time, [timeStart, timeEnd]) - && this.filterNumberRange(focus, [focusStart, focusEnd]) + async count(condition: StatCondition): Promise { + log("select:{condition}", condition) + const filterResults = await this.filter(condition) + return filterResults.length || 0 } /** @@ -323,30 +235,6 @@ class StatDatabase extends BaseDatabase { return this.deleteByUrlBetween(host) } - /** - * Count by condition - * - * @param condition - * @returns count - * @since 1.0.2 - */ - async count(condition: StatCondition): Promise { - condition = condition || {} - const _cond: _StatCondition = processCondition(condition) - const items = await this.refresh() - let count = 0 - - for (let key in items) { - const date = key.substring(0, 8) - const host = key.substring(8) - const val: timer.stat.Result = items[key] - if (this.filterBefore(date, host, val, _cond)) { - count++ - } - } - return count - } - async importData(data: any): Promise { if (typeof data !== "object") return const items = await this.storage.get() diff --git a/src/i18n/message/app/analysis.ts b/src/i18n/message/app/analysis.ts new file mode 100644 index 000000000..21b186a67 --- /dev/null +++ b/src/i18n/message/app/analysis.ts @@ -0,0 +1,177 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +export type AnalysisMessage = { + common: { + focusTotal: string + visitTotal: string + ringGrowth: string + merged: string + virtual: string + hostPlaceholder: string + emptyDesc: string + } + summary: { + title: string + day: string + firstDay: string + } + trend: { + title: string + startDate: string, + endDate: string + lastWeek: string + last15Days: string + last30Days: string + last90Days: string + activeDay: string + totalDay: string + maxFocus: string + averageFocus: string + maxVisit: string + averageVisit: string + focusTitle: string + visitTitle: string + } +} + +const _default: Messages = { + zh_CN: { + common: { + focusTotal: '总计浏览时长', + visitTotal: '总计访问次数', + ringGrowth: '与上期相比 {value}', + merged: '合并', + virtual: '自定义', + hostPlaceholder: '搜索你想分析的站点', + emptyDesc: '未选择站点' + }, + summary: { + title: '数据总览', + day: '总计活跃天数', + firstDay: '首次访问 {value}', + }, + trend: { + title: '区间趋势', + startDate: '开始日期', + endDate: '结束日期', + lastWeek: '最近 7 天', + last15Days: '最近 15 天', + last30Days: '最近 30 天', + last90Days: '最近 90 天', + activeDay: '活跃天数', + totalDay: '区间总天数', + maxFocus: '单日最大浏览时长', + averageFocus: '单日平均浏览时长', + maxVisit: '单日最大访问次数', + averageVisit: '单日平均访问次数', + focusTitle: '浏览时长趋势', + visitTitle: '访问次数趋势', + } + }, + zh_TW: { + common: { + focusTotal: '總計瀏覽時長', + visitTotal: '總計訪問次數', + ringGrowth: '與前期相比 {value}', + merged: '合並', + virtual: '自定義', + hostPlaceholder: '蒐索你想分析的站點', + emptyDesc: '未選擇站點', + }, + summary: { + title: '數據總覽', + day: '總計活躍天數', + firstDay: '首次訪問 {value}', + }, + trend: { + title: '區間趨勢', + startDate: '開始日期', + endDate: '結束日期', + lastWeek: '最近 7 天', + last15Days: '最近 15 天', + last30Days: '最近 30 天', + last90Days: '最近 90 天', + activeDay: '活躍天數', + totalDay: '區間總天數', + maxFocus: '單日最大瀏覽時長', + averageFocus: '單日平均瀏覽時長', + maxVisit: '單日最大訪問次數', + averageVisit: '單日平均訪問次數', + focusTitle: '瀏覽時長趨勢', + visitTitle: '訪問次數趨勢', + } + }, + en: { + common: { + focusTotal: 'Total browsing time', + visitTotal: 'Total visits', + ringGrowth: '{value} compared to the previous period', + merged: 'Merged', + virtual: 'Virtual', + hostPlaceholder: 'Search for a site to analyze', + emptyDesc: 'No site selected', + }, + summary: { + title: 'Summary', + day: 'Total active days', + firstDay: 'First visit {value}', + }, + trend: { + title: 'Trends', + startDate: 'Start date', + endDate: 'End date', + lastWeek: 'Last week', + last15Days: 'Last 15 days', + last30Days: 'Last 30 days', + last90Days: 'Last 90 days', + activeDay: 'Active days', + totalDay: 'Period days', + maxFocus: 'Daily maximum browsing time', + averageFocus: 'Daily average browsing time', + maxVisit: 'Daily maximum visits', + averageVisit: 'Daily average visits', + focusTitle: 'Browsing Time Trends', + visitTitle: 'Visit Trends', + } + }, + ja: { + common: { + focusTotal: '総閲覧時間', + visitTotal: '総訪問数', + ringGrowth: '前期比 {value}', + merged: '合并', + virtual: 'カスタマイズ', + hostPlaceholder: 'ドメイン名を検索', + emptyDesc: 'サイトは空です', + }, + summary: { + title: 'Summary', + day: 'Total active days', + firstDay: 'First visit {value}', + }, + trend: { + title: 'レンジトレンド', + startDate: '開始日', + endDate: '終了日', + lastWeek: '過去 7 日間', + last15Days: '過去 15 日間', + last30Days: '過去 30 日間', + last90Days: '過去 90 日間', + activeDay: 'アクティブな日', + totalDay: '間隔の合計日数', + maxFocus: '1 日の最大閲覧時間', + averageFocus: '1 日あたりの平均閲覧時間', + maxVisit: '1 日あたりの最大訪問数', + averageVisit: '1 日あたりの平均訪問数', + focusTitle: 'タイム トレンドの閲覧', + visitTitle: '訪問数の傾向', + } + } +} + +export default _default \ No newline at end of file diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 1a4f4ba46..5a7e79566 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -8,7 +8,7 @@ import itemMessages, { ItemMessage } from "@i18n/message/common/item" import dataManageMessages, { DataManageMessage } from "./data-manage" import reportMessages, { ReportMessage } from "./report" -import trendMessages, { TrendMessage } from "./trend" +import analysisMessages, { AnalysisMessage } from "./analysis" import menuMessages, { MenuMessage } from "./menu" import habitMessages, { HabitMessage } from "./habit" import limitMessages, { LimitMessage } from "./limit" @@ -31,7 +31,7 @@ export type AppMessage = { whitelist: WhitelistMessage mergeRule: MergeRuleMessage option: OptionMessage - trend: TrendMessage + analysis: AnalysisMessage menu: MenuMessage habit: HabitMessage limit: LimitMessage @@ -53,7 +53,7 @@ const _default: Messages = { whitelist: whitelistMessages.zh_CN, mergeRule: mergeRuleMessages.zh_CN, option: optionMessages.zh_CN, - trend: trendMessages.zh_CN, + analysis: analysisMessages.zh_CN, menu: menuMessages.zh_CN, habit: habitMessages.zh_CN, limit: limitMessages.zh_CN, @@ -73,7 +73,7 @@ const _default: Messages = { whitelist: whitelistMessages.zh_TW, mergeRule: mergeRuleMessages.zh_TW, option: optionMessages.zh_TW, - trend: trendMessages.zh_TW, + analysis: analysisMessages.zh_TW, menu: menuMessages.zh_TW, habit: habitMessages.zh_TW, limit: limitMessages.zh_TW, @@ -93,7 +93,7 @@ const _default: Messages = { whitelist: whitelistMessages.en, mergeRule: mergeRuleMessages.en, option: optionMessages.en, - trend: trendMessages.en, + analysis: analysisMessages.en, menu: menuMessages.en, habit: habitMessages.en, limit: limitMessages.en, @@ -113,7 +113,7 @@ const _default: Messages = { whitelist: whitelistMessages.ja, mergeRule: mergeRuleMessages.ja, option: optionMessages.ja, - trend: trendMessages.ja, + analysis: analysisMessages.ja, menu: menuMessages.ja, habit: habitMessages.ja, limit: limitMessages.ja, diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index e5b3847cf..aee5d5e74 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -9,7 +9,7 @@ export type MenuMessage = { dashboard: string data: string dataReport: string - dataHistory: string + siteAnalysis: string dataClear: string behavior: string habit: string @@ -31,7 +31,7 @@ const _default: Messages = { dashboard: '仪表盘', data: '我的数据', dataReport: '报表明细', - dataHistory: '历史趋势', + siteAnalysis: '站点分析', dataClear: '内存管理', additional: '附加功能', siteManage: '网站管理', @@ -51,7 +51,7 @@ const _default: Messages = { dashboard: '儀錶盤', data: '我的數據', dataReport: '報表明細', - dataHistory: '曆史趨勢', + siteAnalysis: '站點分析', dataClear: '內存管理', additional: '附加功能', siteManage: '網站管理', @@ -71,7 +71,7 @@ const _default: Messages = { dashboard: 'Dashboard', data: 'My Data', dataReport: 'Record', - dataHistory: 'Trend', + siteAnalysis: 'Site Analysis', dataClear: 'Memory Situation', behavior: 'User Behavior', habit: 'Habits', @@ -91,7 +91,7 @@ const _default: Messages = { dashboard: 'ダッシュボード', data: '私のデータ', dataReport: '報告する', - dataHistory: '歴史傾向', + siteAnalysis: 'ウェブサイト分析', dataClear: '記憶状況', behavior: 'ユーザーの行動', habit: '閲覧の習慣', diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index b71ec76ba..dd34d377e 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -73,6 +73,7 @@ export type OptionMessage = { alert: string test: string operation: string + lastTimeTip: string auto: { label: string interval: string @@ -167,6 +168,7 @@ const _default: Messages = { }, alert: '这是一项实验性功能,如果有任何问题请联系作者~ (returnzhy1996@outlook.com)', test: '测试', + lastTimeTip: '上次备份时间: {lastTime}', operation: '备份数据', auto: { label: '是否开启自动备份', @@ -253,6 +255,7 @@ const _default: Messages = { alert: '這是一項實驗性功能,如果有任何問題請聯繫作者 (returnzhy1996@outlook.com) ~', test: '測試', operation: '備份數據', + lastTimeTip: '上次備份時間: {lastTime}', auto: { label: '是否開啟自動備份', interval: '每 {input} 分鐘備份一次', @@ -338,6 +341,7 @@ const _default: Messages = { alert: 'This is an experimental feature, if you have any questions please contact the author via returnzhy1996@outlook.com~', test: 'Test', operation: 'Backup', + lastTimeTip: 'Last backup time: {lastTime}', auto: { label: 'Whether to enable automatic backup', interval: 'and run every {input} minutes', @@ -423,6 +427,7 @@ const _default: Messages = { alert: 'これは実験的な機能です。質問がある場合は、作成者に連絡してください (returnzhy1996@outlook.com)', test: 'テスト', operation: 'バックアップ', + lastTimeTip: '前回のバックアップ時間: {lastTime}', auto: { label: '自動バックアップを有効にするかどうか', interval: ' {input} 分ごとに実行', diff --git a/src/i18n/message/app/site-manage.ts b/src/i18n/message/app/site-manage.ts index d2d1266f0..51488b5ab 100644 --- a/src/i18n/message/app/site-manage.ts +++ b/src/i18n/message/app/site-manage.ts @@ -18,11 +18,7 @@ export type SiteManageMessage = { source: string icon: string } - type: { - normal: string - merged: string - virtual: string - } + type: Record<'normal' | 'merged' | 'virtual', Record<'name' | 'info', string>> source: { user: string detected: string @@ -60,9 +56,18 @@ const _default: Messages = { icon: '网站图标', }, type: { - normal: '普通站点', - merged: '合并站点', - virtual: '自定义站点', + normal: { + name: '普通站点', + info: '按域名的维度统计', + }, + merged: { + name: '合并站点', + info: '将多个相关的域名合并统计,合并规则可以自定义', + }, + virtual: { + name: '自定义站点', + info: '统计 Ant Pattern 格式的任意 URL,可以在右上角新增自定义站点', + }, }, source: { user: '手动设置', @@ -103,9 +108,18 @@ const _default: Messages = { detected: '自動抓取', }, type: { - normal: '普通站點', - merged: '合併站點', - virtual: '自定義站點', + normal: { + name: '普通站點', + info: '按域名的維度統計', + }, + merged: { + name: '合併站點', + info: '將多個相關的域名合併統計,合併規則可以自定義', + }, + virtual: { + name: '自定義站點', + info: '統計 Ant Pattern 格式的任意 URL,可以在右上角新增自定義站點', + }, }, button: { add: '新增', @@ -137,9 +151,18 @@ const _default: Messages = { icon: 'Icon', }, type: { - normal: 'normal', - merged: 'merged', - virtual: 'virtual', + normal: { + name: 'normal', + info: 'statistics by domain name', + }, + merged: { + name: 'merged', + info: 'merge statistics of multiple related domain names, and the merge rules can be customized', + }, + virtual: { + name: 'virtual', + info: 'count any URL in Ant Pattern format, you can add a custom site in the upper right corner', + }, }, source: { user: 'user-maintained', @@ -172,6 +195,8 @@ const _default: Messages = { alias: 'サイト名', aliasInfo: 'サイト名はレコードページとポップアップページに表示されます', source: 'ソース', + type: 'サイト種別', + icon: 'Icon', }, source: { user: '手动输入', @@ -191,6 +216,21 @@ const _default: Messages = { saved: '保存しました', existedTag: '既存', mergedTag: '合并', + virtualTag: 'バーチャル', + }, + type: { + normal: { + name: '普通', + info: 'ドメイン名による統計', + }, + merged: { + name: '合并', + info: '複数の関連するドメイン名のマージ統計をカスタマイズできます', + }, + virtual: { + name: 'バーチャル', + info: 'Ant Pattern 形式の任意の URL をカウントします。右上隅にカスタムサイトを追加できます', + }, }, }, } diff --git a/src/i18n/message/app/trend.ts b/src/i18n/message/app/trend.ts deleted file mode 100644 index 5aa9b77d7..000000000 --- a/src/i18n/message/app/trend.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -export type TrendMessage = { - hostPlaceholder: string - startDate: string, - endDate: string - lastWeek: string - last15Days: string - last30Days: string - last90Days: string - history: { - title: string - timeUnit: { [key in timer.app.TimeFormat]: string } - numberUnit: string - } - saveAsImageTitle: string - defaultSubTitle: string - merged: string - virtual: string -} - -const _default: Messages = { - zh_CN: { - hostPlaceholder: '搜索你想分析的域名', - startDate: '开始日期', - endDate: '结束日期', - lastWeek: '最近 7 天', - last15Days: '最近 15 天', - last30Days: '最近 30 天', - last90Days: '最近 90 天', - history: { - title: '历史记录', - timeUnit: { - default: '时长 / 秒', - second: '时长 / 秒', - minute: '时长 / 分钟', - hour: '时长 / 小时', - }, - numberUnit: '次', - }, - saveAsImageTitle: '保存', - defaultSubTitle: '请先在左上角选择需要分析的域名', - merged: '合并', - virtual: '自定义', - }, - zh_TW: { - hostPlaceholder: '蒐索你想分析的網域', - startDate: '開始日期', - endDate: '結束日期', - lastWeek: '最近 7 天', - last15Days: '最近 15 天', - last30Days: '最近 30 天', - last90Days: '最近 90 天', - history: { - title: '曆史記錄', - timeUnit: { - default: '時長 / 秒', - second: '時長 / 秒', - minute: '時長 / 分鐘', - hour: '時長 / 小時', - }, - numberUnit: '次', - }, - saveAsImageTitle: '保存', - defaultSubTitle: '請先在左上角選擇需要分析的網域', - merged: '合並', - virtual: '自定義', - }, - en: { - hostPlaceholder: 'Search site URL', - startDate: 'Start date', - endDate: 'End date', - lastWeek: 'Last week', - last15Days: 'Last 15 days', - last30Days: 'Last 30 days', - last90Days: 'Last 90 days', - history: { - title: 'Trend', - timeUnit: { - default: 'Time / second', - second: 'Time / second', - minute: 'Time / minute', - hour: 'Time / hour', - }, - numberUnit: 'Visit Counts', - }, - saveAsImageTitle: 'Snapshot', - defaultSubTitle: 'Search and select one URL to analyze on the top-left corner, pls', - merged: 'Merged', - virtual: 'Virtual', - }, - ja: { - hostPlaceholder: 'ドメイン名を検索', - startDate: '開始日', - endDate: '終了日', - lastWeek: '先週', - last15Days: '過去 15 日間', - last30Days: '過去 30 日間', - last90Days: '過去 90 日間', - history: { - title: '歴史記録', - timeUnit: { - default: '期間 / 秒', - second: '期間 / 秒', - minute: '期間 / 分', - hour: '期間 / 時間', - }, - numberUnit: '回', - }, - saveAsImageTitle: 'ダウンロード', - defaultSubTitle: 'まず、左上隅で分析するドメイン名を選択します', - merged: '合并', - virtual: 'カスタマイズ', - }, -} - -export default _default \ No newline at end of file diff --git a/src/i18n/message/common/calendar.ts b/src/i18n/message/common/calendar.ts index 4313380cb..46de6d195 100644 --- a/src/i18n/message/common/calendar.ts +++ b/src/i18n/message/common/calendar.ts @@ -9,6 +9,7 @@ export type CalendarMessage = { weekDays: string months: string dateFormat: string + timeFormat: string } const _default: Messages = { @@ -16,21 +17,25 @@ const _default: Messages = { weekDays: '星期一|星期二|星期三|星期四|星期五|星期六|星期天', months: '一月|二月|三月|四月|五月|六月|七月|八月|九月|十月|十一月|十二月', dateFormat: '{y}/{m}/{d}', + timeFormat: '{y}/{m}/{d} {h}:{i}:{s}', }, zh_TW: { weekDays: '禮拜一|禮拜二|禮拜三|禮拜四|禮拜五|禮拜六|禮拜天', months: '一月|二月|三月|四月|五月|六月|七月|八月|九月|十月|十一月|十二月', dateFormat: '{y}/{m}/{d}', + timeFormat: '{y}/{m}/{d} {h}:{i}:{s}', }, en: { weekDays: 'Mon|Tue|Wed|Thu|Fri|Sat|Sun', months: 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Set|Oct|Nov|Dec', dateFormat: '{m}/{d}/{y}', + timeFormat: '{m}/{d}/{y} {h}:{i}:{s}', }, ja: { weekDays: 'Mon|Tue|Wed|Thu|Fri|Sat|Sun', months: 'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Set|Oct|Nov|Dec', dateFormat: '{y}/{m}/{d}', + timeFormat: '{y}/{m}/{d} {h}:{i}:{s}', }, } diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index 0faab152f..c5355512c 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -18,7 +18,7 @@ export type ItemMessage = { deleteConfirmMsgAll: string deleteConfirmMsgRange: string deleteConfirmMsg: string - jumpToTrend: string + analysis: string exportWholeData: string importWholeData: string } @@ -35,7 +35,7 @@ const _default: Messages = { delete: '删除', add2Whitelist: '白名单', removeFromWhitelist: '启用', - jumpToTrend: '趋势', + analysis: '分析', deleteConfirmMsgAll: '{url} 的所有访问记录将被删除', deleteConfirmMsgRange: '{url} 在 {start} 到 {end} 的访问记录将被删除', deleteConfirmMsg: '{url} 在 {date} 的访问记录将被删除', @@ -53,7 +53,7 @@ const _default: Messages = { delete: '刪除', add2Whitelist: '白名單', removeFromWhitelist: '啟用', - jumpToTrend: '趨勢', + analysis: '分析', deleteConfirmMsgAll: '{url} 的所有拜訪記錄將被刪除', deleteConfirmMsgRange: '{url} 在 {start} 到 {end} 的拜訪記錄將被刪除', deleteConfirmMsg: '{url} 在 {date} 的拜訪記錄將被刪除', @@ -71,7 +71,7 @@ const _default: Messages = { delete: 'Delete', add2Whitelist: 'Whitelist', removeFromWhitelist: 'Enable', - jumpToTrend: 'Trend', + analysis: 'Analysis', deleteConfirmMsgAll: 'All records of {url} will be deleted!', deleteConfirmMsgRange: 'All records of {url} between {start} and {end} will be deleted!', deleteConfirmMsg: 'The record of {url} on {date} will be deleted!', @@ -89,7 +89,7 @@ const _default: Messages = { delete: '削除', add2Whitelist: 'ホワイトリスト', removeFromWhitelist: '有効にする', - jumpToTrend: '傾向', + analysis: '分析する', deleteConfirmMsgAll: '{url} のすべての拜訪記録が削除されます', deleteConfirmMsgRange: '{url} {start} から {end} までの拜訪記録は削除されます', deleteConfirmMsg: '{date} の {url} の拜訪記録は削除されます', diff --git a/src/service/stat-service/index.ts b/src/service/stat-service/index.ts index 1c16dcb56..b80f904bf 100644 --- a/src/service/stat-service/index.ts +++ b/src/service/stat-service/index.ts @@ -63,9 +63,9 @@ function calcFocusInfo(timeInfo: TimeInfo): number { return Object.values(timeInfo).reduce((a, b) => a + b, 0) } -function calcVirtualFocusInfo(data: { [host: string]: TimeInfo }): Record { +function calcVirtualFocusInfo(data: TimeInfo[]): Record { const container: Record = {} - Object.values(data).forEach(timeInfo => Object.entries(timeInfo).forEach(([url, focusTime]) => { + data.forEach(timeInfo => Object.entries(timeInfo).forEach(([url, focusTime]) => { const virtualHosts = virtualSiteHolder.findMatched(url) virtualHosts.forEach(virtualHost => (container[virtualHost] = (container[virtualHost] || 0) + focusTime)) })) @@ -81,18 +81,19 @@ function calcVirtualFocusInfo(data: { [host: string]: TimeInfo }): Record { + const dataExclusiveWhite: [string, TimeInfo][] = Object.entries(data).filter(([host]) => whitelistHolder.notContains(host)) // 1. normal sites const normalFocusInfo: Record = {} - Object.entries(data) - .filter(([host]) => whitelistHolder.notContains(host)) - .forEach(([host, timeInfo]) => normalFocusInfo[host] = resultOf(calcFocusInfo(timeInfo), 0)) + dataExclusiveWhite.forEach(([host, timeInfo]) => normalFocusInfo[host] = resultOf(calcFocusInfo(timeInfo), 0)) // 2. virtual sites - const virtualFocusInfo: Record = calcVirtualFocusInfo(data) + const virtualFocusInfo: Record = calcVirtualFocusInfo(dataExclusiveWhite.map(arr => arr[1])) return statDatabase.accumulateBatch({ ...normalFocusInfo, ...virtualFocusInfo }, new Date()) } - async addOneTime(host: string) { - statDatabase.accumulate(host, new Date(), resultOf(0, 1)) + async addOneTime(host: string, url: string) { + const hosts: string[] = [host, ...virtualSiteHolder.findMatched(url)] + const resultSet: timer.stat.ResultSet = Object.fromEntries(hosts.map(host => [host, resultOf(0, 1)])) + statDatabase.accumulateBatch(resultSet, new Date()) } /** @@ -103,8 +104,7 @@ class StatService { */ async listHosts(fuzzyQuery: string): Promise { const rows = await statDatabase.select() - const allHosts: Set = new Set() - rows.map(row => row.host).forEach(host => allHosts.add(host)) + const allHosts: Set = new Set(rows.map(row => row.host)) // Generate ruler const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) @@ -124,7 +124,11 @@ class StatService { }) const virtualSites = await siteDatabase.select({ virtual: true }) - const virtual: Set = new Set(virtualSites.map(site => site.host)) + const virtual: Set = new Set( + virtualSites + .map(site => site.host) + .filter(host => host?.includes(fuzzyQuery)) + ) return { origin, merged, virtual } } diff --git a/src/util/time.ts b/src/util/time.ts index 0a4757b0b..9954cbe47 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -213,4 +213,24 @@ export function getDayLenth(dateStart: Date, dateEnd: Date): number { } while (cursor.getTime() < dateEnd.getTime()) isSameDay(cursor, dateEnd) && dateDiff++ return dateDiff +} + +/** + * Calc the dates between {@param dateStart} and {@param dateEnd} + * + * @returns + * [20220609] if 2022-06-09 00:00:00 to 2022-06-09 00:00:01 + * [] if 2022-06-10 00:00:00 to 2022-06-09 00:00:01 + * [20221110, 20221111] if 2022-11-10 08:00:00 to 2022-11-11 00:00:01 + */ +export function getAllDatesBetween(dateStart: Date, dateEnd: Date): string[] { + const format = '{y}{m}{d}' + let cursor = new Date(dateStart) + let dates = [] + do { + dates.push(formatTime(cursor, format)) + cursor = new Date(cursor.getTime() + MILL_PER_DAY) + } while (cursor.getTime() < dateEnd.getTime()) + isSameDay(cursor, dateEnd) && dates.push(formatTime(dateEnd, format)) + return dates } \ No newline at end of file