From de89fb787a063e3169e85e6e85b9512ce832e1a6 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 14 Apr 2025 20:44:17 +0800 Subject: [PATCH] feat: support caching filter value for the report page (#449) --- package.json | 22 +-- src/i18n/element.ts | 2 +- src/i18n/message/app/option-resource.json | 2 +- .../Analysis/components/Trend/Filter.tsx | 4 +- .../ClearPanel/ClearFilter/DateFilter.tsx | 4 +- .../Report/ReportFilter/BatchDelete.tsx | 128 ++++++++++++++ .../Report/ReportFilter/DownloadFile.tsx | 68 ++++---- .../Report/ReportFilter/MergeFilterItem.tsx | 77 +++++---- .../Report/ReportFilter/RemoteClient.tsx | 47 +++-- .../components/Report/ReportFilter/index.tsx | 143 +++++----------- .../app/components/Report/ReportList/Item.tsx | 14 +- .../components/Report/ReportList/index.tsx | 2 +- .../Report/ReportTable/columns/CateColumn.tsx | 52 +++--- .../Report/ReportTable/columns/DateColumn.tsx | 4 +- .../Report/ReportTable/columns/HostColumn.tsx | 5 +- .../ReportTable/columns/OperationColumn.tsx | 9 +- .../Report/ReportTable/columns/TimeColumn.tsx | 4 +- .../ReportTable/columns/TooltipSiteList.tsx | 48 +++--- .../ReportTable/columns/VisitColumn.tsx | 2 +- .../components/Report/ReportTable/index.tsx | 36 ++-- src/pages/app/components/Report/context.ts | 100 +++++++++-- src/pages/app/components/Report/index.tsx | 161 +----------------- src/pages/app/components/Report/types.d.ts | 2 - .../common/filter/DateRangeFilterItem.tsx | 9 +- src/pages/app/styles/index.sass | 4 - src/pages/hooks/index.ts | 1 + src/pages/hooks/useLocalStorage.ts | 27 +++ 27 files changed, 498 insertions(+), 479 deletions(-) create mode 100644 src/pages/app/components/Report/ReportFilter/BatchDelete.tsx create mode 100644 src/pages/hooks/useLocalStorage.ts diff --git a/package.json b/package.json index b333a410f..6a907d97b 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,15 @@ "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/preset-env": "^7.26.9", - "@crowdin/crowdin-api-client": "^1.41.4", + "@crowdin/crowdin-api-client": "^1.42.0", "@rsdoctor/webpack-plugin": "^1.0.1", - "@swc/core": "^1.11.13", + "@swc/core": "^1.11.20", "@swc/jest": "^0.2.37", - "@types/chrome": "0.0.313", + "@types/chrome": "0.0.315", "@types/decompress": "^4.2.7", "@types/generate-json-webpack-plugin": "^0.3.7", "@types/jest": "^29.5.14", - "@types/node": "^22.13.14", + "@types/node": "^22.14.1", "@types/punycode": "^2.1.4", "@types/webpack": "^5.28.5", "@vue/babel-plugin-jsx": "^1.4.0", @@ -47,7 +47,7 @@ "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", "decompress": "^4.2.1", - "eslint": "^9.23.0", + "eslint": "^9.24.0", "filemanager-webpack-plugin": "^8.0.0", "generate-json-webpack-plugin": "^2.0.0", "html-webpack-plugin": "^5.6.3", @@ -58,23 +58,23 @@ "mini-css-extract-plugin": "^2.9.2", "postcss": "^8.5.3", "postcss-loader": "^8.1.1", - "postcss-rtlcss": "^5.6.0", - "puppeteer": "^24.4.0", - "sass": "^1.86.0", + "postcss-rtlcss": "^5.7.0", + "puppeteer": "^24.6.1", + "sass": "^1.86.3", "sass-loader": "^16.0.5", "style-loader": "^4.0.0", "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "5.8.2", + "typescript": "5.8.3", "url-loader": "^4.1.1", "web-ext": "^8.5.0", - "webpack": "^5.98.0", + "webpack": "^5.99.5", "webpack-cli": "^6.0.1" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", - "@vueuse/core": "^13.0.0", + "@vueuse/core": "^13.1.0", "countup.js": "^2.8.0", "echarts": "^5.6.0", "element-plus": "2.9.7", diff --git a/src/i18n/element.ts b/src/i18n/element.ts index d5628e4d5..ed0791364 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -24,4 +24,4 @@ export const initElementLocale = async (app: App) => { app.use(ElementPlus, { locale: EL_LOCALE }) } -export const EL_DATE_FORMAT = t(calendarMessages, { key: msg => msg.dateFormat, param: { y: 'YYYY', m: 'MM', d: 'DD' } }) +export const dateFormat = () => t(calendarMessages, { key: msg => msg.dateFormat, param: { y: 'YYYY', m: 'MM', d: 'DD' } }) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 05de37d93..9e307096d 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -350,7 +350,7 @@ "authInfo": "One token with at least gist permission is required" }, "obsidian_local_rest_api": { - "endpointInfo": "Only HTTP is available because it is not possible to configure CORS for extensions pages" + "endpointInfo": "Only HTTP is available, as CORS cannot be configured for extension pages" }, "web_dav": {} }, diff --git a/src/pages/app/components/Analysis/components/Trend/Filter.tsx b/src/pages/app/components/Analysis/components/Trend/Filter.tsx index 156b72838..bae6d0c65 100644 --- a/src/pages/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Filter.tsx @@ -6,7 +6,7 @@ */ import { t } from "@app/locale" -import { EL_DATE_FORMAT } from "@i18n/element" +import { dateFormat } from "@i18n/element" import { type ElementDatePickerShortcut } from "@pages/element-ui/date" import { getDatePickerIconSlots } from "@pages/element-ui/rtl" import { daysAgo } from "@util/time" @@ -41,7 +41,7 @@ const _default = defineComponent({ date.getTime() > new Date().getTime()} - format={EL_DATE_FORMAT} + format={dateFormat()} type="daterange" shortcuts={SHORTCUTS} rangeSeparator="-" diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index e642a987e..c395df42b 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -6,7 +6,7 @@ */ import I18nNode from "@app/components/common/I18nNode" import { t } from "@app/locale" -import { EL_DATE_FORMAT } from "@i18n/element" +import { dateFormat as elDateFormat } from "@i18n/element" import { type ElementDatePickerShortcut } from "@pages/element-ui/date" import { getDatePickerIconSlots } from "@pages/element-ui/rtl" import { formatTime, getBirthday, MILL_PER_DAY } from "@util/time" @@ -54,7 +54,7 @@ const _default = defineComponent({ style={{ width: "250px" } satisfies StyleValue} startPlaceholder={startPlaceholder} endPlaceholder={endPlaceholder} - dateFormat={EL_DATE_FORMAT} + dateFormat={elDateFormat()} type="daterange" disabledDate={(date: Date) => date.getTime() > yesterday} shortcuts={pickerShortcuts} diff --git a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx new file mode 100644 index 000000000..fc3004a15 --- /dev/null +++ b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx @@ -0,0 +1,128 @@ +import { type I18nKey, t } from "@app/locale" +import StatDatabase from "@db/stat-database" +import { DeleteFilled } from "@element-plus/icons-vue" +import statService from "@service/stat-service" +import { groupBy, sum } from "@util/array" +import { formatTime } from "@util/time" +import { ElButton, ElMessage, ElMessageBox } from "element-plus" +import { defineComponent } from "vue" +import { useReportComponent, useReportFilter } from "../context" +import type { DisplayComponent, ReportFilterOption } from "../types" + +const statDatabase = new StatDatabase(chrome.storage.local) + +async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date, Date] | undefined): Promise { + // host => total focus + const hostFocus: { [host: string]: number } = groupBy(selected, + a => a.siteKey?.host, + grouped => grouped.map(a => a.focus).reduce((a, b) => a + b, 0) + ) + const hosts = Object.keys(hostFocus) + if (!hosts.length) { + // Never happen + return t(msg => msg.report.batchDelete.noSelectedMsg) + } + const count2Delete: number = mergeDate + // All the items + ? sum(await Promise.all(Array.from(hosts).map(host => statService.count({ host, fullHost: true, date: dateRange })))) + // The count of row + : selected?.length || 0 + const i18nParam: Record = { + // count + count: count2Delete, + // example for hosts + example: hosts[0], + // Start date, if range + start: undefined, + // End date, if range + end: undefined, + // Date, if single date + date: undefined, + } + + let key: I18nKey | undefined = undefined + const hasDateRange = dateRange?.length === 2 && (dateRange[0] || dateRange[1]) + if (!hasDateRange) { + // Delete all + key = msg => msg.report.batchDelete.confirmMsgAll + } else { + const dateFormat = t(msg => msg.calendar.dateFormat) + const startDate = dateRange[0] + const endDate = dateRange[1] + const start = formatTime(startDate, dateFormat) + const end = formatTime(endDate, dateFormat) + if (start === end) { + // Single date + key = msg => msg.report.batchDelete.confirmMsg + i18nParam.date = start + } else { + // Date range + key = msg => msg.report.batchDelete.confirmMsgRange + i18nParam.start = start + i18nParam.end = end + } + } + return t(key, i18nParam) +} + +async function handleBatchDelete(displayComp: DisplayComponent | undefined, filter: ReportFilterOption) { + if (!displayComp) return + + const selected: timer.stat.Row[] = displayComp?.getSelected?.() || [] + if (!selected?.length) { + ElMessage.info(t(msg => msg.report.batchDelete.noSelectedMsg)) + return + } + const { dateRange, mergeDate } = filter + ElMessageBox({ + message: await computeBatchDeleteMsg(selected, mergeDate, dateRange), + type: "warning", + confirmButtonText: t(msg => msg.button.okey), + showCancelButton: true, + cancelButtonText: t(msg => msg.button.dont), + // Cant close this on press ESC + closeOnPressEscape: false, + // Cant close this on clicking modal + closeOnClickModal: false + }).then(async () => { + // Delete + await deleteBatch(selected, mergeDate, dateRange) + ElMessage.success(t(msg => msg.operation.successMsg)) + displayComp?.refresh?.() + }).catch(() => { + // Do nothing + }) +} + +async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date, Date] | undefined) { + if (mergeDate) { + // Delete according to the date range + const start = dateRange?.[0] + const end = dateRange?.[1] + const hosts = selected.map(d => d.siteKey?.host) + await Promise.all(hosts.map(async h => h && await statDatabase.deleteByUrlBetween(h, start, end))) + } else { + // If not merge date, batch delete + await statService.batchDelete(selected) + } +} + +const BatchDelete = defineComponent(() => { + const filter = useReportFilter() + const comp = useReportComponent() + + return () => ( + } + onClick={() => handleBatchDelete(comp.value, filter)} + > + {t(msg => msg.button.batchDelete)} + + ) +}) + +export default BatchDelete \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx b/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx index b21fb389c..aa89adddb 100644 --- a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx +++ b/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx @@ -5,41 +5,51 @@ * https://opensource.org/licenses/MIT */ +import { useCategories } from "@app/context" import { Download } from "@element-plus/icons-vue" +import statService from "@service/stat-service" import { ElButton, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { defineComponent } from "vue" -import type { FileFormat } from "../types" +import { cvtOption2Param } from "../common" +import { useReportFilter } from "../context" +import { exportCsv, exportJson } from "../file-export" import { ICON_BTN_STYLE } from "./common" -const ALL_FILE_FORMATS: FileFormat[] = ["json", "csv"] +const ALL_FILE_FORMATS = ["json", "csv"] as const +type FileFormat = typeof ALL_FILE_FORMATS[number] -const _default = defineComponent({ - emits: { - download: (_format: FileFormat) => true, - }, - setup(_, ctx) { - const handleClick = (format: FileFormat) => ctx.emit('download', format) - return () => ( - - {ALL_FILE_FORMATS.map(f => - handleClick(f)}> - {f} - - )} - - }} - > - - - - - - - ) +const DownloadFile = defineComponent(() => { + const filter = useReportFilter() + const { categories } = useCategories() + + const handleDownload = async (format: FileFormat) => { + const categoriesVal = categories.value + const param = cvtOption2Param(filter) + const rows = await statService.select(param, true) + format === 'json' && exportJson(filter, rows, categoriesVal) + format === 'csv' && exportCsv(filter, rows, categoriesVal) } + + return () => ( + + {ALL_FILE_FORMATS.map(f => + handleDownload(f)}> + {f} + + )} + + }} + > + + + + + + + ) }) -export default _default \ No newline at end of file +export default DownloadFile \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx b/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx index c44d693b1..d0380bfd6 100644 --- a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx +++ b/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx @@ -1,43 +1,50 @@ import { t } from "@app/locale" -import { useCached } from "@hooks" import Flex from "@pages/components/Flex" -import { ALL_MERGE_METHODS, processNewMethod } from "@util/merge" -import { type CheckboxValueType, ElCheckboxButton, ElCheckboxGroup, ElText } from "element-plus" -import { defineComponent, type PropType, watch } from "vue" +import { ALL_MERGE_METHODS } from "@util/merge" +import { ElCheckboxButton, ElCheckboxGroup, ElText } from "element-plus" +import { computed, defineComponent } from "vue" +import { useReportFilter } from "../context" +import type { ReportFilterOption } from "../types" import "./merge-filter-item.sass" -const MergeFilterItem = defineComponent({ - props: { - defaultValue: Array as PropType, - hideCate: Boolean, - }, - emits: { - change: (_val: timer.stat.MergeMethod[]) => true, - }, - setup(props, ctx) { - const { data, setter } = useCached('__filter_item_report_merge_method', props.defaultValue, true) - watch(data, () => ctx.emit('change', data.value || [])) - - const handleChange = (val: CheckboxValueType[]) => { - const methods = processNewMethod(data.value, val as timer.stat.MergeMethod[]) - setter?.(methods) +const MergeFilterItem = defineComponent<{ hideCate?: boolean }>(props => { + const filter = useReportFilter() + const mergeMethod = computed({ + get: () => { + const { mergeDate, siteMerge } = filter + const res: timer.stat.MergeMethod[] = [] + mergeDate && (res.push('date')) + siteMerge && (res.push(siteMerge)) + return res + }, + set: val => { + filter.mergeDate = val.includes('date') + const oldSiteMerge = filter.siteMerge + const newSiteMerge = (['cate', 'domain'] satisfies ReportFilterOption['siteMerge'][]) + .filter(t => val.includes(t)) + .sort(t => oldSiteMerge?.includes(t) ? 1 : -1)[0] + filter.siteMerge = newSiteMerge + newSiteMerge === 'domain' && oldSiteMerge !== 'domain' && (filter.cateIds = []) } + }) - return () => ( - - - {t(msg => msg.shared.merge.mergeBy)} - - - {ALL_MERGE_METHODS.filter(m => m !== 'cate' || !props.hideCate).map(method => ( - - {t(msg => msg.shared.merge.mergeMethod[method])} - - ))} - - - ) - }, -}) + return () => ( + + + {t(msg => msg.shared.merge.mergeBy)} + + mergeMethod.value = val as timer.stat.MergeMethod[]} + > + {ALL_MERGE_METHODS.filter(m => m !== 'cate' || !props.hideCate).map(method => ( + + {t(msg => msg.shared.merge.mergeMethod[method])} + + ))} + + + ) +}, { props: ['hideCate'] }) export default MergeFilterItem \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx b/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx index 7a61053ab..505872850 100644 --- a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx +++ b/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx @@ -10,35 +10,30 @@ import { UploadFilled } from "@element-plus/icons-vue" import { useRequest } from "@hooks" import statService from "@service/stat-service" import { ElButton, ElIcon, ElTooltip } from "element-plus" -import { computed, defineComponent, ref, watch } from "vue" +import { computed, defineComponent } from "vue" +import { useReportFilter } from "../context" import { ICON_BTN_STYLE } from "./common" -const _default = defineComponent({ - emits: { - change: (_readRemote: boolean) => true - }, - setup(_, ctx) { - const readRemote = ref(false) - watch(readRemote, () => ctx.emit("change", readRemote.value)) - const content = computed(() => t(msg => msg.report.remoteReading[readRemote.value ? 'on' : 'off'])) - const { data: visible } = useRequest(() => statService.canReadRemote(), { defaultValue: false }) +const _default = defineComponent(() => { + const filter = useReportFilter() + const content = computed(() => t(msg => msg.report.remoteReading[filter.readRemote ? 'on' : 'off'])) + const { data: visible } = useRequest(() => statService.canReadRemote(), { defaultValue: false }) - return () => ( - - readRemote.value = !readRemote.value} - > - - - - - - ) - } + return () => ( + + filter.readRemote = !filter.readRemote} + > + + + + + + ) }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportFilter/index.tsx b/src/pages/app/components/Report/ReportFilter/index.tsx index 90140de6c..9b19af0f8 100644 --- a/src/pages/app/components/Report/ReportFilter/index.tsx +++ b/src/pages/app/components/Report/ReportFilter/index.tsx @@ -9,20 +9,13 @@ import CategoryFilter from "@app/components/common/filter/CategoryFilter" import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterItem" import InputFilterItem from '@app/components/common/filter/InputFilterItem' import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" -import { useCategories } from "@app/context" import { t } from "@app/locale" -import { DeleteFilled } from "@element-plus/icons-vue" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { type ElementDatePickerShortcut } from "@pages/element-ui/date" -import statService from "@service/stat-service" -import { containsAny } from "@util/array" import { daysAgo } from "@util/time" -import { ElButton } from "element-plus" -import { computed, defineComponent, watch, type PropType } from "vue" -import { cvtOption2Param } from "../common" -import { exportCsv, exportJson } from "../file-export" -import type { FileFormat, ReportFilterOption } from "../types" +import { defineComponent } from "vue" +import { useReportFilter } from "../context" +import BatchDelete from "./BatchDelete" import DownloadFile from "./DownloadFile" import MergeFilterItem from "./MergeFilterItem" import RemoteClient from "./RemoteClient" @@ -40,100 +33,48 @@ const dateShortcuts: ElementDatePickerShortcut[] = [ datePickerShortcut(t(msg => msg.calendar.range.lastDays, { n: 60 }), 60), ] -const initMergeMethod = (filter: ReportFilterOption): timer.stat.MergeMethod[] => { - const { mergeDate, siteMerge } = filter || {} - const res: timer.stat.MergeMethod[] = [] - mergeDate && (res.push('date')) - siteMerge && (res.push(siteMerge)) - return res +type Props = { + hideCateFilter: boolean, } -const _default = defineComponent({ - props: { - initial: { - type: Object as PropType, - required: true, - }, - hideCateFilter: Boolean, - }, - emits: { - change: (_filterOption: ReportFilterOption) => true, - batchDelete: (_filterOption: ReportFilterOption) => true, - }, - setup(props, ctx) { - const { categories } = useCategories() - const { initial } = props +const _default = defineComponent(props => { + const { hideCateFilter } = props + const filter = useReportFilter() - const [host, setHost] = useState(initial.host) - const [dateRange, setDateRange] = useState(initial.dateRange) - const [mergeMethod, setMergeMethod] = useState(initMergeMethod(initial)) - const [cateIds, setCateIds] = useState(initial.cateIds) - const [timeFormat, setTimeFormat] = useState(initial.timeFormat) - // Whether to read remote backup data - const [readRemote, setReadRemote] = useState(initial.readRemote) - - const option = computed(() => ({ - host: host.value, - dateRange: dateRange.value, - mergeDate: mergeMethod.value.includes('date'), - siteMerge: (['domain', 'cate'] satisfies ReportFilterOption['siteMerge'][]) - .filter(t => mergeMethod.value?.includes?.(t)) - ?.[0], - timeFormat: timeFormat.value, - readRemote: readRemote.value, - cateIds: cateIds.value, - } satisfies ReportFilterOption)) - - watch(option, () => ctx.emit("change", option.value)) - - watch(mergeMethod, () => mergeMethod.value?.includes('domain') && setCateIds([])) - - const handleDownload = async (format: FileFormat) => { - const optionVal = option.value - const categoriesVal = categories.value - const param = cvtOption2Param(optionVal) - const rows = await statService.select(param, true) - format === 'json' && exportJson(optionVal, rows, categoriesVal) - format === 'csv' && exportCsv(optionVal, rows, categoriesVal) - } - - return () => ( - - - msg.item.host)} onSearch={setHost} /> - msg.calendar.label.startDate)} - endPlaceholder={t(msg => msg.calendar.label.endDate)} - disabledDate={(date: Date | number) => new Date(date) > new Date()} - shortcuts={dateShortcuts} - defaultRange={dateRange.value} - onChange={setDateRange} - /> - - - - - - } - onClick={() => ctx.emit("batchDelete", option.value)} - > - {t(msg => msg.button.batchDelete)} - - - - + return () => ( + + + msg.item.host)} + onSearch={str => filter.host = str} + /> + msg.calendar.label.startDate)} + endPlaceholder={t(msg => msg.calendar.label.endDate)} + disabledDate={(date: Date | number) => new Date(date) > new Date()} + shortcuts={dateShortcuts} + defaultRange={filter.dateRange} + onChange={val => filter.dateRange = val} + /> + filter.cateIds = val} + /> + filter.timeFormat = val} + /> + + + + + + - ) - } -}) + + ) +}, { props: ['hideCateFilter'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportList/Item.tsx b/src/pages/app/components/Report/ReportList/Item.tsx index 2474f0760..40a332849 100644 --- a/src/pages/app/components/Report/ReportList/Item.tsx +++ b/src/pages/app/components/Report/ReportList/Item.tsx @@ -23,15 +23,15 @@ const _default = defineComponent({ }, setup(props, ctx) { const filter = useReportFilter() - const mergeHost = computed(() => filter.value?.siteMerge === 'domain') - const formatter = (focus: number): string => periodFormatter(focus, { format: filter.value?.timeFormat }) + const mergeHost = computed(() => filter?.siteMerge === 'domain') + const formatter = (focus: number): string => periodFormatter(focus, { format: filter?.timeFormat }) const { siteKey, iconUrl, mergedRows, date, focus, composition, time } = props.value const selected = ref(false) watch(selected, val => ctx.emit('selectedChange', val)) - const canDelete = computed(() => !mergeHost.value && !filter.value.readRemote) + const canDelete = computed(() => !mergeHost.value && !filter.readRemote) const onDelete = async () => { - await handleDelete(props.value, filter.value) + await handleDelete(props.value, filter) ctx.emit('delete', props.value) } return () => ( @@ -66,7 +66,7 @@ const _default = defineComponent({ } buttonType="danger" - confirmText={computeDeleteConfirmMsg(props.value, filter.value)} + confirmText={computeDeleteConfirmMsg(props.value, filter)} visible={canDelete.value} onConfirm={onDelete} text @@ -74,7 +74,7 @@ const _default = defineComponent({
- + {cvt2LocaleTime(date)} @@ -89,7 +89,7 @@ const _default = defineComponent({ > - {periodFormatter(focus, { format: filter.value?.timeFormat })} + {periodFormatter(focus, { format: filter?.timeFormat })} cvtOption2Param(filterOption.value)) + const param = computed(() => cvtOption2Param(filterOption)) const { data, loading, loadMoreAsync, end, reset } = useScrollRequest(async (num, size) => { const pagination = await statService.selectByPage(param.value, { num, size }) diff --git a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx index 4fb7468a9..390f2f32c 100644 --- a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx @@ -41,33 +41,31 @@ const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: tim ) } -const CateColumn = defineComponent({ - emits: { - change: (_siteKey: timer.site.SiteKey, _newCate: number | undefined) => true, - }, - setup(_, ctx) { - const { categories } = useCategories() +type Props = { + onChange: (key: timer.site.SiteKey, newCate: number | undefined) => void, +} - return () => ( - msg.siteManage.column.cate)} minWidth={140}> - {({ row }: ElTableRowScope) => { - const { cateId, cateKey, mergedRows, siteKey } = row || {} - return ( - - {cateKey - ? renderMerged(cateKey, categories.value, mergedRows ?? []) - : ctx.emit('change', siteKey!, newCateId)} - /> - } - - ) - }} - - ) - } -}) +const CateColumn = defineComponent(props => { + const { categories } = useCategories() + return () => ( + msg.siteManage.column.cate)} minWidth={140}> + {({ row }: ElTableRowScope) => { + const { cateId, cateKey, mergedRows, siteKey } = row || {} + return ( + + {cateKey + ? renderMerged(cateKey, categories.value, mergedRows ?? []) + : props.onChange(siteKey!, newCateId)} + /> + } + + ) + }} + + ) +}, { props: ['onChange'] }) export default CateColumn \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx index 87f5f3275..ea09ec1d3 100644 --- a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx @@ -12,13 +12,11 @@ import { ElTableColumn } from "element-plus" import { defineComponent } from "vue" import type { ReportSort } from "../../types" -const columnLabel = t(msg => msg.item.date) - const _default = defineComponent(() => { return () => ( msg.item.date)} minWidth={135} align="center" sortable="custom" diff --git a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx index 666b37f81..4464639d4 100644 --- a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx @@ -30,7 +30,7 @@ const _default = defineComponent(() => { {({ row: { mergedRows, siteKey, iconUrl } }: ElTableRowScope) => ( { }} /> - ) - } + )} ) }) diff --git a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx index dbd28fbc9..b31a8fe75 100644 --- a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx @@ -19,7 +19,6 @@ import { computed, defineComponent } from "vue" import { useRouter } from "vue-router" import { computeDeleteConfirmMsg, handleDelete } from "../../common" import { useReportFilter } from "../../context" -import Flex from "@pages/components/Flex" const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { en: 330, @@ -41,7 +40,7 @@ const _default = defineComponent({ }, setup(_, ctx) { const filter = useReportFilter() - const canOperate = computed(() => !filter.value?.siteMerge) + const canOperate = computed(() => !filter?.siteMerge) const width = computed(() => canOperate.value ? LOCALE_WIDTH[locale] : 110) const router = useRouter() const { @@ -51,7 +50,7 @@ const _default = defineComponent({ const jump2Analysis = (row: timer.stat.Row) => { let query: AnalysisQuery - const siteMerge = filter.value?.siteMerge + const siteMerge = filter?.siteMerge if (siteMerge === 'cate') { query = { cateId: row?.cateKey?.toString?.() } } else { @@ -83,10 +82,10 @@ const _default = defineComponent({ buttonIcon={} buttonType="danger" buttonText={t(msg => msg.button.delete)} - confirmText={computeDeleteConfirmMsg(row, filter.value)} + confirmText={computeDeleteConfirmMsg(row, filter)} visible={canOperate.value} onConfirm={async () => { - await handleDelete(row, filter.value) + await handleDelete(row, filter) ctx.emit("delete", row) }} /> diff --git a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx index dd5b3e2b7..409396af5 100644 --- a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx @@ -24,7 +24,7 @@ const TimeColumn = defineComponent({ }, setup(props) { const filter = useReportFilter() - const formatter = (focus: number | undefined): string => periodFormatter(focus, { format: filter.value?.timeFormat }) + const formatter = (focus: number | undefined): string => periodFormatter(focus, { format: filter.timeFormat }) return () => ( {({ row }: ElTableRowScope) => ( , - clickDisabled: Boolean, - }, - setup(props) { - return () => { - const siteMap = new SiteMap() - props.modelValue?.forEach(({ siteKey, iconUrl }) => siteKey && siteMap.put(siteKey, iconUrl)) +type Props = { + modelValue?: timer.stat.Row[] + clickDisabled?: boolean +} - return ( - - - {siteMap?.map((siteKey, iconUrl) => ( - - ))} - - - ) - } - }, -}) +const TooltipSiteList = defineComponent(props => { + const { modelValue, clickDisabled: clickable } = toRefs(props) + const iconMap = computed(() => { + const siteMap = new SiteMap() + modelValue?.value?.forEach(({ siteKey, iconUrl }) => siteKey && siteMap.put(siteKey, iconUrl)) + return siteMap + }) + return () => ( + + + {iconMap.value?.map((key, icon) => ( + + ))} + + + ) +}, { props: ['clickDisabled', 'modelValue'] }) export default TooltipSiteList \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx index 86e74b094..478ba3166 100644 --- a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx @@ -26,7 +26,7 @@ const VisitColumn = defineComponent(() => { > {({ row }: ElTableRowScope) => ( , - required: true, - }, - }, - setup(props, ctx) { + setup(_, ctx) { const [page, setPage] = useState({ size: 10, num: 1 }) - const [sort, setSort] = useState(props.defaultSort) + const sort = useReportSort() const filterOption = useReportFilter() - const queryParam = computed(() => computeTimerQueryParam(filterOption.value, sort.value)) + const queryParam = computed(() => computeTimerQueryParam(filterOption, sort.value)) const { data, refresh } = useRequest( () => statService.selectByPage(queryParam.value, page.value), { loadingTarget: "#report-table-content", deps: [queryParam, page] }, @@ -73,8 +67,8 @@ const _default = defineComponent({ const tableRef = ref() // Force to re-layout after merge change watch([ - () => filterOption.value?.mergeDate, - () => filterOption.value?.siteMerge, + () => filterOption?.mergeDate, + () => filterOption?.siteMerge, ], () => tableRef.value?.doLayout?.()) const handleCateChange = (key: timer.site.SiteKey, newCateId: number | undefined) => { @@ -88,17 +82,15 @@ const _default = defineComponent({ setSort(newSortInfo)} + onSort-change={(val: ReportSort) => sort.value = val} > - {!filterOption.value?.siteMerge && } - {!filterOption.value?.mergeDate && } - {filterOption.value?.siteMerge !== 'cate' && <> + {!filterOption?.siteMerge && } + {!filterOption?.mergeDate && } + {filterOption?.siteMerge !== 'cate' && <> msg.siteManage.column.alias)} @@ -112,7 +104,7 @@ const _default = defineComponent({ )} /> } - {filterOption.value?.siteMerge !== 'domain' && } + {filterOption?.siteMerge !== 'domain' && } {runColVisible.value && } diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Report/context.ts index 905365b3a..9ff70aed5 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Report/context.ts @@ -1,17 +1,97 @@ -import { useProvide, useProvider } from "@hooks" -import { type Ref } from "vue" -import type { ReportFilterOption } from "./types" +import { useLocalStorage, useProvide, useProvider } from "@hooks" +import { reactive, type Reactive, ref, type Ref, toRaw, watch } from "vue" +import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" +import type { DisplayComponent, ReportFilterOption, ReportQueryParam, ReportSort } from "./types" type Context = { - filter: Ref + filter: Reactive + sort: Ref + comp: Ref } const NAMESPACE = 'report' -export const initProvider = ( - filter: Ref, -) => useProvide(NAMESPACE, { - filter -}) +type QueryPartial = Partial> -export const useReportFilter = (): Ref => useProvider(NAMESPACE, "filter").filter \ No newline at end of file +/** + * Init the query parameters + */ +function parseQuery(route: RouteLocation, router: Router): [QueryPartial, ReportSort['prop'] | undefined] { + const routeQuery = route.query as unknown as ReportQueryParam + const { mh, md, ds, de, sc } = routeQuery + const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined + const dateEnd = de ? new Date(Number.parseInt(de)) : undefined + // Remove queries + router.replace({ query: {} }) + + let siteMerge: ReportFilterOption['siteMerge'] + if (mh === "true" || mh === "1") siteMerge = 'domain' + + const now = new Date() + const partial: QueryPartial = { + ...((md === 'true' || md === '1') && { mergeDate: true }), + ...((dateStart ?? dateEnd) && { dateRange: [dateStart ?? now, dateEnd ?? now] }), + ...(siteMerge && { siteMerge }) + } + return [partial, sc ? sc satisfies ReportSort['prop'] : undefined] +} + +type FilterStorageValue = Omit & { + dateStart?: number + dateEnd?: number +} + +const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilterOption => { + const { host, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage || {} + const now = new Date() + return { + host, + dateRange: [dateStart ? new Date(dateStart) : now, dateEnd ? new Date(dateEnd) : now], + mergeDate: mergeDate ?? false, + siteMerge, + cateIds, + timeFormat: timeFormat ?? 'default', + readRemote: false, + } +} + +const cvtFilter2Storage = (filter: ReportFilterOption): FilterStorageValue => { + const { host, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter + return { + host, + mergeDate, siteMerge, + dateStart: dateRange?.[0]?.getTime?.(), + dateEnd: dateRange?.[1]?.getTime?.(), + cateIds, timeFormat, + } +} + +export const initReportContext = () => { + const route = useRoute() + const router = useRouter() + const [queryFilter, querySort] = parseQuery(route, router) + + const [cachedFilter, setCachedFilter] = useLocalStorage('report_filter') + + const initialFilter: ReportFilterOption = { ...cvtStorage2Filter(cachedFilter), ...queryFilter } + const filter = reactive(initialFilter) + watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) + + const sort = ref({ + order: 'descending', + prop: querySort || 'focus' + }) + + const comp = ref() + + const context: Context = { filter, sort, comp } + useProvide(NAMESPACE, context) + + return context +} + +export const useReportFilter = (): Reactive => useProvider(NAMESPACE, "filter").filter + +export const useReportSort = (): Ref => useProvider(NAMESPACE, 'sort').sort + +export const useReportComponent = () => useProvider(NAMESPACE, 'comp').comp \ No newline at end of file diff --git a/src/pages/app/components/Report/index.tsx b/src/pages/app/components/Report/index.tsx index 7b2399f28..027d7b094 100644 --- a/src/pages/app/components/Report/index.tsx +++ b/src/pages/app/components/Report/index.tsx @@ -5,172 +5,23 @@ * https://opensource.org/licenses/MIT */ -import { type I18nKey, t } from "@app/locale" -import StatDatabase from "@db/stat-database" -import { useMediaSize, useState } from "@hooks" +import { useMediaSize } from "@hooks" import { MediaSize } from "@hooks/useMediaSize" -import statService from "@service/stat-service" -import { groupBy, sum } from "@util/array" -import { formatTime } from "@util/time" -import { ElMessage, ElMessageBox } from "element-plus" -import { computed, defineComponent, ref } from "vue" -import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" +import { computed, defineComponent } from "vue" import ContentContainer from "../common/ContentContainer" -import { initProvider } from "./context" +import { initReportContext } from "./context" import ReportFilter from "./ReportFilter" import ReportList from "./ReportList" import ReportTable from "./ReportTable" -import type { DisplayComponent, ReportFilterOption, ReportQueryParam, ReportSort } from "./types" - -const statDatabase = new StatDatabase(chrome.storage.local) - -async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date, Date] | undefined): Promise { - // host => total focus - const hostFocus: { [host: string]: number } = groupBy(selected, - a => a.siteKey?.host, - grouped => grouped.map(a => a.focus).reduce((a, b) => a + b, 0) - ) - const hosts = Object.keys(hostFocus) - if (!hosts.length) { - // Never happen - return t(msg => msg.report.batchDelete.noSelectedMsg) - } - const count2Delete: number = mergeDate - // All the items - ? sum(await Promise.all(Array.from(hosts).map(host => statService.count({ host, fullHost: true, date: dateRange })))) - // The count of row - : selected?.length || 0 - const i18nParam: Record = { - // count - count: count2Delete, - // example for hosts - example: hosts[0], - // Start date, if range - start: undefined, - // End date, if range - end: undefined, - // Date, if single date - date: undefined, - } - - let key: I18nKey | undefined = undefined - const hasDateRange = dateRange?.length === 2 && (dateRange[0] || dateRange[1]) - if (!hasDateRange) { - // Delete all - key = msg => msg.report.batchDelete.confirmMsgAll - } else { - const dateFormat = t(msg => msg.calendar.dateFormat) - const startDate = dateRange[0] - const endDate = dateRange[1] - const start = formatTime(startDate, dateFormat) - const end = formatTime(endDate, dateFormat) - if (start === end) { - // Single date - key = msg => msg.report.batchDelete.confirmMsg - i18nParam.date = start - } else { - // Date range - key = msg => msg.report.batchDelete.confirmMsgRange - i18nParam.start = start - i18nParam.end = end - } - } - return t(key, i18nParam) -} - -async function handleBatchDelete(displayComp: DisplayComponent, filterOption: ReportFilterOption) { - const selected: timer.stat.Row[] = displayComp?.getSelected?.() || [] - if (!selected?.length) { - ElMessage.info(t(msg => msg.report.batchDelete.noSelectedMsg)) - return - } - const { dateRange, mergeDate } = filterOption - ElMessageBox({ - message: await computeBatchDeleteMsg(selected, mergeDate, dateRange), - type: "warning", - confirmButtonText: t(msg => msg.button.okey), - showCancelButton: true, - cancelButtonText: t(msg => msg.button.dont), - // Cant close this on press ESC - closeOnPressEscape: false, - // Cant close this on clicking modal - closeOnClickModal: false - }).then(async () => { - // Delete - await deleteBatch(selected, mergeDate, dateRange) - ElMessage.success(t(msg => msg.operation.successMsg)) - displayComp?.refresh?.() - }).catch(() => { - // Do nothing - }) -} - -async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date, Date] | undefined) { - if (mergeDate) { - // Delete according to the date range - const start = dateRange?.[0] - const end = dateRange?.[1] - const hosts = selected.map(d => d.siteKey?.host) - await Promise.all(hosts.map(async h => h && await statDatabase.deleteByUrlBetween(h, start, end))) - } else { - // If not merge date, batch delete - await statService.batchDelete(selected) - } -} - -/** - * Init the query parameters - */ -function initQueryParam(route: RouteLocation, router: Router): [ReportFilterOption, ReportSort] { - const routeQuery: ReportQueryParam = route.query as unknown as ReportQueryParam - const { mh, md, ds, de, sc } = routeQuery - const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined - const dateEnd = de ? new Date(Number.parseInt(de)) : undefined - // Remove queries - router.replace({ query: {} }) - - let siteMerge: ReportFilterOption['siteMerge'] - if (mh === "true" || mh === "1") siteMerge = 'domain' - - const now = new Date() - const filterOption: ReportFilterOption = { - host: '', - dateRange: [dateStart || now, dateEnd || now], - mergeDate: md === "true" || md === "1", - siteMerge, - timeFormat: "default" - } - const sortInfo: ReportSort = { - prop: sc || 'focus', - order: 'descending' - } - return [filterOption, sortInfo] -} const _default = defineComponent(() => { - const route = useRoute() - const router = useRouter() - const [initialFilterParam, initialSort] = initQueryParam(route, router) - const [filterOption, setFilterOption] = useState(initialFilterParam) - initProvider(filterOption) - - const displayComp = ref() - + const { comp } = initReportContext() const mediaSize = useMediaSize() const isXs = computed(() => mediaSize.value === MediaSize.xs) return () => ( - displayComp.value && handleBatchDelete(displayComp.value, filterOption)} - hideCateFilter={isXs.value} - /> - ), - default: () => isXs.value - ? - : + filter: () => , + default: () => isXs.value ? : }} /> }) diff --git a/src/pages/app/components/Report/types.d.ts b/src/pages/app/components/Report/types.d.ts index 09e6c92ad..387263708 100644 --- a/src/pages/app/components/Report/types.d.ts +++ b/src/pages/app/components/Report/types.d.ts @@ -1,7 +1,5 @@ import type { Sort } from "element-plus" -export type FileFormat = 'json' | 'csv' - export type ReportSort = Omit & { prop: timer.core.Dimension | 'host' | 'date' } diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx index 6e2172ffe..8abbc08f3 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx @@ -6,11 +6,11 @@ */ import { t } from "@app/locale" -import { EL_DATE_FORMAT } from "@i18n/element" +import { dateFormat } from "@i18n/element" import { type ElementDatePickerShortcut } from "@pages/element-ui/date" import { getDatePickerIconSlots } from "@pages/element-ui/rtl" import { ElDatePicker } from "element-plus" -import { defineComponent, type PropType, ref, type Ref } from "vue" +import { defineComponent, type PropType, ref, StyleValue } from "vue" const clearShortcut = (): ElementDatePickerShortcut => ({ text: t(msg => msg.button.clear), @@ -53,7 +53,7 @@ const _default = defineComponent({ return () => diff --git a/src/pages/app/styles/index.sass b/src/pages/app/styles/index.sass index 6d4c723e5..ab15a5065 100644 --- a/src/pages/app/styles/index.sass +++ b/src/pages/app/styles/index.sass @@ -54,10 +54,6 @@ a .el-picker-panel [slot="sidebar"] + .el-picker-panel__body, .el-picker-panel__sidebar + .el-picker-panel__body margin-left: 130px !important - .el-range-input - width: 120px - .el-date-editor.el-input__wrapper - width: 280px .el-button+.el-button margin-left: 0px diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index b3b8e7fb8..1e37e4efc 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -41,3 +41,4 @@ export * from "./useRequest" export * from "./useShadow" export * from "./useState" export * from "./useSwitch" +export * from "./useLocalStorage" \ No newline at end of file diff --git a/src/pages/hooks/useLocalStorage.ts b/src/pages/hooks/useLocalStorage.ts new file mode 100644 index 000000000..c0c676078 --- /dev/null +++ b/src/pages/hooks/useLocalStorage.ts @@ -0,0 +1,27 @@ +type StoragePrimitive = string | boolean | number | undefined +type StorageArray = Array +type StorageObject = { [key: string]: StorageValue } +type StorageValue = + | StoragePrimitive + | StorageArray + | StorageObject + +export const useLocalStorage = (key: string, defaultVal?: T): [data: T | undefined, setter: (val: T | undefined) => void] => { + const setter = (val: T | undefined) => { + if (val === undefined || val === null) { + localStorage?.removeItem(key) + } else { + localStorage?.setItem(key, JSON.stringify(val)) + } + } + let storedVal = localStorage.getItem(key) + if (!storedVal) { + return [defaultVal, setter] + } + + try { + return [JSON.parse(storedVal), setter] + } catch { } + + return [defaultVal, setter] +} \ No newline at end of file