From eea975baff4e38a2f2b19f6cd3d353ad6f35d3be Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 22 May 2026 17:25:30 +0800 Subject: [PATCH 01/95] style: optimize UI --- .../Limit/components/Modify/Step3/PeriodInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 742a75548..6c409502f 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -128,7 +128,7 @@ const PeriodInput = defineComponent>(props => { return () => ( - + {props.modelValue?.map((p, idx) => >(props => { style={{ ...BUTTON_STYLE, marginInlineStart: 0 } satisfies StyleValue} /> -
+ >(props => { > {t(msg => msg.button.create)} -
+
) }, { props: ['modelValue', 'onChange'] }) From 06f47b96776d8133ef354f896740e33cfe1c8514 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 23 May 2026 00:15:51 +0800 Subject: [PATCH 02/95] v4.3.3 --- CHANGELOG.md | 5 +++++ package.json | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e825c2e98..d72a500e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.3] - 2026-05-23 + +- Added Norwegian Bokmal, Hungarian, Indonesian to translate +- Added mark line for habit average chart (#778) + ## [4.3.2] - 2026-05-16 - Urgently fixed some bugs diff --git a/package.json b/package.json index c20c16056..5ef3742f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.2", + "version": "4.3.3", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -34,8 +34,8 @@ "@rsdoctor/rspack-plugin": "^1.5.11", "@rspack/cli": "^2.0.4", "@rspack/core": "^2.0.4", - "@rstest/core": "^0.10.1", - "@rstest/coverage-istanbul": "^0.10.1", + "@rstest/core": "^0.10.2", + "@rstest/coverage-istanbul": "^0.10.2", "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", @@ -50,7 +50,7 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.14.1", + "knip": "^6.14.2", "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", From b7f1d9fd71bd2a21ec799fb705fcf2ee3e168201 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 23 May 2026 17:28:16 +0800 Subject: [PATCH 03/95] refactor: use guards for useLocaleStorage --- package.json | 2 +- src/background/database/limit-database.ts | 10 +++---- .../database/stat-database/classic.ts | 3 +-- .../database/stat-database/index.ts | 3 +-- src/pages/app/components/Analysis/context.ts | 7 ++--- .../Dashboard/components/TopKVisit/context.ts | 9 ++++++- .../app/components/Habit/Period/Filter.tsx | 7 +++-- .../app/components/Habit/Period/context.ts | 17 ++++++++++-- .../app/components/Habit/Period/types.d.ts | 6 ----- src/pages/app/components/Report/context.ts | 26 +++++++++++++++---- .../components/SiteManage/useSiteManage.ts | 8 +++++- src/pages/app/util/limit/types.ts | 11 +++++--- src/pages/hooks/useLocalStorage.ts | 16 +++++++----- src/pages/popup/common.tsx | 7 ++--- src/pages/popup/context.ts | 24 ++++++++++++++--- src/pages/popup/router.ts | 7 +++-- src/util/guard.ts | 4 +-- 17 files changed, 111 insertions(+), 56 deletions(-) delete mode 100644 src/pages/app/components/Habit/Period/types.d.ts diff --git a/package.json b/package.json index 5ef3742f0..7f5b4a477 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "element-plus": "2.14.0", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", - "typescript-guard": "0.2.4", + "typescript-guard": "0.2.5", "vue": "^3.5.34", "vue-router": "^5.0.7" }, diff --git a/src/background/database/limit-database.ts b/src/background/database/limit-database.ts index b2a3911d8..d5d37ef0a 100644 --- a/src/background/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { isOptionalInt, isRecord, isVector2 } from '@util/guard' +import { isRecord, isVector2 } from '@util/guard' import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isBoolean, isInt, isString } from 'typescript-guard' +import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isInt, isOptionalBoolean, isOptionalInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' @@ -38,10 +38,10 @@ const isValidRow = createObjectGuard({ weekly: isOptionalInt, weeklyCount: isOptionalInt, visitTime: isOptionalInt, - enabled: createOptionalGuard(isBoolean), - locked: createOptionalGuard(isBoolean), + enabled: isOptionalBoolean, + locked: isOptionalBoolean, weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), - allowDelay: createOptionalGuard(isBoolean), + allowDelay: isOptionalBoolean, periods: createOptionalGuard(createArrayGuard(isVector2)), }) diff --git a/src/background/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts index 4b6403d89..9898869b9 100644 --- a/src/background/database/stat-database/classic.ts +++ b/src/background/database/stat-database/classic.ts @@ -1,8 +1,7 @@ import { log } from '@/common/logger' -import { isOptionalInt } from '@util/guard' import { escapeRegExp } from '@util/pattern' import { isNotZeroResult } from '@util/stat' -import { createObjectGuard } from 'typescript-guard' +import { createObjectGuard, isOptionalInt } from 'typescript-guard' import BaseDatabase from '../common/base-database' import { REMAIN_WORD_PREFIX } from '../common/constant' import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroResult } from './common' diff --git a/src/background/database/stat-database/index.ts b/src/background/database/stat-database/index.ts index e39db8d98..f22b15ced 100644 --- a/src/background/database/stat-database/index.ts +++ b/src/background/database/stat-database/index.ts @@ -5,9 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { isOptionalInt } from '@util/guard' import { isNotZeroResult } from '@util/stat' -import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { createArrayGuard, createObjectGuard, isOptionalInt, isString } from 'typescript-guard' import { extractNamespace, isExportData, isLegacyVersion } from '../common/migratable' import { StorageHolder } from '../common/storage-holder' import type { BrowserMigratable, StorageMigratable } from '../types' diff --git a/src/pages/app/components/Analysis/context.ts b/src/pages/app/components/Analysis/context.ts index a9bdebcce..57423035b 100644 --- a/src/pages/app/components/Analysis/context.ts +++ b/src/pages/app/components/Analysis/context.ts @@ -7,6 +7,7 @@ import { type AppAnalysisQuery } from '@/shared/route' import { listCateStats, listSiteStats } from "@api/sw/stat" +import { isTimeFormat } from '@app/util/limit/types' import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" import { extractHostname } from '@util/pattern' import { ref, watch, type Ref } from "vue" @@ -50,9 +51,9 @@ const NAMESPACE = 'siteAnalysis' export const initAnalysis = () => { const target = ref(parseQuery()) - const [cachedFormat, setFormatCache] = useLocalStorage('analysis_timeFormat') - const timeFormat = ref(cachedFormat ?? 'default') - watch(timeFormat, setFormatCache) + const [cached, setCached] = useLocalStorage('analysis_timeFormat', isTimeFormat, 'default') + const timeFormat = ref(cached) + watch(timeFormat, setCached) const { data: rows, loading } = useRequest(() => queryRows(target.value), { deps: target, defaultValue: [] }) useProvide(NAMESPACE, { target, timeFormat, rows }) diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts index 7291f59ca..1b3659e39 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts @@ -1,6 +1,7 @@ import { getSiteStatPage } from "@api/sw/stat" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" +import { createObjectGuard, createStringUnionGuard, isInt } from 'typescript-guard' import { reactive, type ShallowRef, toRaw, watch } from "vue" export type BizOption = { @@ -12,12 +13,18 @@ export type BizOption = { } export type TopKChartType = 'bar' | 'pie' | 'halfPie' +const isTopKChartType = createStringUnionGuard('bar', 'pie', 'halfPie') export type TopKFilterOption = { topK: number dayNum: number topKChartType: TopKChartType } +const isTopKFilterOption = createObjectGuard({ + topK: isInt, + dayNum: isInt, + topKChartType: isTopKChartType, +}) type Context = { value: ShallowRef @@ -28,7 +35,7 @@ const NAMESPACE = 'dashboardTopKVisit' export const initProvider = () => { const [cachedFilter, setFilterCache] = useLocalStorage( - 'habit_period_filter', { topK: 6, dayNum: 30, topKChartType: 'pie' } + `${NAMESPACE}_filter`, isTopKFilterOption, { topK: 6, dayNum: 30, topKChartType: 'pie' } ) const filter = reactive(cachedFilter) watch(() => filter, () => setFilterCache(toRaw(filter)), { deep: true }) diff --git a/src/pages/app/components/Habit/Period/Filter.tsx b/src/pages/app/components/Habit/Period/Filter.tsx index a5111cb65..50cbdb97b 100644 --- a/src/pages/app/components/Habit/Period/Filter.tsx +++ b/src/pages/app/components/Habit/Period/Filter.tsx @@ -11,8 +11,7 @@ import { type HabitMessage } from '@i18n/message/app/habit' import Flex from '@pages/components/Flex' import { ElRadioButton, ElRadioGroup } from 'element-plus' import { defineComponent } from 'vue' -import { usePeriodFilter } from './context' -import type { ChartType } from './types' +import { type ChartType, isChartType, usePeriodFilter } from './context' // [value, label] type _SizeOption = [number, keyof HabitMessage['period']['sizes']] @@ -31,7 +30,7 @@ function allOptions(): Record { return allOptions } -const CHART_CONFIG: { [type in ChartType]: string } = { +const CHART_CONFIG: Record = { average: t(msg => msg.habit.period.chartType.average), trend: t(msg => msg.habit.period.chartType.trend), stack: t(msg => msg.habit.period.chartType.stack), @@ -55,7 +54,7 @@ const _default = defineComponent(() => { /> val && (filter.chartType = val as ChartType)} + onChange={val => isChartType(val) && (filter.chartType = val)} > {Object.entries(CHART_CONFIG).map(([type, name]) => ( diff --git a/src/pages/app/components/Habit/Period/context.ts b/src/pages/app/components/Habit/Period/context.ts index 91c2eb864..08a95506f 100644 --- a/src/pages/app/components/Habit/Period/context.ts +++ b/src/pages/app/components/Habit/Period/context.ts @@ -9,9 +9,22 @@ import { listPeriods } from '@api/sw/period' import { useLocalStorage, useProvide, useProvider, useRequest } from '@hooks' import { keyOf, MAX_PERIOD_ORDER } from "@util/period" import { getDayLength, MILL_PER_DAY } from "@util/time" +import { createObjectGuard, createStringUnionGuard, isInt } from 'typescript-guard' import { computed, reactive, toRaw, watch, type Reactive, type Ref } from "vue" import { useHabitFilter } from "../context" -import type { FilterOption } from "./types" + +export type ChartType = 'average' | 'trend' | 'stack' +export const isChartType = createStringUnionGuard('average', 'trend', 'stack') + +type FilterOption = { + periodSize: number + chartType: ChartType +} + +const isFilterOption = createObjectGuard({ + periodSize: isInt, + chartType: isChartType, +}) type Value = { curr: tt4b.period.Row[] @@ -46,7 +59,7 @@ export const initProvider = () => { const globalFilter = useHabitFilter() const periodRange = computed(() => computeRange(globalFilter.dateRange)) const [cachedFilter, setFilterCache] = useLocalStorage( - 'habit_period_filter', { periodSize: 1, chartType: 'average' } + 'habit_period_filter', isFilterOption, { periodSize: 1, chartType: 'average' } ) const filter = reactive(cachedFilter) watch(() => filter, () => setFilterCache(toRaw(filter)), { deep: true }) diff --git a/src/pages/app/components/Habit/Period/types.d.ts b/src/pages/app/components/Habit/Period/types.d.ts deleted file mode 100644 index cc0e80f5f..000000000 --- a/src/pages/app/components/Habit/Period/types.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type ChartType = 'average' | 'trend' | 'stack' - -export type FilterOption = { - periodSize: number - chartType: ChartType -} diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Report/context.ts index e33e0ffb6..4965a6db2 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Report/context.ts @@ -1,5 +1,11 @@ +import { isOptionalIntArray, isTimeFormat } from '@app/util/limit/types' import { useLocalStorage, useProvide, useProvider } from '@hooks' -import { createStringUnionGuard, isString } from 'typescript-guard' +import { + createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, + isOptionalInt, + isOptionalString, + isString, +} from 'typescript-guard' import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" import type { DisplayComponent, ReportFilterOption, ReportSort } from "./types" @@ -40,12 +46,22 @@ function parseQuery(route: RouteLocation, router: Router): [QueryPartial, Report return [partial, isSortProp(sc) ? sc : undefined] } -type FilterStorageValue = Omit & { +type CacheValue = Omit & { dateStart?: number dateEnd?: number } -const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilterOption => { +const isCacheValue = createObjectGuard({ + query: isOptionalString, + mergeDate: isBoolean, + siteMerge: createOptionalGuard(isSiteMerge), + cateIds: isOptionalIntArray, + timeFormat: isTimeFormat, + dateStart: isOptionalInt, + dateEnd: isOptionalInt, +}) + +const cvtStorage2Filter = (storage: CacheValue | undefined): ReportFilterOption => { const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage ?? {} const now = new Date() return { @@ -59,7 +75,7 @@ const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilte } } -const cvtFilter2Storage = (filter: ReportFilterOption): FilterStorageValue => { +const cvtFilter2Storage = (filter: ReportFilterOption): CacheValue => { const { query, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter const [dateStart, dateEnd] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] return { @@ -76,7 +92,7 @@ export const initReportContext = () => { const router = useRouter() const [queryFilter, querySort] = parseQuery(route, router) - const [cachedFilter, setCachedFilter] = useLocalStorage('report_filter') + const [cachedFilter, setCachedFilter] = useLocalStorage('report_filter', isCacheValue) const filter = reactive({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) diff --git a/src/pages/app/components/SiteManage/useSiteManage.ts b/src/pages/app/components/SiteManage/useSiteManage.ts index 4d497dd5d..abc279462 100644 --- a/src/pages/app/components/SiteManage/useSiteManage.ts +++ b/src/pages/app/components/SiteManage/useSiteManage.ts @@ -1,5 +1,7 @@ import { getSitePage } from '@api/sw/site' +import { isOptionalIntArray } from '@app/util/limit/types' import { type RequestOption, useLocalStorage, useProvide, useProvider, useRequest, useState } from '@hooks' +import { createObjectGuard } from 'typescript-guard' import { reactive, type ShallowRef, watch } from 'vue' type FilterOption = { @@ -12,6 +14,10 @@ type CacheValue = { cateIds?: number[] } +const isCacheValue = createObjectGuard({ + cateIds: isOptionalIntArray, +}) + type Context = { pagination: ShallowRef> filter: FilterOption @@ -23,7 +29,7 @@ type Context = { const NAMESPACE = 'site-manage' export const initSiteManage = (loadingTarget: RequestOption['loadingTarget']) => { - const [cache, setCache] = useLocalStorage('site-manage-filter') + const [cache, setCache] = useLocalStorage('site-manage-filter', isCacheValue) const filter = reactive({ cateIds: cache?.cateIds }) watch(() => filter.cateIds, cateIds => setCache({ cateIds })) diff --git a/src/pages/app/util/limit/types.ts b/src/pages/app/util/limit/types.ts index e60c007fd..1b1aaabe9 100644 --- a/src/pages/app/util/limit/types.ts +++ b/src/pages/app/util/limit/types.ts @@ -4,8 +4,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { type I18nKey } from "@i18n" -import { type LimitMessage } from "@i18n/message/app/limit" +import type { I18nKey } from "@i18n" +import type { LimitMessage } from "@i18n/message/app/limit" +import { createArrayGuard, createOptionalGuard, createStringUnionGuard, isInt } from 'typescript-guard' type LimitVerificationMessage = LimitMessage['verification'] @@ -34,4 +35,8 @@ export interface VerificationGenerator { * Render the prompt */ generate(context: VerificationContext): VerificationPair -} \ No newline at end of file +} + +export const isTimeFormat = createStringUnionGuard('default', 'hour', 'minute', 'second') + +export const isOptionalIntArray = createOptionalGuard(createArrayGuard(isInt)) diff --git a/src/pages/hooks/useLocalStorage.ts b/src/pages/hooks/useLocalStorage.ts index 49eb8944a..2ddc0c87c 100644 --- a/src/pages/hooks/useLocalStorage.ts +++ b/src/pages/hooks/useLocalStorage.ts @@ -1,4 +1,3 @@ - type StoragePrimitive = string | boolean | number | undefined type StorageArray = Array type StorageObject = { [key: string]: StorageValue } @@ -7,10 +6,12 @@ type StorageValue = | StorageArray | StorageObject -export function useLocalStorage(key: string, defaultValue: T): [T, ArgCallback] -export function useLocalStorage(key: string): [T | undefined, (val: T | undefined) => void] -export function useLocalStorage(key: string, defaultVal?: T): [data: T | undefined, setter: ArgCallback] { - const value: T | undefined = deserialize(localStorage.getItem(key)) ?? defaultVal +type Guard = (val: unknown) => val is T + +export function useLocalStorage(key: string, guard: Guard, defaultValue: T): [T, ArgCallback] +export function useLocalStorage(key: string, guard: Guard): [T | undefined, (val: T | undefined) => void] +export function useLocalStorage(key: string, guard: Guard, defaultVal?: T): [data: T | undefined, setter: ArgCallback] { + const value = deserialize(localStorage.getItem(key), guard) ?? defaultVal const setter = (val: T | undefined) => { if (val === undefined) { @@ -23,11 +24,12 @@ export function useLocalStorage(key: string, defaultVal?: T): return [value, setter] } -function deserialize(json: string | null): T | undefined { +function deserialize(json: string | null, guard: Guard): T | undefined { if (!json) return undefined try { - return JSON.parse(json) as T + const parsed = JSON.parse(json) + return guard(parsed) ? parsed : undefined } catch { return undefined } diff --git a/src/pages/popup/common.tsx b/src/pages/popup/common.tsx index 760855891..adf6080c2 100644 --- a/src/pages/popup/common.tsx +++ b/src/pages/popup/common.tsx @@ -1,12 +1,11 @@ import { getWeekStartTime } from "@api/sw/option" import { listCateStats, listGroupStats, listSiteStats } from '@api/sw/stat' import { REPORT_ROUTE, type ReportQuery } from "@app/router/constants" -import type { PopupDuration, PopupMenu, PopupQuery } from "@popup/types" +import type { PopupDuration, PopupQuery } from "@popup/types" import { isRemainHost } from "@util/constant/remain-host" import { getAppPageUrl } from "@util/constant/url" import { isSite } from "@util/stat" import { cvtDateRange2Str, getMonthTime, MILL_PER_DAY, type DateRange } from "@util/time" -import { createOptionalGuard, createStringUnionGuard } from 'typescript-guard' type DateRangeCalculator = (now: Date, num?: number) => Awaitable<[Date, Date] | Date | undefined> @@ -82,6 +81,4 @@ export function calJumpUrl( query.q = host return getAppPageUrl(REPORT_ROUTE, query) } -} - -export const isMenu = createOptionalGuard(createStringUnionGuard('percentage', 'ranking', 'limit')) \ No newline at end of file +} \ No newline at end of file diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index 4df4cc3c7..bf121da9d 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -5,10 +5,11 @@ import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" import { isDarkMode, processDarkMode } from "@pages/util/dark-mode" import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" +import { createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, isNumber, isOptionalInt } from 'typescript-guard' import { computed, reactive, Ref, ref, type ShallowRef, toRaw, watch } from "vue" import { useRoute, useRouter } from 'vue-router' -import { isMenu } from './common' import { t } from "./locale" +import { isMenu } from './router' import type { PopupMenu, PopupOption, PopupQuery } from './types' type PopupContextValue = { @@ -26,7 +27,7 @@ type PopupContextValue = { } const initMenu = () => { - const [stored, setStored] = useLocalStorage('popup_menu', 'percentage') + const [stored, setStored] = useLocalStorage('popup_menu', isMenu, 'percentage') const route = useRoute() const router = useRouter() const myRoute = computed(() => { @@ -92,8 +93,17 @@ export const initPopupContext = (): ShallowRef => { return appKey } +const isMergeMethod = createStringUnionGuard>('cate', 'domain', 'group') + +const isQuery = createObjectGuard({ + dimension: createStringUnionGuard('focus', 'time'), + duration: createStringUnionGuard('allTime', 'lastDays', 'thisMonth', 'thisWeek', 'today', 'yesterday'), + durationNum: isOptionalInt, + mergeMethod: createOptionalGuard(isMergeMethod), +}) + const initQuery = () => { - const [queryCache, setQueryCache] = useLocalStorage('popup-query', { + const [queryCache, setQueryCache] = useLocalStorage('popup-query', isQuery, { dimension: 'focus', duration: 'today', mergeMethod: undefined, @@ -105,8 +115,14 @@ const initQuery = () => { return query } +const isOption = createObjectGuard({ + showName: isBoolean, + topN: isNumber, + donutChart: isBoolean, +}) + const initOption = () => { - const [optionCache, setOptionCache] = useLocalStorage('popup-option', { + const [optionCache, setOptionCache] = useLocalStorage('popup-option', isOption, { showName: true, topN: 10, donutChart: false, diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index 649a27c7a..d1d30792d 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -1,6 +1,7 @@ import { useLocalStorage } from '@hooks' +import { createStringUnionGuard } from 'typescript-guard' import { type App } from "vue" -import { createRouter, createWebHashHistory, RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" +import { createRouter, createWebHashHistory, type RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" import type { PopupMenu } from './types' type Path = `/${PopupMenu}` @@ -25,8 +26,10 @@ const createRoutes = (stored: PopupMenu | undefined): MyRoute[] => [ }, ] +export const isMenu = createStringUnionGuard('limit', 'percentage', 'ranking') + export default (app: App) => { - const [stored] = useLocalStorage('popup_menu') + const [stored] = useLocalStorage('popup_menu', isMenu) const routes = createRoutes(stored) const history = createWebHashHistory() const router = createRouter({ routes, history }) diff --git a/src/util/guard.ts b/src/util/guard.ts index d1d57cbb2..13477380e 100644 --- a/src/util/guard.ts +++ b/src/util/guard.ts @@ -1,6 +1,4 @@ -import { createOptionalGuard, isInt } from 'typescript-guard' - -export const isOptionalInt = createOptionalGuard(isInt) +import { isInt } from 'typescript-guard' export const isRecord = (unk: unknown): unk is Record => typeof unk === 'object' && unk !== null From a73bff83ef09fbaaf8ecce8fe50295e7a1ed56e2 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 24 May 2026 00:08:44 +0800 Subject: [PATCH 04/95] chore: change the order of browser --- script/user-chart/render.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 709db982c..32a873b51 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -14,7 +14,7 @@ type EcOption = ComposeOption< | LineSeriesOption | TitleComponentOption | GridComponentOption> -const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] +const ALL_BROWSERS: Browser[] = ['chrome', 'edge', 'firefox'] type OriginData = { [browser in Browser]: UserCount From c750d07e22d00f27c770ed08efafdf9f66d32fd5 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 25 May 2026 00:24:26 +0800 Subject: [PATCH 05/95] refactor: rename /report to /record --- src/background/action.ts | 4 +-- src/i18n/message/app/index.ts | 6 ++-- src/i18n/message/app/menu-resource.json | 28 +++++++-------- src/i18n/message/app/menu.ts | 2 +- ...ort-resource.json => record-resource.json} | 0 src/i18n/message/app/{report.ts => record.ts} | 6 ++-- src/pages/app/Layout/menu/item.ts | 6 ++-- .../Dashboard/components/Calendar/index.tsx | 6 ++-- .../{Report => Record}/Filter/BatchDelete.tsx | 20 +++++------ .../Filter/DownloadFile.tsx | 8 ++--- .../Filter/MergeFilterItem.tsx | 4 +-- .../Filter/RemoteClient.tsx | 6 ++-- .../{Report => Record}/Filter/common.ts | 0 .../{Report => Record}/Filter/index.tsx | 4 +-- .../{Report => Record}/List/Item.tsx | 4 +-- .../{Report => Record}/List/index.tsx | 6 ++-- .../Table/columns/CateColumn.tsx | 2 +- .../Table/columns/DateColumn.tsx | 4 +-- .../Table/columns/GroupColumn.tsx | 0 .../Table/columns/HostColumn.tsx | 10 +++--- .../Table/columns/OperationColumn.tsx | 6 ++-- .../Table/columns/TimeColumn.tsx | 10 +++--- .../Table/columns/VisitColumn.tsx | 10 +++--- .../{Report => Record}/Table/index.tsx | 14 ++++---- .../components/{Report => Record}/common.ts | 24 ++++++------- .../components/CompositionTable.tsx | 8 ++--- .../components/TooltipSiteList.tsx | 0 .../components/{Report => Record}/context.ts | 36 +++++++++---------- .../{Report => Record}/file-export.ts | 10 +++--- .../components/{Report => Record}/index.tsx | 4 +-- .../components/{Report => Record}/types.d.ts | 4 +-- src/pages/app/router/constants.ts | 2 +- src/pages/app/router/index.ts | 6 ++-- src/pages/popup/common.tsx | 10 +++--- src/shared/route.ts | 6 ++-- test-e2e/common/record.ts | 2 +- test/util/pattern.test.ts | 2 +- 37 files changed, 140 insertions(+), 140 deletions(-) rename src/i18n/message/app/{report-resource.json => record-resource.json} (100%) rename src/i18n/message/app/{report.ts => record.ts} (82%) rename src/pages/app/components/{Report => Record}/Filter/BatchDelete.tsx (89%) rename src/pages/app/components/{Report => Record}/Filter/DownloadFile.tsx (93%) rename src/pages/app/components/{Report => Record}/Filter/MergeFilterItem.tsx (97%) rename src/pages/app/components/{Report => Record}/Filter/RemoteClient.tsx (89%) rename src/pages/app/components/{Report => Record}/Filter/common.ts (100%) rename src/pages/app/components/{Report => Record}/Filter/index.tsx (97%) rename src/pages/app/components/{Report => Record}/List/Item.tsx (98%) rename src/pages/app/components/{Report => Record}/List/index.tsx (94%) rename src/pages/app/components/{Report => Record}/Table/columns/CateColumn.tsx (97%) rename src/pages/app/components/{Report => Record}/Table/columns/DateColumn.tsx (85%) rename src/pages/app/components/{Report => Record}/Table/columns/GroupColumn.tsx (100%) rename src/pages/app/components/{Report => Record}/Table/columns/HostColumn.tsx (86%) rename src/pages/app/components/{Report => Record}/Table/columns/OperationColumn.tsx (97%) rename src/pages/app/components/{Report => Record}/Table/columns/TimeColumn.tsx (83%) rename src/pages/app/components/{Report => Record}/Table/columns/VisitColumn.tsx (80%) rename src/pages/app/components/{Report => Record}/Table/index.tsx (94%) rename src/pages/app/components/{Report => Record}/common.ts (86%) rename src/pages/app/components/{Report => Record}/components/CompositionTable.tsx (90%) rename src/pages/app/components/{Report => Record}/components/TooltipSiteList.tsx (100%) rename src/pages/app/components/{Report => Record}/context.ts (76%) rename src/pages/app/components/{Report => Record}/file-export.ts (94%) rename src/pages/app/components/{Report => Record}/index.tsx (87%) rename src/pages/app/components/{Report => Record}/types.d.ts (85%) diff --git a/src/background/action.ts b/src/background/action.ts index 5ea65c59c..b183480dc 100644 --- a/src/background/action.ts +++ b/src/background/action.ts @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { APP_OPTION_ROUTE, APP_REPORT_ROUTE } from "@/shared/route" +import { APP_OPTION_ROUTE, APP_RECORD_ROUTE } from "@/shared/route" import { onIconClick } from "@api/chrome/action" import { createContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" @@ -88,7 +88,7 @@ export function initBrowserAction() { if (IS_ANDROID) { // Forbidden popup page - onIconClick(() => createTab({ url: getAppPageUrl(APP_REPORT_ROUTE) })) + onIconClick(() => createTab({ url: getAppPageUrl(APP_RECORD_ROUTE) })) } } diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index d5e0ff383..3d5f88bd1 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -24,7 +24,7 @@ import menuMessages, { type MenuMessage } from "./menu" import mergeRuleMessages, { type MergeRuleMessage } from "./merge-rule" import operationMessages, { type OperationMessage } from './operation' import optionMessages, { type OptionMessage } from "./option" -import reportMessages, { type ReportMessage } from "./report" +import recordMessages, { type RecordMessage } from "./record" import siteManageManages, { type SiteManageMessage } from "./site-manage" import timeFormatMessages, { type TimeFormatMessage } from "./time-format" import whitelistMessages, { type WhitelistMessage } from "./whitelist" @@ -34,7 +34,7 @@ export type AppMessage = { dataManage: DataManageMessage item: ItemMessage shared: SharedMessage - report: ReportMessage + record: RecordMessage whitelist: WhitelistMessage mergeRule: MergeRuleMessage option: OptionMessage @@ -59,7 +59,7 @@ const MESSAGE_ROOT: MessageRoot = { dataManage: dataManageMessages, item: itemMessages, shared: sharedMessages, - report: reportMessages, + record: recordMessages, whitelist: whitelistMessages, mergeRule: mergeRuleMessages, option: optionMessages, diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 5924e2ef5..c775c4023 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -2,7 +2,7 @@ "zh_CN": { "dashboard": "仪表盘", "data": "我的数据", - "dataReport": "报表明细", + "record": "报表明细", "siteAnalysis": "站点分析", "dataClear": "数据管理", "behavior": "上网行为", @@ -17,7 +17,7 @@ "zh_TW": { "dashboard": "数据总览", "data": "我的資料", - "dataReport": "報表明細", + "record": "報表明細", "siteAnalysis": "網站分析", "dataClear": "儲存狀況", "behavior": "用戶行爲", @@ -32,7 +32,7 @@ "en": { "dashboard": "Dashboard", "data": "My Data", - "dataReport": "Record", + "record": "Record", "siteAnalysis": "Site Analysis", "dataClear": "Storage", "behavior": "User Behavior", @@ -47,7 +47,7 @@ "ja": { "dashboard": "ダッシュボード", "data": "私のデータ", - "dataReport": "報告する", + "record": "報告する", "siteAnalysis": "ウェブサイト分析", "dataClear": "記憶状況", "behavior": "ユーザーの行動", @@ -62,7 +62,7 @@ "pt_PT": { "dashboard": "Painel", "data": "Os Meus Dados", - "dataReport": "Registos", + "record": "Registos", "siteAnalysis": "Análise de Sites", "dataClear": "Armazenamento", "behavior": "Comportamento", @@ -77,7 +77,7 @@ "uk": { "dashboard": "Огляд", "data": "Мої дані", - "dataReport": "Записи", + "record": "Записи", "siteAnalysis": "Аналіз сайту", "dataClear": "Стан пам'яті", "behavior": "Поведінка", @@ -92,7 +92,7 @@ "es": { "dashboard": "Tablero", "data": "Mis datos", - "dataReport": "Registro", + "record": "Registro", "siteAnalysis": "Análisis de sitios", "dataClear": "Estado de la memoria", "behavior": "Comportamiento del usuario", @@ -107,7 +107,7 @@ "de": { "dashboard": "Übersicht", "data": "Meine Daten", - "dataReport": "Aufzeichnen", + "record": "Aufzeichnen", "siteAnalysis": "Analysebericht", "dataClear": "Speicherstatus", "behavior": "Nutzerverhalten", @@ -122,7 +122,7 @@ "fr": { "dashboard": "Tableau de bord", "data": "Mes données", - "dataReport": "Enregistrement", + "record": "Enregistrement", "siteAnalysis": "Analyse du site", "dataClear": "Situation de la mémoire", "behavior": "Comportement de l'utilisateur", @@ -137,7 +137,7 @@ "ru": { "dashboard": "Панель Данных", "data": "Мои Данные", - "dataReport": "Рекорд", + "record": "Рекорд", "siteAnalysis": "Анализ сайта", "dataClear": "Память о ситуации", "behavior": "Поведение", @@ -152,7 +152,7 @@ "ar": { "dashboard": "لوحة التحكم", "data": "بياناتي", - "dataReport": "تسجيل", + "record": "تسجيل", "siteAnalysis": "تحليل الموقع", "dataClear": "حالة الذاكرة", "behavior": "سلوك المستخدم", @@ -167,7 +167,7 @@ "tr": { "dashboard": "Kontrol Paneli", "data": "Verilerim", - "dataReport": "Kayıtlı Veriler", + "record": "Kayıtlı Veriler", "siteAnalysis": "Site Analizi", "dataClear": "Depolama", "behavior": "Kullanıcı Davranışları", @@ -182,7 +182,7 @@ "pl": { "dashboard": "Panel", "data": "Moje dane", - "dataReport": "Wpisy", + "record": "Wpisy", "siteAnalysis": "Analiza strony", "dataClear": "Pamięć", "behavior": "Zachowanie użytkownika", @@ -197,7 +197,7 @@ "it": { "dashboard": "Dashboard", "data": "Miei Dati", - "dataReport": "Record", + "record": "Record", "siteAnalysis": "Analisi Sito", "dataClear": "Memoria", "behavior": "Comportamento Dell'Utente", diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index 3c0807740..c52a95737 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -10,7 +10,7 @@ import resource from './menu-resource.json' export type MenuMessage = { dashboard: string data: string - dataReport: string + record: string siteAnalysis: string dataClear: string behavior: string diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/record-resource.json similarity index 100% rename from src/i18n/message/app/report-resource.json rename to src/i18n/message/app/record-resource.json diff --git a/src/i18n/message/app/report.ts b/src/i18n/message/app/record.ts similarity index 82% rename from src/i18n/message/app/report.ts rename to src/i18n/message/app/record.ts index d1be95f6a..c4c17e57e 100644 --- a/src/i18n/message/app/report.ts +++ b/src/i18n/message/app/record.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import resource from './report-resource.json' +import resource from './record-resource.json' -export type ReportMessage = { +export type RecordMessage = { exportFileName: string total: string batchDelete: { @@ -29,6 +29,6 @@ export type ReportMessage = { noMore: string } -const _default: Messages = resource +const _default: Messages = resource export default _default \ No newline at end of file diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index 5f723ee7e..04cc4ab96 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -7,7 +7,7 @@ */ import { type I18nKey } from '@app/locale' -import { ANALYSIS_ROUTE, MERGE_ROUTE } from '@app/router/constants' +import { ANALYSIS_ROUTE, MERGE_ROUTE, RECORD_ROUTE } from '@app/router/constants' import { Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, Rank, SetUp, Stopwatch, Timer, View } from "@element-plus/icons-vue" import { Trend } from '@pages/icons' import { getGuidePageUrl } from "@util/constant/url" @@ -53,8 +53,8 @@ export const menuGroups = (): MenuGroup[] => [{ route: '/data/dashboard', icon: Stopwatch, }, { - title: msg => msg.menu.dataReport, - route: '/data/report', + title: msg => msg.menu.record, + route: RECORD_ROUTE, icon: Table, }, { title: msg => msg.menu.siteAnalysis, diff --git a/src/pages/app/components/Dashboard/components/Calendar/index.tsx b/src/pages/app/components/Dashboard/components/Calendar/index.tsx index 263e5564f..8da120db3 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/index.tsx +++ b/src/pages/app/components/Dashboard/components/Calendar/index.tsx @@ -10,7 +10,7 @@ import { getWeekStartTime } from '@api/sw/option' import { listSiteStats } from '@api/sw/stat' import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from "@app/locale" -import { REPORT_ROUTE, type ReportQuery } from '@app/router/constants' +import { RECORD_ROUTE, type RecordQuery } from '@app/router/constants' import { useEcharts, useRequest } from "@hooks" import Flex from "@pages/components/Flex" import { groupBy, sum } from "@util/array" @@ -44,7 +44,7 @@ const fetchData = async (): Promise => { } /** - * Click to jump to the report page + * Click to jump to the record page * * @since 1.1.1 */ @@ -59,7 +59,7 @@ function handleClick(value: ChartValue): void { const currentDay = parseInt(currentDate.substring(6, 8)) const currentTs = (new Date(currentYear, currentMonth, currentDay).getTime() + 1000).toString() - const url = getAppPageUrl(REPORT_ROUTE, { ds: currentTs, de: currentTs } satisfies ReportQuery) + const url = getAppPageUrl(RECORD_ROUTE, { ds: currentTs, de: currentTs } satisfies RecordQuery) createTabAfterCurrent(url) } diff --git a/src/pages/app/components/Report/Filter/BatchDelete.tsx b/src/pages/app/components/Record/Filter/BatchDelete.tsx similarity index 89% rename from src/pages/app/components/Report/Filter/BatchDelete.tsx rename to src/pages/app/components/Record/Filter/BatchDelete.tsx index caad0b7c4..86a5c5b4e 100644 --- a/src/pages/app/components/Report/Filter/BatchDelete.tsx +++ b/src/pages/app/components/Record/Filter/BatchDelete.tsx @@ -8,8 +8,8 @@ import { isGroup, isNormalSite, isSite } from "@util/stat" import { cvtDateRange2Str, DateRange, formatTime, formatTimeYMD, getBirthday } from "@util/time" import { ElButton, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent } from "vue" -import { useReportComponent, useReportFilter } from "../context" -import type { DisplayComponent, ReportFilterOption } from "../types" +import { useRecordComponent, useRecordFilter } from "../context" +import type { DisplayComponent, RecordFilterOption } from "../types" async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange): Promise { const hosts: string[] = [] @@ -26,7 +26,7 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole } if (!example) { // Never happen - return t(msg => msg.report.batchDelete.noSelectedMsg) + return t(msg => msg.record.batchDelete.noSelectedMsg) } let count2Delete = selected.length ?? 0 if (mergeDate) { @@ -54,7 +54,7 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] if (!startDate && !endDate) { // Delete all - key = msg => msg.report.batchDelete.confirmMsgAll + key = msg => msg.record.batchDelete.confirmMsgAll } else { const dateFormat = t(msg => msg.calendar.dateFormat) startDate = startDate ?? getBirthday() @@ -63,11 +63,11 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole const end = formatTime(endDate, dateFormat) if (start === end) { // Single date - key = msg => msg.report.batchDelete.confirmMsg + key = msg => msg.record.batchDelete.confirmMsg i18nParam.date = start } else { // Date range - key = msg => msg.report.batchDelete.confirmMsgRange + key = msg => msg.record.batchDelete.confirmMsgRange i18nParam.start = start i18nParam.end = end } @@ -75,12 +75,12 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole return t(key, i18nParam) } -async function handleBatchDelete(displayComp: DisplayComponent | undefined, filter: ReportFilterOption) { +async function handleBatchDelete(displayComp: DisplayComponent | undefined, filter: RecordFilterOption) { if (!displayComp) return const selected = displayComp?.getSelected?.() ?? [] if (!selected?.length) { - ElMessage.info(t(msg => msg.report.batchDelete.noSelectedMsg)) + ElMessage.info(t(msg => msg.record.batchDelete.noSelectedMsg)) return } const { dateRange, mergeDate } = filter @@ -119,12 +119,12 @@ async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRa } const BatchDelete = defineComponent(() => { - const filter = useReportFilter() + const filter = useRecordFilter() const disabled = computed(() => { const { siteMerge } = filter return !!siteMerge && siteMerge !== 'group' }) - const comp = useReportComponent() + const comp = useRecordComponent() return () => ( { - const filter = useReportFilter() - const sort = useReportSort() + const filter = useRecordFilter() + const sort = useRecordSort() const cate = useCategory() const { groupMap } = useTabGroups() diff --git a/src/pages/app/components/Report/Filter/MergeFilterItem.tsx b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx similarity index 97% rename from src/pages/app/components/Report/Filter/MergeFilterItem.tsx rename to src/pages/app/components/Record/Filter/MergeFilterItem.tsx index 0171f6989..809cceaaa 100644 --- a/src/pages/app/components/Report/Filter/MergeFilterItem.tsx +++ b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx @@ -6,7 +6,7 @@ import Flex from "@pages/components/Flex" import { ElCheckboxButton, ElCheckboxGroup, ElIcon, ElText, ElTooltip } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" import { type JSX } from "vue/jsx-runtime" -import { useReportFilter } from "../context" +import { useRecordFilter } from "../context" const METHOD_ICONS: Record = { cate: , @@ -16,7 +16,7 @@ const METHOD_ICONS: Record = { } const MergeFilterItem = defineComponent<{}>(() => { - const filter = useReportFilter() + const filter = useRecordFilter() const cate = useCategory() const { mergeItems: siteMergeItems } = useSiteMerge({ onGroupDisabled: () => mergeMethod.value.filter(v => v !== 'group') diff --git a/src/pages/app/components/Report/Filter/RemoteClient.tsx b/src/pages/app/components/Record/Filter/RemoteClient.tsx similarity index 89% rename from src/pages/app/components/Report/Filter/RemoteClient.tsx rename to src/pages/app/components/Record/Filter/RemoteClient.tsx index 2b54268ee..42bd2223e 100644 --- a/src/pages/app/components/Report/Filter/RemoteClient.tsx +++ b/src/pages/app/components/Record/Filter/RemoteClient.tsx @@ -11,12 +11,12 @@ import { UploadFilled } from "@element-plus/icons-vue" import { useRequest } from '@hooks' import { ElButton, ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" -import { useReportFilter } from "../context" +import { useRecordFilter } from "../context" import { ICON_BTN_STYLE } from "./common" const _default = defineComponent(() => { - const filter = useReportFilter() - const content = computed(() => t(msg => msg.report.remoteReading[filter.readRemote ? 'on' : 'off'])) + const filter = useRecordFilter() + const content = computed(() => t(msg => msg.record.remoteReading[filter.readRemote ? 'on' : 'off'])) const { data: visible } = useRequest(() => checkAuth().then(errMsg => !errMsg), { defaultValue: false }) return () => ( diff --git a/src/pages/app/components/Report/Filter/common.ts b/src/pages/app/components/Record/Filter/common.ts similarity index 100% rename from src/pages/app/components/Report/Filter/common.ts rename to src/pages/app/components/Record/Filter/common.ts diff --git a/src/pages/app/components/Report/Filter/index.tsx b/src/pages/app/components/Record/Filter/index.tsx similarity index 97% rename from src/pages/app/components/Report/Filter/index.tsx rename to src/pages/app/components/Record/Filter/index.tsx index dd26b59fa..62828e70e 100644 --- a/src/pages/app/components/Report/Filter/index.tsx +++ b/src/pages/app/components/Record/Filter/index.tsx @@ -14,7 +14,7 @@ import Flex from "@pages/components/Flex" import { ElDatePickerShortcut } from '@pages/element-ui/types' import { daysAgo } from "@util/time" import { defineComponent } from "vue" -import { useReportFilter } from "../context" +import { useRecordFilter } from "../context" import BatchDelete from "./BatchDelete" import DownloadFile from "./DownloadFile" import MergeFilterItem from "./MergeFilterItem" @@ -33,7 +33,7 @@ const dateShortcuts: ElDatePickerShortcut[] = [ ] const _default = defineComponent<{}>(() => { - const filter = useReportFilter() + const filter = useRecordFilter() return () => ( diff --git a/src/pages/app/components/Report/List/Item.tsx b/src/pages/app/components/Record/List/Item.tsx similarity index 98% rename from src/pages/app/components/Report/List/Item.tsx rename to src/pages/app/components/Record/List/Item.tsx index 2561035be..94e4f0214 100644 --- a/src/pages/app/components/Report/List/Item.tsx +++ b/src/pages/app/components/Record/List/Item.tsx @@ -13,7 +13,7 @@ import { computed, defineComponent, ref, StyleValue, watch } from "vue" import { computeDeleteConfirmMsg, handleDelete } from "../common" import CompositionTable from "../components/CompositionTable" import TooltipSiteList from "../components/TooltipSiteList" -import { useReportFilter } from "../context" +import { useRecordFilter } from "../context" type Props = { value: tt4b.stat.Row @@ -36,7 +36,7 @@ const useContentStyle = () => { } const _default = defineComponent(props => { - const filter = useReportFilter() + const filter = useRecordFilter() const { groupMap } = useTabGroups() const formatter = (focus: number): string => periodFormatter(focus, { format: filter?.timeFormat }) const { date, focus, time } = props.value diff --git a/src/pages/app/components/Report/List/index.tsx b/src/pages/app/components/Record/List/index.tsx similarity index 94% rename from src/pages/app/components/Report/List/index.tsx rename to src/pages/app/components/Record/List/index.tsx index 014f7d618..399db1a33 100644 --- a/src/pages/app/components/Report/List/index.tsx +++ b/src/pages/app/components/Record/List/index.tsx @@ -5,7 +5,7 @@ import { getHost } from "@util/stat" import { ElCard, useNamespace } from "element-plus" import { defineComponent, ref } from "vue" import { queryPage } from "../common" -import { useReportFilter } from "../context" +import { useRecordFilter } from "../context" import type { DisplayComponent } from "../types" import Item from "./Item" @@ -33,7 +33,7 @@ const useStyle = () => { } const _default = defineComponent<{}>((_, ctx) => { - const filterOption = useReportFilter() + const filterOption = useRecordFilter() const { data, loading, loadMoreAsync, end, reset } = useScrollRequest(async (num, size) => { const pagination = await queryPage( filterOption, @@ -76,7 +76,7 @@ const _default = defineComponent<{}>((_, ctx) => { ))}

- {end.value ? t(msg => msg.report.noMore) : (loading.value ? 'Loading ...' : 'Load More')} + {end.value ? t(msg => msg.record.noMore) : (loading.value ? 'Loading ...' : 'Load More')}

) diff --git a/src/pages/app/components/Report/Table/columns/CateColumn.tsx b/src/pages/app/components/Record/Table/columns/CateColumn.tsx similarity index 97% rename from src/pages/app/components/Report/Table/columns/CateColumn.tsx rename to src/pages/app/components/Record/Table/columns/CateColumn.tsx index 48498b325..7aef8a460 100644 --- a/src/pages/app/components/Report/Table/columns/CateColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/CateColumn.tsx @@ -1,5 +1,5 @@ import CategoryEditable from "@app/components/common/Category/Editable" -import TooltipSiteList from "@app/components/Report/components/TooltipSiteList" +import TooltipSiteList from "@app/components/Record/components/TooltipSiteList" import { useCategory } from '@app/context' import { t } from '@app/locale' import Flex from "@pages/components/Flex" diff --git a/src/pages/app/components/Report/Table/columns/DateColumn.tsx b/src/pages/app/components/Record/Table/columns/DateColumn.tsx similarity index 85% rename from src/pages/app/components/Report/Table/columns/DateColumn.tsx rename to src/pages/app/components/Record/Table/columns/DateColumn.tsx index 3f341d755..4df148ed7 100644 --- a/src/pages/app/components/Report/Table/columns/DateColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/DateColumn.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import type { ReportSort } from '@app/components/Report/types' +import type { RecordSort } from '@app/components/Record/types' import { t } from '@app/locale' import { cvt2LocaleTime } from '@app/util/time' import { ElTableColumn, RenderRowData } from "element-plus" @@ -13,7 +13,7 @@ import { type FunctionalComponent } from "vue" const DateColumn: FunctionalComponent = () => ( msg.item.date)} minWidth={135} align="center" diff --git a/src/pages/app/components/Report/Table/columns/GroupColumn.tsx b/src/pages/app/components/Record/Table/columns/GroupColumn.tsx similarity index 100% rename from src/pages/app/components/Report/Table/columns/GroupColumn.tsx rename to src/pages/app/components/Record/Table/columns/GroupColumn.tsx diff --git a/src/pages/app/components/Report/Table/columns/HostColumn.tsx b/src/pages/app/components/Record/Table/columns/HostColumn.tsx similarity index 86% rename from src/pages/app/components/Report/Table/columns/HostColumn.tsx rename to src/pages/app/components/Record/Table/columns/HostColumn.tsx index d31761191..4f2ecc5dc 100644 --- a/src/pages/app/components/Report/Table/columns/HostColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/HostColumn.tsx @@ -6,9 +6,9 @@ */ import HostAlert from '@app/components/common/HostAlert' -import TooltipSiteList from '@app/components/Report/components/TooltipSiteList' -import { useReportFilter } from '@app/components/Report/context' -import { ReportSort } from '@app/components/Report/types' +import TooltipSiteList from '@app/components/Record/components/TooltipSiteList' +import { useRecordFilter } from '@app/components/Record/context' +import type { RecordSort } from '@app/components/Record/types' import { t } from '@app/locale' import Flex from "@pages/components/Flex" import TooltipWrapper from '@pages/components/TooltipWrapper' @@ -18,10 +18,10 @@ import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" const _default = defineComponent(() => { - const filter = useReportFilter() + const filter = useRecordFilter() return () => ( msg.item.host)} minWidth={210} sortable="custom" diff --git a/src/pages/app/components/Report/Table/columns/OperationColumn.tsx b/src/pages/app/components/Record/Table/columns/OperationColumn.tsx similarity index 97% rename from src/pages/app/components/Report/Table/columns/OperationColumn.tsx rename to src/pages/app/components/Record/Table/columns/OperationColumn.tsx index 2f29ed29b..b2db9b948 100644 --- a/src/pages/app/components/Report/Table/columns/OperationColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/OperationColumn.tsx @@ -7,8 +7,8 @@ import { type AppAnalysisQuery } from '@/shared/route' import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" import PopupConfirmButton from '@app/components/common/PopupConfirmButton' -import { computeDeleteConfirmMsg, handleDelete } from '@app/components/Report/common' -import { useReportFilter } from '@app/components/Report/context' +import { computeDeleteConfirmMsg, handleDelete } from '@app/components/Record/common' +import { useRecordFilter } from '@app/components/Record/context' import { t } from '@app/locale' import { ANALYSIS_ROUTE } from '@app/router/constants' import { Delete, Open, Plus, Stopwatch } from "@element-plus/icons-vue" @@ -54,7 +54,7 @@ const deleteVisible = (row: tt4b.stat.Row) => { } const _default = defineComponent(({ onDelete }) => { - const filter = useReportFilter() + const filter = useRecordFilter() const { groupMap } = useTabGroups() const width = computed(() => { const siteMerge = filter.siteMerge diff --git a/src/pages/app/components/Report/Table/columns/TimeColumn.tsx b/src/pages/app/components/Record/Table/columns/TimeColumn.tsx similarity index 83% rename from src/pages/app/components/Report/Table/columns/TimeColumn.tsx rename to src/pages/app/components/Record/Table/columns/TimeColumn.tsx index 5843ec336..b790307db 100644 --- a/src/pages/app/components/Report/Table/columns/TimeColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/TimeColumn.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import CompositionTable from '@app/components/Report/components/CompositionTable' -import { useReportFilter } from "@app/components/Report/context" -import type { ReportSort } from "@app/components/Report/types" +import CompositionTable from '@app/components/Record/components/CompositionTable' +import { useRecordFilter } from "@app/components/Record/context" +import type { RecordSort } from "@app/components/Record/types" import { t } from '@app/locale' import { periodFormatter } from '@app/util/time' import TooltipWrapper from '@pages/components/TooltipWrapper' @@ -20,11 +20,11 @@ type Props = { } const TimeColumn = defineComponent(props => { - const filter = useReportFilter() + const filter = useRecordFilter() const formatter = (focus: number | undefined): string => periodFormatter(focus, { format: filter.timeFormat }) return () => ( msg.item[props.dimension])} minWidth={130} align="center" diff --git a/src/pages/app/components/Report/Table/columns/VisitColumn.tsx b/src/pages/app/components/Record/Table/columns/VisitColumn.tsx similarity index 80% rename from src/pages/app/components/Report/Table/columns/VisitColumn.tsx rename to src/pages/app/components/Record/Table/columns/VisitColumn.tsx index 232a52b50..95977b125 100644 --- a/src/pages/app/components/Report/Table/columns/VisitColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/VisitColumn.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import CompositionTable from "@app/components/Report/components/CompositionTable" -import { useReportFilter } from "@app/components/Report/context" -import type { ReportSort } from "@app/components/Report/types" +import CompositionTable from "@app/components/Record/components/CompositionTable" +import { useRecordFilter } from "@app/components/Record/context" +import type { RecordSort } from "@app/components/Record/types" import { t } from '@app/locale' import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition } from "@util/stat" @@ -15,10 +15,10 @@ import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" const VisitColumn = defineComponent(() => { - const filter = useReportFilter() + const filter = useRecordFilter() return () => ( msg.item.time)} minWidth={130} align="center" diff --git a/src/pages/app/components/Report/Table/index.tsx b/src/pages/app/components/Record/Table/index.tsx similarity index 94% rename from src/pages/app/components/Report/Table/index.tsx rename to src/pages/app/components/Record/Table/index.tsx index 03937fcd1..69ae2874a 100644 --- a/src/pages/app/components/Report/Table/index.tsx +++ b/src/pages/app/components/Record/Table/index.tsx @@ -22,8 +22,8 @@ import { cvtDateRange2Str } from '@util/time' import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type TableInstance } from "element-plus" import { computed, defineComponent, ref, watch } from "vue" import { queryPage } from "../common" -import { useReportFilter, useReportSort } from "../context" -import type { DisplayComponent, ReportFilterOption, ReportSort } from "../types" +import { useRecordFilter, useRecordSort } from "../context" +import type { DisplayComponent, RecordFilterOption, RecordSort } from "../types" import CateColumn from "./columns/CateColumn" import DateColumn from "./columns/DateColumn" import GroupColumn from "./columns/GroupColumn" @@ -41,7 +41,7 @@ async function handleAliasChange(key: tt4b.site.SiteKey, newAlias: string | unde type ColumnVisible = Record<'index' | 'date' | 'site' | 'cate' | 'group', boolean> -const computeVisible = (filter: ReportFilterOption): ColumnVisible => { +const computeVisible = (filter: RecordFilterOption): ColumnVisible => { const { siteMerge, mergeDate } = filter return { index: !siteMerge || siteMerge === 'group', @@ -55,8 +55,8 @@ const computeVisible = (filter: ReportFilterOption): ColumnVisible => { const _default = defineComponent((_, ctx) => { const rtl = isRtl() const [page, setPage] = useState({ size: 20, num: 1 }) - const sort = useReportSort() - const filter = useReportFilter() + const sort = useRecordSort() + const filter = useRecordFilter() const visible = computed(() => computeVisible(filter)) const { data, refresh, loading } = useRequest(() => queryPage(filter, sort.value, page.value), { loadingTarget: () => table.value?.$el, @@ -116,7 +116,7 @@ const _default = defineComponent((_, ctx) => { height="100%" defaultSort={sort.value} onSelection-change={setSelection} - onSort-change={(val: ReportSort) => sort.value = val} + onSort-change={(val: RecordSort) => sort.value = val} > {visible.value.index && } {visible.value.date && } @@ -150,7 +150,7 @@ const _default = defineComponent((_, ctx) => { v-slots={{ content: () => ( - {t(msg => msg.report.total, { + {t(msg => msg.record.total, { visit: total.value.visit, focus: periodFormatter(total.value.focus, { format: filter.timeFormat }), })} diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Record/common.ts similarity index 86% rename from src/pages/app/components/Report/common.ts rename to src/pages/app/components/Record/common.ts index d6319bf0a..a4da62db3 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Record/common.ts @@ -5,7 +5,7 @@ import { import { t } from '@app/locale' import { getGroupName, isGroup, isSite } from "@util/stat" import { cvtDateRange2Str, type DateRange, formatTime, getBirthday } from "@util/time" -import type { ReportFilterOption, ReportSort } from "./types" +import type { RecordFilterOption, RecordSort } from "./types" /** * Compute the confirm text for one item to delete @@ -34,7 +34,7 @@ function computeRangeConfirmText(url: string, dateRange: DateRange): string { : t(msg => msg.item.operation.deleteConfirmMsgRange, { url, start, end }) } -export function computeDeleteConfirmMsg(row: tt4b.stat.Row, filterOption: ReportFilterOption, groupMap: Record): string { +export function computeDeleteConfirmMsg(row: tt4b.stat.Row, filterOption: RecordFilterOption, groupMap: Record): string { let name: string | undefined if (isGroup(row)) { name = getGroupName(groupMap, row) @@ -49,7 +49,7 @@ export function computeDeleteConfirmMsg(row: tt4b.stat.Row, filterOption: Report : computeSingleConfirmText(name, date ?? '') } -export async function handleDelete(row: tt4b.stat.Row, filterOption: ReportFilterOption) { +export async function handleDelete(row: tt4b.stat.Row, filterOption: RecordFilterOption) { const { date } = row const { mergeDate, dateRange } = filterOption if (!mergeDate) { @@ -72,15 +72,15 @@ export async function handleDelete(row: tt4b.stat.Row, filterOption: ReportFilte isGroup(row) && await deleteSiteStatByGroup(row.groupKey, strRange) } -const cvtOrderDir = (order: ReportSort['order']): tt4b.common.SortDirection | undefined => { +const cvtOrderDir = (order: RecordSort['order']): tt4b.common.SortDirection | undefined => { if (order === 'ascending') return 'ASC' else if (order === 'descending') return 'DESC' else return undefined } const cvt2GroupQuery = ( - { query, mergeDate, dateRange: date }: ReportFilterOption, - { prop, order }: ReportSort, + { query, mergeDate, dateRange: date }: RecordFilterOption, + { prop, order }: RecordSort, ): tt4b.stat.GroupQuery => ({ date: cvtDateRange2Str(date), mergeDate, query, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, @@ -88,8 +88,8 @@ const cvt2GroupQuery = ( }) const cvt2SiteQuery = ( - { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, - { prop, order }: ReportSort, + { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: RecordFilterOption, + { prop, order }: RecordSort, ): tt4b.stat.SiteQuery => ({ date: cvtDateRange2Str(date), mergeDate, mergeHost: siteMerge === 'domain', @@ -100,15 +100,15 @@ const cvt2SiteQuery = ( }) const cvt2CateQuery = ( - { dateRange: date, mergeDate, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, - { prop, order }: ReportSort, + { dateRange: date, mergeDate, query, cateIds, readRemote: inclusiveRemote }: RecordFilterOption, + { prop, order }: RecordSort, ): tt4b.stat.CateQuery => ({ date: cvtDateRange2Str(date), mergeDate, query, cateIds, inclusiveRemote, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) -export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, page: tt4b.common.PageQuery): Promise> => { +export const queryPage = async (filter: RecordFilterOption, sort: RecordSort, page: tt4b.common.PageQuery): Promise> => { const { siteMerge } = filter if (siteMerge === 'group') { return await getGroupStatPage({ ...cvt2GroupQuery(filter, sort), ...page }) @@ -119,7 +119,7 @@ export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, pa } } -export const queryAll = async (filter: ReportFilterOption, sort: ReportSort): Promise => { +export const queryAll = async (filter: RecordFilterOption, sort: RecordSort): Promise => { const { siteMerge } = filter if (siteMerge === 'group') { return await listGroupStats(cvt2GroupQuery(filter, sort)) diff --git a/src/pages/app/components/Report/components/CompositionTable.tsx b/src/pages/app/components/Record/components/CompositionTable.tsx similarity index 90% rename from src/pages/app/components/Report/components/CompositionTable.tsx rename to src/pages/app/components/Record/components/CompositionTable.tsx index 5d0afa0f5..2f3895342 100644 --- a/src/pages/app/components/Report/components/CompositionTable.tsx +++ b/src/pages/app/components/Record/components/CompositionTable.tsx @@ -18,10 +18,10 @@ type Row = { type ValueFormatter = (val: number) => string -const CLIENT_NAME = t(msg => msg.report.remoteReading.table.client) -const VALUE = t(msg => msg.report.remoteReading.table.value) -const LOCAL_DATA = t(msg => msg.report.remoteReading.table.localData) -const PERCENTAGE = t(msg => msg.report.remoteReading.table.percentage) +const CLIENT_NAME = t(msg => msg.record.remoteReading.table.client) +const VALUE = t(msg => msg.record.remoteReading.table.value) +const LOCAL_DATA = t(msg => msg.record.remoteReading.table.localData) +const PERCENTAGE = t(msg => msg.record.remoteReading.table.percentage) function computeRows(data: tt4b.stat.RemoteCompositionVal[]): Row[] { const rows: Row[] = data.map(e => typeof e === 'number' diff --git a/src/pages/app/components/Report/components/TooltipSiteList.tsx b/src/pages/app/components/Record/components/TooltipSiteList.tsx similarity index 100% rename from src/pages/app/components/Report/components/TooltipSiteList.tsx rename to src/pages/app/components/Record/components/TooltipSiteList.tsx diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Record/context.ts similarity index 76% rename from src/pages/app/components/Report/context.ts rename to src/pages/app/components/Record/context.ts index 4965a6db2..4002c9c30 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Record/context.ts @@ -8,27 +8,27 @@ import { } from 'typescript-guard' import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" -import type { DisplayComponent, ReportFilterOption, ReportSort } from "./types" +import type { DisplayComponent, RecordFilterOption, RecordSort } from "./types" type Context = { - filter: ReportFilterOption - sort: ShallowRef + filter: RecordFilterOption + sort: ShallowRef comp: ShallowRef } -const NAMESPACE = 'report' +const NAMESPACE = 'record' -type QueryPartial = PartialPick +type QueryPartial = PartialPick -const isSortProp = createStringUnionGuard('date', 'host', 'focus', 'run', 'time') -const isSiteMerge = createStringUnionGuard>( +const isSortProp = createStringUnionGuard('date', 'host', 'focus', 'run', 'time') +const isSiteMerge = createStringUnionGuard>( 'cate', 'domain', 'group', ) /** * Init the query parameters */ -function parseQuery(route: RouteLocation, router: Router): [QueryPartial, ReportSort['prop'] | undefined] { +function parseQuery(route: RouteLocation, router: Router): [QueryPartial, RecordSort['prop'] | undefined] { const routeQuery = route.query const { q, mm, md, ds, de, sc } = routeQuery const dateStart = isString(ds) ? new Date(Number.parseInt(ds)) : undefined @@ -46,7 +46,7 @@ function parseQuery(route: RouteLocation, router: Router): [QueryPartial, Report return [partial, isSortProp(sc) ? sc : undefined] } -type CacheValue = Omit & { +type CacheValue = Omit & { dateStart?: number dateEnd?: number } @@ -61,7 +61,7 @@ const isCacheValue = createObjectGuard({ dateEnd: isOptionalInt, }) -const cvtStorage2Filter = (storage: CacheValue | undefined): ReportFilterOption => { +const cvtStorage2Filter = (storage: CacheValue | undefined): RecordFilterOption => { const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage ?? {} const now = new Date() return { @@ -75,7 +75,7 @@ const cvtStorage2Filter = (storage: CacheValue | undefined): ReportFilterOption } } -const cvtFilter2Storage = (filter: ReportFilterOption): CacheValue => { +const cvtFilter2Storage = (filter: RecordFilterOption): CacheValue => { const { query, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter const [dateStart, dateEnd] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] return { @@ -87,16 +87,16 @@ const cvtFilter2Storage = (filter: ReportFilterOption): CacheValue => { } } -export const initReportContext = () => { +export const initRecordContext = () => { const route = useRoute() const router = useRouter() const [queryFilter, querySort] = parseQuery(route, router) - const [cachedFilter, setCachedFilter] = useLocalStorage('report_filter', isCacheValue) - const filter = reactive({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) + const [cachedFilter, setCachedFilter] = useLocalStorage('record_filter', isCacheValue) + const filter = reactive({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) - const sort = ref({ + const sort = ref({ order: 'descending', prop: querySort ?? 'focus' }) @@ -109,8 +109,8 @@ export const initReportContext = () => { return context } -export const useReportFilter = (): ReportFilterOption => useProvider(NAMESPACE, "filter").filter +export const useRecordFilter = (): RecordFilterOption => useProvider(NAMESPACE, "filter").filter -export const useReportSort = (): ShallowRef => useProvider(NAMESPACE, 'sort').sort +export const useRecordSort = (): ShallowRef => useProvider(NAMESPACE, 'sort').sort -export const useReportComponent = () => useProvider(NAMESPACE, 'comp').comp \ No newline at end of file +export const useRecordComponent = () => useProvider(NAMESPACE, 'comp').comp \ No newline at end of file diff --git a/src/pages/app/components/Report/file-export.ts b/src/pages/app/components/Record/file-export.ts similarity index 94% rename from src/pages/app/components/Report/file-export.ts rename to src/pages/app/components/Record/file-export.ts index 818cc5e9b..6dc915433 100644 --- a/src/pages/app/components/Report/file-export.ts +++ b/src/pages/app/components/Record/file-export.ts @@ -11,7 +11,7 @@ import { exportCsv as exportCsv_, exportJson as exportJson_ } from "@util/file" import { CATE_NOT_SET_ID } from "@util/site" import { getAlias, getGroupName, getHost, getRelatedCateId, isGroup } from "@util/stat" import { formatTimeYMD } from "@util/time" -import type { ReportFilterOption } from "./types" +import type { RecordFilterOption } from "./types" type ExportInfo = { host?: string @@ -26,11 +26,11 @@ type ExportInfo = { /** * Compute the name of downloaded file */ -function computeFileName(filterParam: ReportFilterOption): string { +function computeFileName(filterParam: RecordFilterOption): string { const { dateRange, siteMerge, mergeDate, timeFormat } = filterParam const [ds, de] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] const parts = [ - t(msg => msg.report.exportFileName), + t(msg => msg.record.exportFileName), ds && formatTimeYMD(ds), de && formatTimeYMD(de), mergeDate && t(msg => msg.shared.merge.mergeMethod.date), @@ -64,7 +64,7 @@ const getCateName = (row: tt4b.stat.Row, categories: tt4b.site.Cate[]): string | export type ExportParam = { rows: tt4b.stat.Row[] - filter: ReportFilterOption + filter: RecordFilterOption categories: tt4b.site.Cate[] groupMap: Record } @@ -84,7 +84,7 @@ export function exportJson(param: ExportParam): void { type CsvColumn = keyof ExportInfo type CsvColumnConfig = { - visible: (mergeDate: boolean, siteMerge: ReportFilterOption['siteMerge']) => boolean + visible: (mergeDate: boolean, siteMerge: RecordFilterOption['siteMerge']) => boolean i18n: I18nKey formatter: (row: tt4b.stat.Row, categories: tt4b.site.Cate[], groupMap: Record) => string } diff --git a/src/pages/app/components/Report/index.tsx b/src/pages/app/components/Record/index.tsx similarity index 87% rename from src/pages/app/components/Report/index.tsx rename to src/pages/app/components/Record/index.tsx index 0e44aec53..6fb5677e5 100644 --- a/src/pages/app/components/Report/index.tsx +++ b/src/pages/app/components/Record/index.tsx @@ -8,13 +8,13 @@ import { useXsState } from '@hooks' import { defineComponent } from "vue" import ContentContainer from '../common/ContentContainer' -import { initReportContext } from "./context" +import { initRecordContext } from "./context" import Filter from "./Filter" import List from "./List" import Table from "./Table" const _default = defineComponent(() => { - const { comp } = initReportContext() + const { comp } = initRecordContext() const isXs = useXsState() return () => & { +export type RecordSort = Omit & { prop: tt4b.core.Dimension | 'host' | 'date' } -export type ReportFilterOption = { +export type RecordFilterOption = { query: string | undefined dateRange: DateRange mergeDate: boolean diff --git a/src/pages/app/router/constants.ts b/src/pages/app/router/constants.ts index 4c3116c23..485c854e0 100644 --- a/src/pages/app/router/constants.ts +++ b/src/pages/app/router/constants.ts @@ -12,7 +12,7 @@ export const DASHBOARD_ROUTE = '/data/dashboard' */ export { APP_ANALYSIS_ROUTE as ANALYSIS_ROUTE, APP_LIMIT_ROUTE as LIMIT_ROUTE, APP_OPTION_ROUTE as OPTION_ROUTE, - APP_REPORT_ROUTE as REPORT_ROUTE, type AppLimitQuery as LimitQuery, type AppReportQuery as ReportQuery + APP_RECORD_ROUTE as RECORD_ROUTE, type AppLimitQuery as LimitQuery, type AppRecordQuery as RecordQuery } from "@/shared/route" /** diff --git a/src/pages/app/router/index.ts b/src/pages/app/router/index.ts index 6366a72f1..106293dd4 100644 --- a/src/pages/app/router/index.ts +++ b/src/pages/app/router/index.ts @@ -7,7 +7,7 @@ import { type App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" -import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, MERGE_ROUTE, OPTION_ROUTE, REPORT_ROUTE } from "./constants" +import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, MERGE_ROUTE, OPTION_ROUTE, RECORD_ROUTE } from "./constants" const dataRoutes: RouteRecordRaw[] = [ { @@ -20,8 +20,8 @@ const dataRoutes: RouteRecordRaw[] = [ component: () => import('../components/Dashboard') }, { - path: REPORT_ROUTE, - component: () => import('../components/Report') + path: RECORD_ROUTE, + component: () => import('../components/Record') }, { path: ANALYSIS_ROUTE, component: () => import('../components/Analysis') diff --git a/src/pages/popup/common.tsx b/src/pages/popup/common.tsx index adf6080c2..3fd944cb6 100644 --- a/src/pages/popup/common.tsx +++ b/src/pages/popup/common.tsx @@ -1,6 +1,6 @@ import { getWeekStartTime } from "@api/sw/option" import { listCateStats, listGroupStats, listSiteStats } from '@api/sw/stat' -import { REPORT_ROUTE, type ReportQuery } from "@app/router/constants" +import { RECORD_ROUTE, type RecordQuery } from "@app/router/constants" import type { PopupDuration, PopupQuery } from "@popup/types" import { isRemainHost } from "@util/constant/remain-host" import { getAppPageUrl } from "@util/constant/url" @@ -41,8 +41,8 @@ export const queryRows = async (param: PopupQuery): Promise<[rows: tt4b.stat.Row return [rows, dateRange] } -function buildReportQuery(siteType: tt4b.site.Type, date: DateRange | undefined, type: tt4b.core.Dimension): ReportQuery { - const query: ReportQuery = {} +function buildRecordQuery(siteType: tt4b.site.Type, date: DateRange | undefined, type: tt4b.core.Dimension): RecordQuery { + const query: RecordQuery = {} // Merge host siteType === 'merged' && (query.mm = 'domain') // Date @@ -77,8 +77,8 @@ export function calJumpUrl( return `http://${host}` } - const query = buildReportQuery(siteType, date, type) + const query = buildRecordQuery(siteType, date, type) query.q = host - return getAppPageUrl(REPORT_ROUTE, query) + return getAppPageUrl(RECORD_ROUTE, query) } } \ No newline at end of file diff --git a/src/shared/route.ts b/src/shared/route.ts index 2c48c3261..2ec584852 100644 --- a/src/shared/route.ts +++ b/src/shared/route.ts @@ -12,11 +12,11 @@ export type AppAnalysisQuery = Partial & { } export const APP_OPTION_ROUTE = '/additional/option' -export const APP_REPORT_ROUTE = '/data/report' +export const APP_RECORD_ROUTE = '/data/record' /** - * The query param of report page + * The query param of record page */ -export type AppReportQuery = { +export type AppRecordQuery = { /** * Query */ diff --git a/test-e2e/common/record.ts b/test-e2e/common/record.ts index e78319e2b..107a8de8c 100644 --- a/test-e2e/common/record.ts +++ b/test-e2e/common/record.ts @@ -43,7 +43,7 @@ export function parseTime2Sec(timeStr: string | undefined): number | undefined { } export async function readRecordsOfFirstPage(context: LaunchContext) { - const recordPage = await context.openAppPage('/data/report') + const recordPage = await context.openAppPage('/data/record') // At least one record await recordPage.waitForSelector('.el-table .el-table__body-wrapper table tbody tr td') let records = await recordPage.evaluate(readRecords) diff --git a/test/util/pattern.test.ts b/test/util/pattern.test.ts index 607f4df41..00c489794 100644 --- a/test/util/pattern.test.ts +++ b/test/util/pattern.test.ts @@ -4,7 +4,7 @@ import { extractFileHost, extractHostname, isBrowserUrl, isHomepage, isIpAndPort test('browser url', () => { // chrome expect(isBrowserUrl('chrome://settings/')).toBeTruthy() - expect(isBrowserUrl('chrome-extension://hkjmfadlepammjmjiihpongliebpcnba/static/app.html#/data/report')).toBeTruthy() + expect(isBrowserUrl('chrome-extension://hkjmfadlepammjmjiihpongliebpcnba/static/app.html#/data/record')).toBeTruthy() // firefox expect(isBrowserUrl('about:addons')).toBeTruthy() // edge From aaa1a8f4ac9b1ce4ec6ac38a22c02ad8cee0052f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 00:18:30 +0000 Subject: [PATCH 06/95] i18n(download): download translations by bot --- src/i18n/message/app/menu-resource.json | 1 - src/i18n/message/app/option-resource.json | 4 ++-- src/i18n/message/app/record-resource.json | 3 --- src/i18n/message/popup/header-resource.json | 2 ++ 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index c775c4023..210bddfb2 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -197,7 +197,6 @@ "it": { "dashboard": "Dashboard", "data": "Miei Dati", - "record": "Record", "siteAnalysis": "Analisi Sito", "dataClear": "Memoria", "behavior": "Comportamento Dell'Utente", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 4e231e4a7..5ee7cdd9d 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1152,7 +1152,7 @@ "hard": "Strict", "disgusting": "Dégoûtant" }, - "strict": "Ne pas autoriser le déblocage quand même", + "strict": "Ne pas autoriser le déblocage", "strictTitle": "Confirmation de l'opération", "strictContent": "Lorsque vous sélectionnez cette option, si un site déclenche une limite quotidienne, vous ne serez pas autorisé à le débloquer manuellement sauf en attendant le lendemain. Si les règles ne sont pas correctement configurées, elles peuvent très bien gêner vos routines !", "pswFormLabel": "Mot de passe", @@ -1163,7 +1163,7 @@ "twoFaCopyLink": "Copier le lien", "twoFaVerifyLabel": "Entrez le code à 6 chiffres pour verifier" }, - "delayDuration": "Délai pour {input} minutes chaque fois" + "delayDuration": "Délai additionnel de {input} minutes à chaque fois" }, "backup": { "title": "Sauvegarde des données", diff --git a/src/i18n/message/app/record-resource.json b/src/i18n/message/app/record-resource.json index 1141f68f7..9b96fb627 100644 --- a/src/i18n/message/app/record-resource.json +++ b/src/i18n/message/app/record-resource.json @@ -105,7 +105,6 @@ "noMore": "Não mais" }, "uk": { - "exportFileName": "My_Browsing_Time", "total": "Всього відвідувань: {visit} разів, загальна тривалість: {focus}", "batchDelete": { "noSelectedMsg": "Спершу виберіть рядок, який ви хочете видалити", @@ -251,7 +250,6 @@ "noMore": "Daha Fazla Veri Yok" }, "pl": { - "exportFileName": "My_Browsing_Time", "total": "Całkowita liczba wizyt: {visit}, całkowity czas trwania: {focus}", "batchDelete": { "noSelectedMsg": "Proszę najpierw wybrać wiersz, który chcesz usunąć z tabeli", @@ -272,7 +270,6 @@ "noMore": "Nie więcej" }, "it": { - "exportFileName": "My_Browsing_Time", "total": "Visite totali: {visit} volte, durata totale: {focus}", "batchDelete": { "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 360b3b0b3..5af352876 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -8,6 +8,7 @@ }, "zh_CN": { "rating": "提交评价", + "discord": "加入 Discord", "showSiteName": "显示网站名称", "showTopN": "显示前 {n} 名", "donutChart": "以圆环图显示" @@ -43,6 +44,7 @@ }, "fr": { "rating": "Donner une note", + "discord": "Rejoignez notre Discord", "showSiteName": "Afficher le nom du site", "showTopN": "Afficher les {n} premiers", "donutChart": "Affichés sous forme de graphiques en anneau" From 42d66bb7c8f8ea021e3412720402639c1a6413bf Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 26 May 2026 00:21:04 +0800 Subject: [PATCH 07/95] refactor: cond editor --- rspack/util.ts | 4 +- src/api/chrome/tab.ts | 3 +- src/api/sw/whitelist.ts | 2 + src/background/database/whitelist-database.ts | 4 + src/background/message-dispatcher.ts | 1 + src/background/service/whitelist/holder.ts | 9 +- src/background/track-server/group.ts | 2 +- src/i18n/message/app/index.ts | 9 +- src/i18n/message/app/menu-resource.json | 30 +- src/i18n/message/app/menu.ts | 3 +- src/i18n/message/app/merge-rule-resource.json | 268 ----------- src/i18n/message/app/merge-rule.ts | 32 -- src/i18n/message/app/rule-resource.json | 433 ++++++++++++++++++ src/i18n/message/app/rule.ts | 37 ++ src/i18n/message/app/whitelist-resource.json | 139 ------ src/i18n/message/app/whitelist.ts | 23 - src/pages/app/Layout/icons/About.tsx | 18 - src/pages/app/Layout/icons/Database.tsx | 19 - src/pages/app/Layout/icons/Table.tsx | 16 - src/pages/app/Layout/icons/Website.tsx | 16 - src/pages/app/Layout/icons/Whitelist.tsx | 17 - src/pages/app/Layout/menu/item.ts | 24 +- src/pages/app/components/Limit/common.ts | 13 - .../Limit/components/Modify/Step2.tsx | 33 ++ .../components/Modify/Step2/SiteInput.tsx | 113 ----- .../Limit/components/Modify/Step2/index.tsx | 66 --- .../Limit/components/Modify/index.tsx | 2 +- .../Record/Table/columns/OperationColumn.tsx | 4 +- src/pages/app/components/Rule/AlertBox.tsx | 15 + .../{RuleMerge => Rule/Merge}/ItemList.tsx | 10 +- .../Merge}/components/AddButton.tsx | 0 .../Merge}/components/Item.tsx | 4 +- .../Merge}/components/ItemInput.tsx | 12 +- src/pages/app/components/Rule/Merge/index.tsx | 38 ++ src/pages/app/components/Rule/Whitelist.tsx | 35 ++ src/pages/app/components/Rule/index.tsx | 62 +++ src/pages/app/components/RuleMerge/index.tsx | 46 -- .../Whitelist/WhitePanel/AddButton.tsx | 40 -- .../Whitelist/WhitePanel/WhiteInput.tsx | 104 ----- .../Whitelist/WhitePanel/WhiteItem.tsx | 57 --- .../components/Whitelist/WhitePanel/index.tsx | 81 ---- src/pages/app/components/Whitelist/index.tsx | 34 -- .../app/components/common/AlertLines.tsx | 35 +- .../app/components/common/EditableTag.tsx | 7 +- src/pages/app/router/constants.ts | 5 +- src/pages/app/router/index.ts | 13 +- src/pages/components/CondEditor.tsx | 170 +++++++ src/pages/icons.tsx | 36 ++ src/util/constant/option.ts | 3 +- test-e2e/common/cond-editor.ts | 21 + test-e2e/common/whitelist.ts | 19 +- test-e2e/limit/common.ts | 11 +- types/common.d.ts | 2 + types/tt4b.d.ts | 1 + 54 files changed, 964 insertions(+), 1237 deletions(-) delete mode 100644 src/i18n/message/app/merge-rule-resource.json delete mode 100644 src/i18n/message/app/merge-rule.ts create mode 100644 src/i18n/message/app/rule-resource.json create mode 100644 src/i18n/message/app/rule.ts delete mode 100644 src/i18n/message/app/whitelist-resource.json delete mode 100644 src/i18n/message/app/whitelist.ts delete mode 100644 src/pages/app/Layout/icons/About.tsx delete mode 100644 src/pages/app/Layout/icons/Database.tsx delete mode 100644 src/pages/app/Layout/icons/Table.tsx delete mode 100644 src/pages/app/Layout/icons/Website.tsx delete mode 100644 src/pages/app/Layout/icons/Whitelist.tsx delete mode 100644 src/pages/app/components/Limit/common.ts create mode 100644 src/pages/app/components/Limit/components/Modify/Step2.tsx delete mode 100644 src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx delete mode 100644 src/pages/app/components/Limit/components/Modify/Step2/index.tsx create mode 100644 src/pages/app/components/Rule/AlertBox.tsx rename src/pages/app/components/{RuleMerge => Rule/Merge}/ItemList.tsx (88%) rename src/pages/app/components/{RuleMerge => Rule/Merge}/components/AddButton.tsx (100%) rename src/pages/app/components/{RuleMerge => Rule/Merge}/components/Item.tsx (94%) rename src/pages/app/components/{RuleMerge => Rule/Merge}/components/ItemInput.tsx (87%) create mode 100644 src/pages/app/components/Rule/Merge/index.tsx create mode 100644 src/pages/app/components/Rule/Whitelist.tsx create mode 100644 src/pages/app/components/Rule/index.tsx delete mode 100644 src/pages/app/components/RuleMerge/index.tsx delete mode 100644 src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx delete mode 100644 src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx delete mode 100644 src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx delete mode 100644 src/pages/app/components/Whitelist/WhitePanel/index.tsx delete mode 100644 src/pages/app/components/Whitelist/index.tsx create mode 100644 src/pages/components/CondEditor.tsx create mode 100644 test-e2e/common/cond-editor.ts diff --git a/rspack/util.ts b/rspack/util.ts index 6df6c9490..f05e1b3fa 100644 --- a/rspack/util.ts +++ b/rspack/util.ts @@ -1,6 +1,6 @@ -import type { RspackOptions, RspackPluginInstance } from '@rspack/core' +import type { Plugin, RspackOptions } from '@rspack/core' -export function enhancePluginWith(option: RspackOptions, ...toPush: RspackPluginInstance[]) { +export function enhancePluginWith(option: RspackOptions, ...toPush: Plugin[]) { const { plugins = [] } = option plugins.push(...toPush) option.plugins = plugins diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 79c9e6b27..acf91692c 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -56,7 +56,8 @@ export async function createTabAfterCurrent(url: string, currentTab?: ChromeTab) } export function listTabs(query?: chrome.tabs.QueryInfo): Promise { - query = query || {} + query ??= {} + if (IS_MV3) return chrome.tabs.query(query) return new Promise(resolve => chrome.tabs.query(query, tabs => { handleError("listTabs") resolve(tabs || []) diff --git a/src/api/sw/whitelist.ts b/src/api/sw/whitelist.ts index 14e3744df..3218b7198 100644 --- a/src/api/sw/whitelist.ts +++ b/src/api/sw/whitelist.ts @@ -5,3 +5,5 @@ export const listWhitelist = () => sendMsg2Runtime('whitelist.all') export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white) export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white) + +export const saveWhitelist = (whitelist: string[]) => sendMsg2Runtime('whitelist.save', whitelist) \ No newline at end of file diff --git a/src/background/database/whitelist-database.ts b/src/background/database/whitelist-database.ts index 47c1ccd2d..afc61f656 100644 --- a/src/background/database/whitelist-database.ts +++ b/src/background/database/whitelist-database.ts @@ -23,6 +23,10 @@ class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whi return exist || [] } + async saveAll(toSave: string[]): Promise { + await this.update(toSave) + } + async add(white: string): Promise { const exist = await this.selectAll() if (exist.includes(white)) return diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index f248607fd..5e15c1d4c 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -113,6 +113,7 @@ class MessageDispatcher { .register('whitelist.all', () => whitelistHolder.all()) .register('whitelist.add', white => whitelistHolder.add(white)) .register('whitelist.delete', white => whitelistHolder.remove(white)) + .register('whitelist.save', list => whitelistHolder.saveAll(list)) // Merge rule .register('merge.all', () => mergeRuleDatabase.selectAll()) .register('merge.delete', origin => mergeRuleDatabase.remove(origin)) diff --git a/src/background/service/whitelist/holder.ts b/src/background/service/whitelist/holder.ts index c974138c6..3280de659 100644 --- a/src/background/service/whitelist/holder.ts +++ b/src/background/service/whitelist/holder.ts @@ -19,8 +19,8 @@ class WhitelistHolder { this.rebuild() } - private async rebuild() { - const whitelist = await db.selectAll() + private async rebuild(whitelist?: string[]) { + whitelist ??= await db.selectAll() this.processor.setWhitelist(whitelist) this.postHandlers.forEach(handler => handler(whitelist)) } @@ -38,6 +38,11 @@ class WhitelistHolder { return db.selectAll() } + async saveAll(toSave: string[]): Promise { + await db.saveAll(toSave) + await this.rebuild(toSave) + } + async remove(white: string): Promise { await db.remove(white) await this.rebuild() diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index b8648eeac..f72a14e7c 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -10,7 +10,7 @@ function handleTabGroupsEnabled(option: tt4b.option.TrackingOption) { chrome.tabGroups.onRemoved.removeListener(handleRemove) chrome.tabGroups.onRemoved.addListener(handleRemove) } catch (e) { - console.warn('failed to handle event: enableTabGroup', e) + console.info('failed to handle event: enableTabGroup', e) } } diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 3d5f88bd1..92716d4ce 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -21,13 +21,12 @@ import habitMessages, { type HabitMessage } from "./habit" import helpUsMessages, { type HelpUsMessage } from "./help-us" import limitMessages, { type LimitMessage } from "./limit" import menuMessages, { type MenuMessage } from "./menu" -import mergeRuleMessages, { type MergeRuleMessage } from "./merge-rule" import operationMessages, { type OperationMessage } from './operation' import optionMessages, { type OptionMessage } from "./option" import recordMessages, { type RecordMessage } from "./record" +import ruleMessages, { type RuleMessage } from "./rule" import siteManageManages, { type SiteManageMessage } from "./site-manage" import timeFormatMessages, { type TimeFormatMessage } from "./time-format" -import whitelistMessages, { type WhitelistMessage } from "./whitelist" export type AppMessage = { about: AboutMessage @@ -35,8 +34,7 @@ export type AppMessage = { item: ItemMessage shared: SharedMessage record: RecordMessage - whitelist: WhitelistMessage - mergeRule: MergeRuleMessage + rule: RuleMessage option: OptionMessage analysis: AnalysisMessage menu: MenuMessage @@ -60,8 +58,7 @@ const MESSAGE_ROOT: MessageRoot = { item: itemMessages, shared: sharedMessages, record: recordMessages, - whitelist: whitelistMessages, - mergeRule: mergeRuleMessages, + rule: ruleMessages, option: optionMessages, analysis: analysisMessages, menu: menuMessages, diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 210bddfb2..9feee4539 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -9,8 +9,7 @@ "habit": "上网习惯", "additional": "附加功能", "siteManage": "网站管理", - "whitelist": "白名单管理", - "mergeRule": "子域名合并", + "rule": "规则管理", "other": "其他", "about": "关于" }, @@ -24,8 +23,6 @@ "habit": "習慣分析", "additional": "附加功能", "siteManage": "網站管理", - "whitelist": "白名單", - "mergeRule": "網站合併規則", "other": "其他", "about": "關於" }, @@ -39,8 +36,7 @@ "habit": "Habits", "additional": "Additional Features", "siteManage": "Site Management", - "whitelist": "Whitelist", - "mergeRule": "Merge-site Rules", + "rule": "Rules", "other": "Other Features", "about": "About" }, @@ -54,8 +50,6 @@ "habit": "閲覧の習慣", "additional": "その他の機能", "siteManage": "ウェブサイト管理", - "whitelist": "Webホワイトリスト", - "mergeRule": "ドメイン合併", "other": "その他の機能", "about": "について" }, @@ -69,8 +63,6 @@ "habit": "Hábitos", "additional": "Funcionalidades", "siteManage": "Gestão de Sites", - "whitelist": "Lista Branca", - "mergeRule": "Regras de Agrupamento", "other": "Outras Opções", "about": "Sobre" }, @@ -84,8 +76,6 @@ "habit": "Звички", "additional": "Додаткові функції", "siteManage": "Керування сайтами", - "whitelist": "Білий список", - "mergeRule": "Правила об'єднання сайтів", "other": "Інші функції", "about": "Про нас" }, @@ -99,8 +89,6 @@ "habit": "Hábitos", "additional": "Funciones adicionales", "siteManage": "Gestión de sitios", - "whitelist": "Lista blanca", - "mergeRule": "Reglas de fusión de sitios", "other": "Otras Funciones", "about": "Acerca de" }, @@ -114,8 +102,6 @@ "habit": "Gewohnheit", "additional": "Zusatzfunktionen", "siteManage": "Websites", - "whitelist": "Whitelist", - "mergeRule": "Regeln zusammenführen", "other": "Andere Eigenschaften", "about": "Über uns" }, @@ -129,8 +115,6 @@ "habit": "Habitudes", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", - "whitelist": "Liste blanche", - "mergeRule": "Fusionner les règles du site", "other": "Autres fonctionnalités", "about": "À propos" }, @@ -144,8 +128,6 @@ "habit": "Привычки", "additional": "Дополнительный", "siteManage": "Управление сайтом", - "whitelist": "Белый список", - "mergeRule": "Объединение сайтов", "other": "Другие особенности", "about": "О нас" }, @@ -159,8 +141,6 @@ "habit": "العادات", "additional": "ميزات إضافية", "siteManage": "أدوار النظام", - "whitelist": "القائمة البيضاء", - "mergeRule": "دمج قواعد الموقع", "other": "مميزات اخزي", "about": "حول" }, @@ -174,8 +154,6 @@ "habit": "Alışkanlıklar", "additional": "Ek Özellikler", "siteManage": "Site Yönetimi", - "whitelist": "Beyaz Liste", - "mergeRule": "Birleştirme Kuralları", "other": "Diğer Özellikler", "about": "Hakkımızda" }, @@ -189,8 +167,6 @@ "habit": "Nawyki", "additional": "Dodatkowe funkcje", "siteManage": "Zarządzanie stronami", - "whitelist": "Whitelista", - "mergeRule": "Reguły łączenia stron", "other": "Pozostałe funkcje", "about": "O rozszerzeniu" }, @@ -203,8 +179,6 @@ "habit": "Abitudini", "additional": "Funzionalità aggiuntive", "siteManage": "Gestione del sito", - "whitelist": "Whitelist", - "mergeRule": "Raggruppa regole dei siti", "other": "Altre funzioni", "about": "Info su" } diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index c52a95737..3f34f82bf 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -17,8 +17,7 @@ export type MenuMessage = { habit: string additional: string siteManage: string - whitelist: string - mergeRule: string + rule: string other: string about: string } diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json deleted file mode 100644 index 418b50957..000000000 --- a/src/i18n/message/app/merge-rule-resource.json +++ /dev/null @@ -1,268 +0,0 @@ -{ - "zh_CN": { - "removeConfirmMsg": "自定义合并规则 {origin} 将被移除", - "originPlaceholder": "原域名", - "mergedPlaceholder": "合并后域名", - "errorOrigin": "原域名格式错误", - "duplicateMsg": "合并规则已存在:{origin}", - "addConfirmMsg": "将为 {origin} 设置自定义合并规则", - "infoAlertTitle": "该页面可以配置子域名的合并规则", - "infoAlert0": "点击新增按钮,会弹出原域名和合并后域名的输入框,填写并保存规则", - "infoAlert1": "原域名可填具体的域名或者正则表达式,比如 www.baidu.com,*.baidu.com,*.google.com.*。以此确定哪些域名在合并时会使用该条规则", - "infoAlert2": "合并后域名可填具体的域名,或者填数字,或者不填", - "infoAlert3": "如果填数字,则表示合并后域名的级数。比如存在规则【 *.*.edu.cn >>> 3 】,那么 www.hust.edu.cn 将被合并至 hust.edu.cn", - "infoAlert4": "如果不填,则表示原域名不会被合并", - "infoAlert5": "如果没有命中任何规则,则默认会合并至 {psl} 的前一级", - "tagResult": { - "blank": "不合并", - "level": "{level} 级域名" - } - }, - "zh_TW": { - "removeConfirmMsg": "自訂合併規則 {origin} 將被刪除", - "originPlaceholder": "原始網域", - "mergedPlaceholder": "合併後網域", - "errorOrigin": "原始網域格式錯誤", - "duplicateMsg": "合併規則已存在:{origin}", - "addConfirmMsg": "將為 {origin} 設定自訂合併規則", - "infoAlertTitle": "此頁面可設定子網域合併規則", - "infoAlert0": "點擊「新增」按鈕後,需填寫原始網域與合併後網域", - "infoAlert1": "原始網域可填寫具體網域或正規表示式,例如:www.baidu.com、*.baidu.com、*.google.com.*", - "infoAlert2": "合併後網域可填寫:具體網域/數字/留空", - "infoAlert3": "若填數字,代表合併後的網域層級數。例如規則【 *.*.edu.cn >>> 3 】會將 www.hust.edu.cn 合併為 hust.edu.cn", - "infoAlert4": "若留空,表示原始網域將保持不變", - "infoAlert5": "若未匹配任何規則,預設會合併至 {psl} 的上一層級", - "tagResult": { - "blank": "保持原網域", - "level": "{level} 層網域" - } - }, - "en": { - "removeConfirmMsg": "{origin} will be removed from customized merge rules.", - "originPlaceholder": "Original site", - "mergedPlaceholder": "Merged", - "errorOrigin": "The format of original site is invalid.", - "duplicateMsg": "The rule already exists: {origin}", - "addConfirmMsg": "Customized merge rules will be set for {origin}", - "infoAlertTitle": "the merge rules when counting sites on this page", - "infoAlert0": "Click the [New] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", - "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", - "infoAlert2": "The merged site can be filled with a specific site, a number or blank", - "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", - "infoAlert4": "Blank means the original site will not be merged", - "infoAlert5": "If no rule is matched, it will default to the level before {psl}", - "tagResult": { - "blank": "Not Merge", - "level": "Keep Level {level}" - } - }, - "ja": { - "removeConfirmMsg": "カスタム マージ ルール {origin} は削除されます", - "originPlaceholder": "独自ドメイン名", - "mergedPlaceholder": "統計的ドメイン名", - "errorOrigin": "元のドメイン名の形式が間違っています", - "duplicateMsg": "ルールはすでに存在します:{origin}", - "addConfirmMsg": "カスタム マージ ルールが {origin} に設定されます", - "infoAlertTitle": "このページでは、サブドメインのマージ ルールを設定できます", - "infoAlert0": "[追加] ボタンをクリックすると、元のドメイン名と結合されたドメイン名の入力ボックスがポップアップし、ルールを入力して保存します。", - "infoAlert1": "元のドメイン名には、特定のドメイン名または正規表現 (www.baidu.com、*.baidu.com、*.google.com.* など) を入力できます。 マージ時にこのルールを使用するドメインを決定するには", - "infoAlert2": "統合されたドメイン名の後、特定のドメイン名を入力するか、番号を入力するか、空白のままにすることができます", - "infoAlert3": "数字を記入する場合は、ドメイン名のレベルが予約されていることを意味します。 たとえば、ルール [*.*.edu.cn >>> 3 ] がある場合、www.hust.edu.cn は hust.edu.cn にマージされます。", - "infoAlert4": "記入しない場合は、元のドメイン名が統合されないことを意味します", - "infoAlert5": "一致するルールがない場合、デフォルトで {psl} より前のレベルになります", - "tagResult": { - "blank": "不合并", - "level": "{level} 次ドメイン" - } - }, - "pt_PT": { - "removeConfirmMsg": "{origin} será removido das regras de agrupamento personalizadas.", - "originPlaceholder": "Site original", - "mergedPlaceholder": "Agrupado", - "errorOrigin": "Formato do site original inválido.", - "duplicateMsg": "A regra já existe: {origin}", - "addConfirmMsg": "Será criada uma regra de agrupamento para {origin}", - "infoAlertTitle": "Regras de agrupamento para contagem de sites nesta página", - "infoAlert0": "Clique em [Novo] para mostrar os campos do site original e do site agrupado", - "infoAlert1": "O site original pode ser um URL específico ou expressão regular (ex: www.baidu.com, *.baidu.com, *.google.com.*)", - "infoAlert2": "O site agrupado pode ser um URL específico, um número ou ficar em branco", - "infoAlert3": "Um número indica o nível de agrupamento. Ex: com a regra '*.*.edu.cn >>> 3', 'www.hust.edu.cn' será agrupado como 'hust.edu.cn'", - "infoAlert4": "Em branco significa que o site original não será agrupado", - "infoAlert5": "Se nenhuma regra for encontrada, usará o nível anterior a {psl}", - "tagResult": { - "blank": "Não Agrupar", - "level": "Manter Nível {level}" - } - }, - "uk": { - "removeConfirmMsg": "{origin} буде вилучено з налаштованих правил об'єднання.", - "originPlaceholder": "Оригінальний сайт", - "mergedPlaceholder": "Об'єднуваний", - "errorOrigin": "Неприпустимий формат оригінального сайту.", - "duplicateMsg": "Правило вже існує: {origin}", - "addConfirmMsg": "Для {origin} буде встановлено користувацьке правило об'єднання", - "infoAlertTitle": "На цій сторінці ви можете встановити правила об’єднання для статистики використання сайтів", - "infoAlert0": "Натисніть кнопку [Нове], заповніть поля оригінального та об'єднуваного сайту, після чого збережіть правило", - "infoAlert1": "Оригінальний сайт можна ввести як URL-адресу або регулярний вираз, як-от www.wikipedia.org, *.wikipedia.org, *.google.com.*, щоб визначити відповідні сайти для об'єднання", - "infoAlert2": "Об'єднуваний сайт можна ввести як URL-адресу, число, або залишити пустим", - "infoAlert3": "Число означає рівень об'єднуваного сайту. Наприклад, для правила '*.*.edu.cn >>> 3' об'єднуваний сайт 'www.hust.edu.cn' буде об'єднано з 'hust.edu.cn'", - "infoAlert4": "Пусте поле означає, що сайт не буде об'єднано", - "infoAlert5": "Якщо жодне правило не збігається, його буде встановлено до типового рівня {psl}", - "tagResult": { - "blank": "Не об'єднувати", - "level": "Зберегти рівень {level}" - } - }, - "es": { - "removeConfirmMsg": "{origin} será eliminado de las reglas de fusión personalizadas.", - "originPlaceholder": "Sitio original", - "mergedPlaceholder": "Fusionado", - "errorOrigin": "El formato del sitio original no es válido.", - "duplicateMsg": "La regla ya existe: {origin}", - "addConfirmMsg": "Las reglas de fusión personalizadas se establecerán para {origin}", - "infoAlertTitle": "Puedes establecer las reglas de fusión al contar sitios en esta página", - "infoAlert0": "Haz clic en el botón [New One], se mostrarán los recuadros del sitio fuente y el sitio fusionado, llénalos y guarda la regla", - "infoAlert1": "El sitio original puede ser llenado con un sitio específico o una expresión regular, tal como www.baidu.com, *.baidu.com, *.google.com.*, para determinar qué sitios seguirán esta regla al fusionarse", - "infoAlert2": "El sitio fusionado puede ser llenado con un sitio específico, un número o en dejarse en blanco", - "infoAlert3": "Un número significa el nivel del sitio fusionado. Por ejemplo, hay una regla '*.*.edu.cn >>> 3', entonces 'www.hust.edu.cn' se fusionará con 'hust.edu.cn'", - "infoAlert4": "Dejar en blanco significa que el sitio original no se fusionará", - "infoAlert5": "Si no se sigue ninguna regla, se establecerá por defecto al nivel antes de {psl}", - "tagResult": { - "blank": "No fusionar", - "level": "Mantener en nivel {level}" - } - }, - "de": { - "removeConfirmMsg": "{origin} wird aus den benutzerdefinierten Zusammenführen Regeln entfernt.", - "originPlaceholder": "Ursprüngliche Website", - "mergedPlaceholder": "Zusammengeführt", - "errorOrigin": "Das Format der Original-Website ist ungültig.", - "duplicateMsg": "Die Regel existiert bereits: {origin}", - "addConfirmMsg": "Für {origin} werden benutzerdefinierte Zusammenführungsregeln festgelegt", - "infoAlertTitle": "Auf dieser Seite können Sie Zusammenführen Regeln für die Zählung von Websites festlegen", - "infoAlert0": "Klicken Sie auf die Schaltfläche [Erstellen]. Die Eingabefelder der ursprünglichen Website und der zusammengeführten Website werden angezeigt. Füllen Sie die Regel aus und speichern Sie sie", - "infoAlert1": "Die ursprüngliche Website kann mit einer bestimmten Website oder einem regulären Ausdruck wie www.baidu.com, *.baidu.com, *.google.com.* gefüllt werden, um zu bestimmen, welche Websites beim Zusammenführen dieser Regel entsprechen", - "infoAlert2": "Die zusammengeführte Website kann mit einer bestimmten Site, einer Zahl oder einem Leerzeichen gefüllt werden", - "infoAlert3": "Eine Zahl gibt die Ebene der zusammengeführten Website an. Gibt es beispielsweise eine Regel „*.*.edu.cn >>> 3“, dann wird „www.hust.edu.cn“ als „hust.edu.cn“ zusammengeführt", - "infoAlert4": "Leer bedeutet, dass die ursprüngliche Website nicht zusammengeführt wird", - "infoAlert5": "Wenn keine Regel zutrifft, wird standardmäßig die Ebene vor {psl} verwendet", - "tagResult": { - "blank": "Nicht zusammenführen", - "level": "Level behalten {level}" - } - }, - "fr": { - "removeConfirmMsg": "{origin} sera supprimé des règles de fusion personnalisées.", - "originPlaceholder": "Site original", - "mergedPlaceholder": "Fusionné", - "errorOrigin": "Le format du site original est invalide.", - "duplicateMsg": "La règle existe déjà : {origin}", - "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", - "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", - "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", - "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion", - "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", - "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", - "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", - "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}", - "tagResult": { - "blank": "Non Fusionner", - "level": "Garder le niveau {level}" - } - }, - "ru": { - "removeConfirmMsg": "{origin} будет удален из настроенных правил слияния.", - "originPlaceholder": "Оригинальный сайт", - "mergedPlaceholder": "Соединено", - "errorOrigin": "Недопустимый формат оригинального сайта.", - "duplicateMsg": "Это правило уже существует: {origin}", - "addConfirmMsg": "Для {origin} будут установлены индивидуальные правила слияния", - "infoAlertTitle": "правила слияния при подсчете сайтов на этой странице", - "infoAlert0": "Нажмите кнопку [Новый], будут показаны поля ввода сайта источника и будут отображаться слияния, заполнять и сохранять правило", - "infoAlert1": "Исходный сайт может быть заполнен определенным сайтом или регулярным выражением, например www.baidu.com, *.baidu.com, *.google.com.*, чтобы определить, какие сайты будут соответствовать этому правилу при слиянии", - "infoAlert2": "Объединенный сайт может быть заполнен определенным сайтом, номером или пустым местом", - "infoAlert3": "Число означает уровень слияния сайта. Например, правило '*.*.edu.cn >>> 3', то 'www.hust.edu.cn' будет объединено с 'hust.edu.cn'", - "infoAlert4": "Пустое значение означает, что оригинальный сайт не будет объединён", - "infoAlert5": "Если правило не совпадает, оно будет по умолчанию на уровне до {psl}", - "tagResult": { - "blank": "Не объединять", - "level": "Сохранять уровень {level}" - } - }, - "ar": { - "removeConfirmMsg": "سيتم إزالة {origin} من قواعد الدمج المخصصة.", - "originPlaceholder": "الموقع الأصلي", - "mergedPlaceholder": "تم الدمج", - "errorOrigin": "تنسيق الموقع الأصلي غير صالح.", - "duplicateMsg": "القاعدة موجودة فعلًا: {origin}", - "addConfirmMsg": "سيتم تعيين قواعد الدمج المخصصة لـ {origin}", - "infoAlertTitle": "قواعد الدمج عند حساب المواقع الموجودة على هذه الصفحة", - "infoAlert0": "انقر فوق الزر [جديد]، سيتم عرض مربعات الإدخال الخاصة بموقع المصدر وموقع الدمج، املأ القاعدة واحفظها", - "infoAlert1": "يمكن ملء الموقع الأصلي بموقع معين أو تعبير عادي، مثل www.baidu.com، و*.baidu.com، و*.google.com.*، لتحديد المواقع التي ستطابق هذه القاعدة أثناء الدمج", - "infoAlert2": "يمكن ملء الموقع المدمج بموقع محدد أو رَقَم أو مساحة فارغة", - "infoAlert3": "يشير الرقم إلى مستوى الموقع المدمج. على سبيل المثال، هناك قاعدة '*.*.edu.cn >>> 3'، ثم سيتم دمج 'www.hust.edu.cn' في 'hust.edu.cn'", - "infoAlert4": "الفراغ يعني أن الموقع الأصلي لن يتم دمجه", - "infoAlert5": "إذا لم يتم مطابقة أي قاعدة، فسيتم تعيينها افتراضيًا على المستوى قبل {psl}", - "tagResult": { - "blank": "لا دمج", - "level": "الحفاظ على المستوى {level}" - } - }, - "tr": { - "removeConfirmMsg": "{origin} özelleştirilmiş birleştirme kurallarından kaldırılacaktır.", - "originPlaceholder": "Orijinal site", - "mergedPlaceholder": "Birleştirildi", - "errorOrigin": "Orijinal sitenin formatı geçersiz.", - "duplicateMsg": "Kural zaten mevcut: {origin}", - "addConfirmMsg": "{origin} için özelleştirilmiş birleştirme kuralları ayarlanacaktır", - "infoAlertTitle": "bu sayfadaki siteleri sayarken birleştirme kuralları", - "infoAlert0": "[Yeni] düğmesine tıklayın, kaynak sitenin ve birleştirme sitesinin giriş kutuları görüntülenecektir, kuralı doldurun ve kaydedin", - "infoAlert1": "Orijinal site, birleştirme sırasında bu kuralla eşleşecek siteleri belirlemek için www.baidu.com, *.baidu.com, *.google.com.* gibi belirli bir site veya düzenli ifade ile doldurulabilir", - "infoAlert2": "Birleştirilen site, belirli bir site, bir sayı veya boşluk ile doldurulabilir", - "infoAlert3": "Bir sayı, birleştirilen sitenin seviyesini ifade eder. Örneğin, ‘*.*.edu.cn >>> 3’ kuralı varsa, ‘www.hust.edu.cn’ sitesi ‘hust.edu.cn’ sitesiyle birleştirilir", - "infoAlert4": "Boşluk, orijinal sitenin birleştirilmeyeceği anlamına gelir", - "infoAlert5": "Eşleşen kural yoksa, varsayılan olarak {psl} öncesindeki seviyeye geçilir", - "tagResult": { - "blank": "Birleştirilemez", - "level": "Seviyeyi {level} koruyun" - } - }, - "pl": { - "removeConfirmMsg": "{origin} zostanie usunięty ze spersonalizowanych reguł scalania.", - "originPlaceholder": "Oryginalna strona", - "mergedPlaceholder": "Scalony", - "errorOrigin": "Format oryginalnej strony jest nieprawidłowy.", - "duplicateMsg": "Ta reguła już istnieje: {origin}", - "addConfirmMsg": "Spersonalizowane reguły scalania zostaną ustawione dla {origin}", - "infoAlertTitle": "reguły scalania podczas liczenia witryn na tej stronie", - "infoAlert0": "Kliknij przycisk [New One], pola wprowadzania strony źródłowej oraz strony scalenia zostaną wyświetlone, wypełnij i zapisz regułę", - "infoAlert1": "Oryginalna strona może być wypełniona konkretną stroną lub wyrażeniem regularnym, takim jak *.baidu.com, *.google.com.*, aby określić, które witryny będą odpowiadać tej regule podczas scalania", - "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", - "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", - "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", - "infoAlert5": "Jeśli żadna reguła nie jest dopasowana, będzie domyślnie przed {psl}", - "tagResult": { - "blank": "Nie scalaj", - "level": "Zachowaj poziom {level}" - } - }, - "it": { - "removeConfirmMsg": "{origin} Verrà rimosso dalle regole di unione personalizzate.", - "originPlaceholder": "Sito originale", - "mergedPlaceholder": "Uniti", - "errorOrigin": "Il formato del sito originale non è valido.", - "duplicateMsg": "La regola esiste già: {origin}", - "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", - "infoAlertTitle": "le regole di unione quando si contano i siti su questa pagina", - "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola", - "infoAlert1": "Il sito originale può essere compilato con un sito specifico o un'espressione regolare, ad esempio www.baidu.com, *.baidu.com, *.google.com*, per determinare quali siti saranno interessati da questa regola durante l'unione", - "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto", - "infoAlert3": "Un numero indica il livello del sito a cui viene unito. Ad esempio, se esiste una regola del tipo '*.*.edu.cn >>> 3', allora 'www.hust.edu.cn' verrà unito a 'hust.edu.cn'", - "infoAlert4": "Vuoto significa che il sito originale non verrà unito", - "infoAlert5": "Se non viene individuata alcuna regola corrispondente, verrà utilizzato per impostazione predefinita il livello precedente {psl}", - "tagResult": { - "blank": "Non unite", - "level": "Mantiene Livello {level}" - } - } -} \ No newline at end of file diff --git a/src/i18n/message/app/merge-rule.ts b/src/i18n/message/app/merge-rule.ts deleted file mode 100644 index 173ba961a..000000000 --- a/src/i18n/message/app/merge-rule.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2021-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import resource from './merge-rule-resource.json' - -export type MergeRuleMessage = { - removeConfirmMsg: string - originPlaceholder: string - mergedPlaceholder: string - errorOrigin: string - duplicateMsg: string - addConfirmMsg: string - infoAlertTitle: string - infoAlert0: string - infoAlert1: string - infoAlert2: string - infoAlert3: string - infoAlert4: string - infoAlert5: string - tagResult: { - blank: string - level: string - } -} - -const mergeRuleMessages: Messages = resource - -export default mergeRuleMessages \ No newline at end of file diff --git a/src/i18n/message/app/rule-resource.json b/src/i18n/message/app/rule-resource.json new file mode 100644 index 000000000..80b46d7fe --- /dev/null +++ b/src/i18n/message/app/rule-resource.json @@ -0,0 +1,433 @@ +{ + "zh_CN": { + "white": { + "label": "白名单", + "addConfirmMsg": "{url} 将被加入至白名单", + "removeConfirmMsg": "{url} 将从白名单中移除", + "infoAlertTitle": "你可以在这里配置网站白名单", + "infoAlert0": "白名单内网站的上网时长和打开次数不会被统计", + "infoAlert1": "白名单内网站的上网时间也不会被限制", + "infoAlert2": "您可以使用通配符 (*) 匹配多个站点,例如 *.example.com/**,并使用 + 作为前缀排除站点,例如 +need.example.com/**" + }, + "merge": { + "label": "子域名合并", + "removeConfirmMsg": "自定义合并规则 {origin} 将被移除", + "originPlaceholder": "原域名", + "mergedPlaceholder": "合并后域名", + "errorOrigin": "原域名格式错误", + "duplicateMsg": "合并规则已存在:{origin}", + "addConfirmMsg": "将为 {origin} 设置自定义合并规则", + "infoAlertTitle": "该页面可以配置子域名的合并规则", + "infoAlert0": "点击新增按钮,会弹出原域名和合并后域名的输入框,填写并保存规则", + "infoAlert1": "原域名可填具体的域名或者正则表达式,比如 www.baidu.com,*.baidu.com,*.google.com.*。以此确定哪些域名在合并时会使用该条规则", + "infoAlert2": "合并后域名可填具体的域名,或者填数字,或者不填", + "infoAlert3": "如果填数字,则表示合并后域名的级数。比如存在规则【 *.*.edu.cn >>> 3 】,那么 www.hust.edu.cn 将被合并至 hust.edu.cn", + "infoAlert4": "如果不填,则表示原域名不会被合并", + "infoAlert5": "如果没有命中任何规则,则默认会合并至 {psl} 的前一级", + "tagResult": { + "blank": "不合并", + "level": "{level} 级域名" + } + } + }, + "zh_TW": { + "white": { + "label": "白名單", + "addConfirmMsg": "{url} 將會被加入白名單。", + "removeConfirmMsg": "{url} 將會被白名單移除。", + "infoAlertTitle": "網站白名單設定", + "infoAlert0": "白名單內的網站將不會記錄瀏覽時長與造訪次數", + "infoAlert1": "白名單內的網站不受時間限制功能影響", + "infoAlert2": "您可以使用通配符(*)來匹配多個網站,例如*.example.com/​​**,並使用+作為前綴來排除網站,例如+need.example.com/​**" + }, + "merge": { + "label": "網站合併規則", + "removeConfirmMsg": "自訂合併規則 {origin} 將被刪除", + "originPlaceholder": "原始網域", + "mergedPlaceholder": "合併後網域", + "errorOrigin": "原始網域格式錯誤", + "duplicateMsg": "合併規則已存在:{origin}", + "addConfirmMsg": "將為 {origin} 設定自訂合併規則", + "infoAlertTitle": "此頁面可設定子網域合併規則", + "infoAlert0": "點擊「新增」按鈕後,需填寫原始網域與合併後網域", + "infoAlert1": "原始網域可填寫具體網域或正規表示式,例如:www.baidu.com、*.baidu.com、*.google.com.*", + "infoAlert2": "合併後網域可填寫:具體網域/數字/留空", + "infoAlert3": "若填數字,代表合併後的網域層級數。例如規則【 *.*.edu.cn >>> 3 】會將 www.hust.edu.cn 合併為 hust.edu.cn", + "infoAlert4": "若留空,表示原始網域將保持不變", + "infoAlert5": "若未匹配任何規則,預設會合併至 {psl} 的上一層級", + "tagResult": { + "blank": "保持原網域", + "level": "{level} 層網域" + } + } + }, + "en": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} will be added to the whitelist.", + "removeConfirmMsg": "{url} will be removed from the whitelist.", + "infoAlertTitle": "You can whitelist sites on this page", + "infoAlert0": "Whitelisted sites will not be counted", + "infoAlert1": "Whitelisted sites will not be restricted", + "infoAlert2": "You can use a wildcards(*) to match multiple sites, such as *.example.com/**, and use + as a prefix to exclude sites, such as +need.example.com/**" + }, + "merge": { + "label": "Merge-site Rules", + "removeConfirmMsg": "{origin} will be removed from customized merge rules.", + "originPlaceholder": "Original site", + "mergedPlaceholder": "Merged", + "errorOrigin": "The format of original site is invalid.", + "duplicateMsg": "The rule already exists: {origin}", + "addConfirmMsg": "Customized merge rules will be set for {origin}", + "infoAlertTitle": "the merge rules when counting sites on this page", + "infoAlert0": "Click the [New] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", + "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", + "infoAlert2": "The merged site can be filled with a specific site, a number or blank", + "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", + "infoAlert4": "Blank means the original site will not be merged", + "infoAlert5": "If no rule is matched, it will default to the level before {psl}", + "tagResult": { + "blank": "Not Merge", + "level": "Keep Level {level}" + } + } + }, + "ja": { + "white": { + "label": "ホワイトリスト", + "addConfirmMsg": "{url} がホワイトリストに追加されます。", + "removeConfirmMsg": "{url} はホワイトリストから削除されます", + "infoAlertTitle": "このページでサイトのホワイトリストを設定できます", + "infoAlert0": "ホワイトリストのサイトはカウントされません。", + "infoAlert1": "ホワイトリストのサイトは制限されません。" + }, + "merge": { + "label": "ドメイン合併", + "removeConfirmMsg": "カスタム マージ ルール {origin} は削除されます", + "originPlaceholder": "独自ドメイン名", + "mergedPlaceholder": "統計的ドメイン名", + "errorOrigin": "元のドメイン名の形式が間違っています", + "duplicateMsg": "ルールはすでに存在します:{origin}", + "addConfirmMsg": "カスタム マージ ルールが {origin} に設定されます", + "infoAlertTitle": "このページでは、サブドメインのマージ ルールを設定できます", + "infoAlert0": "[追加] ボタンをクリックすると、元のドメイン名と結合されたドメイン名の入力ボックスがポップアップし、ルールを入力して保存します。", + "infoAlert1": "元のドメイン名には、特定のドメイン名または正規表現 (www.baidu.com、*.baidu.com、*.google.com.* など) を入力できます。 マージ時にこのルールを使用するドメインを決定するには", + "infoAlert2": "統合されたドメイン名の後、特定のドメイン名を入力するか、番号を入力するか、空白のままにすることができます", + "infoAlert3": "数字を記入する場合は、ドメイン名のレベルが予約されていることを意味します。 たとえば、ルール [*.*.edu.cn >>> 3 ] がある場合、www.hust.edu.cn は hust.edu.cn にマージされます。", + "infoAlert4": "記入しない場合は、元のドメイン名が統合されないことを意味します", + "infoAlert5": "一致するルールがない場合、デフォルトで {psl} より前のレベルになります", + "tagResult": { + "blank": "不合并", + "level": "{level} 次ドメイン" + } + } + }, + "pt_PT": { + "white": { + "label": "Lista Branca", + "addConfirmMsg": "{url} será adicionado à lista de permissões.", + "removeConfirmMsg": "{url} será removido da lista de permissões.", + "infoAlertTitle": "Pode adicionar sites à lista de permissões nesta página", + "infoAlert0": "Os sites na lista de permissões não serão contabilizados", + "infoAlert1": "Os sites na lista de permissões não serão restringidos", + "infoAlert2": "Podes usar wildcards(*) para corresponder a vários sites, como *.example.com/**, e usar + como prefixo para excluir sites específicos, como +need.example.com/**" + }, + "merge": { + "label": "Regras de Agrupamento", + "removeConfirmMsg": "{origin} será removido das regras de agrupamento personalizadas.", + "originPlaceholder": "Site original", + "mergedPlaceholder": "Agrupado", + "errorOrigin": "Formato do site original inválido.", + "duplicateMsg": "A regra já existe: {origin}", + "addConfirmMsg": "Será criada uma regra de agrupamento para {origin}", + "infoAlertTitle": "Regras de agrupamento para contagem de sites nesta página", + "infoAlert0": "Clique em [Novo] para mostrar os campos do site original e do site agrupado", + "infoAlert1": "O site original pode ser um URL específico ou expressão regular (ex: www.baidu.com, *.baidu.com, *.google.com.*)", + "infoAlert2": "O site agrupado pode ser um URL específico, um número ou ficar em branco", + "infoAlert3": "Um número indica o nível de agrupamento. Ex: com a regra '*.*.edu.cn >>> 3', 'www.hust.edu.cn' será agrupado como 'hust.edu.cn'", + "infoAlert4": "Em branco significa que o site original não será agrupado", + "infoAlert5": "Se nenhuma regra for encontrada, usará o nível anterior a {psl}", + "tagResult": { + "blank": "Não Agrupar", + "level": "Manter Nível {level}" + } + } + }, + "uk": { + "white": { + "label": "Білий список", + "addConfirmMsg": "{url} буде додано до білого списку.", + "removeConfirmMsg": "{url} буде вилучено з білого списку.", + "infoAlertTitle": "На цій сторінці ви можете налаштувати білий список сайтів", + "infoAlert0": "Сайти в білому списку не враховуватимуться", + "infoAlert1": "Сайти в білому списку не матимуть обмежень", + "infoAlert2": "Можна використовувати змінні (*) для збігів декількох сайтів, наприклад *.example.com/**. Щоб виключити сайти, використовуйте +, наприклад +need.example.com/**" + }, + "merge": { + "label": "Правила об'єднання сайтів", + "removeConfirmMsg": "{origin} буде вилучено з налаштованих правил об'єднання.", + "originPlaceholder": "Оригінальний сайт", + "mergedPlaceholder": "Об'єднуваний", + "errorOrigin": "Неприпустимий формат оригінального сайту.", + "duplicateMsg": "Правило вже існує: {origin}", + "addConfirmMsg": "Для {origin} буде встановлено користувацьке правило об'єднання", + "infoAlertTitle": "На цій сторінці ви можете встановити правила об’єднання для статистики використання сайтів", + "infoAlert0": "Натисніть кнопку [Нове], заповніть поля оригінального та об'єднуваного сайту, після чого збережіть правило", + "infoAlert1": "Оригінальний сайт можна ввести як URL-адресу або регулярний вираз, як-от www.wikipedia.org, *.wikipedia.org, *.google.com.*, щоб визначити відповідні сайти для об'єднання", + "infoAlert2": "Об'єднуваний сайт можна ввести як URL-адресу, число, або залишити пустим", + "infoAlert3": "Число означає рівень об'єднуваного сайту. Наприклад, для правила '*.*.edu.cn >>> 3' об'єднуваний сайт 'www.hust.edu.cn' буде об'єднано з 'hust.edu.cn'", + "infoAlert4": "Пусте поле означає, що сайт не буде об'єднано", + "infoAlert5": "Якщо жодне правило не збігається, його буде встановлено до типового рівня {psl}", + "tagResult": { + "blank": "Не об'єднувати", + "level": "Зберегти рівень {level}" + } + } + }, + "es": { + "white": { + "label": "Lista blanca", + "addConfirmMsg": "{url} se agregará a la lista blanca.", + "removeConfirmMsg": "{url} será eliminado de la lista blanca.", + "infoAlertTitle": "Puedes configurar una lista blanca de sitios en esta página", + "infoAlert0": "Los sitios en la lista blanca no serán contados", + "infoAlert1": "Los sitios en la lista blanca no serán restringidos", + "infoAlert2": "Puedes usar un comodín (*) para coincidir varios sitios a la vez: *.ejemplo.com/**, y usar + como prefijo para excluir sitios, como +meme.ejemplo.com/**" + }, + "merge": { + "label": "Reglas de fusión de sitios", + "removeConfirmMsg": "{origin} será eliminado de las reglas de fusión personalizadas.", + "originPlaceholder": "Sitio original", + "mergedPlaceholder": "Fusionado", + "errorOrigin": "El formato del sitio original no es válido.", + "duplicateMsg": "La regla ya existe: {origin}", + "addConfirmMsg": "Las reglas de fusión personalizadas se establecerán para {origin}", + "infoAlertTitle": "Puedes establecer las reglas de fusión al contar sitios en esta página", + "infoAlert0": "Haz clic en el botón [New One], se mostrarán los recuadros del sitio fuente y el sitio fusionado, llénalos y guarda la regla", + "infoAlert1": "El sitio original puede ser llenado con un sitio específico o una expresión regular, tal como www.baidu.com, *.baidu.com, *.google.com.*, para determinar qué sitios seguirán esta regla al fusionarse", + "infoAlert2": "El sitio fusionado puede ser llenado con un sitio específico, un número o en dejarse en blanco", + "infoAlert3": "Un número significa el nivel del sitio fusionado. Por ejemplo, hay una regla '*.*.edu.cn >>> 3', entonces 'www.hust.edu.cn' se fusionará con 'hust.edu.cn'", + "infoAlert4": "Dejar en blanco significa que el sitio original no se fusionará", + "infoAlert5": "Si no se sigue ninguna regla, se establecerá por defecto al nivel antes de {psl}", + "tagResult": { + "blank": "No fusionar", + "level": "Mantener en nivel {level}" + } + } + }, + "de": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} wird zur Whitelist hinzugefügt.", + "removeConfirmMsg": "{url} wird von der Whitelist entfernt.", + "infoAlertTitle": "Sie können eine Whitelist vonseiten auf dieser Seite festlegen", + "infoAlert0": "Websites auf der Whitelist werden nicht gezählt", + "infoAlert1": "Websites auf der Whitelist werden nicht eingeschränkt", + "infoAlert2": "Sie können einen Platzhalter (*) verwenden, um mehrere Websites wie *.example.com/** zusammenzufassen, und + als Präfix verwenden, um Webseiten wie +need.example.com/** auszuschließen" + }, + "merge": { + "label": "Regeln zusammenführen", + "removeConfirmMsg": "{origin} wird aus den benutzerdefinierten Zusammenführen Regeln entfernt.", + "originPlaceholder": "Ursprüngliche Website", + "mergedPlaceholder": "Zusammengeführt", + "errorOrigin": "Das Format der Original-Website ist ungültig.", + "duplicateMsg": "Die Regel existiert bereits: {origin}", + "addConfirmMsg": "Für {origin} werden benutzerdefinierte Zusammenführungsregeln festgelegt", + "infoAlertTitle": "Auf dieser Seite können Sie Zusammenführen Regeln für die Zählung von Websites festlegen", + "infoAlert0": "Klicken Sie auf die Schaltfläche [Erstellen]. Die Eingabefelder der ursprünglichen Website und der zusammengeführten Website werden angezeigt. Füllen Sie die Regel aus und speichern Sie sie", + "infoAlert1": "Die ursprüngliche Website kann mit einer bestimmten Website oder einem regulären Ausdruck wie www.baidu.com, *.baidu.com, *.google.com.* gefüllt werden, um zu bestimmen, welche Websites beim Zusammenführen dieser Regel entsprechen", + "infoAlert2": "Die zusammengeführte Website kann mit einer bestimmten Site, einer Zahl oder einem Leerzeichen gefüllt werden", + "infoAlert3": "Eine Zahl gibt die Ebene der zusammengeführten Website an. Gibt es beispielsweise eine Regel „*.*.edu.cn >>> 3“, dann wird „www.hust.edu.cn“ als „hust.edu.cn“ zusammengeführt", + "infoAlert4": "Leer bedeutet, dass die ursprüngliche Website nicht zusammengeführt wird", + "infoAlert5": "Wenn keine Regel zutrifft, wird standardmäßig die Ebene vor {psl} verwendet", + "tagResult": { + "blank": "Nicht zusammenführen", + "level": "Level behalten {level}" + } + } + }, + "fr": { + "white": { + "label": "Liste blanche", + "addConfirmMsg": "{url} sera ajouté à la liste blanche.", + "removeConfirmMsg": "{url} sera supprimé de la liste blanche.", + "infoAlertTitle": "Vous pouvez ajouter des sites à la liste blanche sur cette page", + "infoAlert0": "Les sites sur liste blanche ne seront pas comptés", + "infoAlert1": "Les sites sur liste blanche ne seront pas restreints", + "infoAlert2": "Vous pouvez utiliser un signe générique(*) pour cibler plusieurs sites, comme *.example.com/**, et utiliser + comme un préfixe pour exclure des sites, comme +need.example.com/**" + }, + "merge": { + "label": "Fusionner les règles du site", + "removeConfirmMsg": "{origin} sera supprimé des règles de fusion personnalisées.", + "originPlaceholder": "Site original", + "mergedPlaceholder": "Fusionné", + "errorOrigin": "Le format du site original est invalide.", + "duplicateMsg": "La règle existe déjà : {origin}", + "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", + "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", + "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", + "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion", + "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", + "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", + "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", + "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}", + "tagResult": { + "blank": "Non Fusionner", + "level": "Garder le niveau {level}" + } + } + }, + "ru": { + "white": { + "label": "Белый список", + "addConfirmMsg": "{url} будет добавлен в белый список.", + "removeConfirmMsg": "{url} будет удален из белого списка.", + "infoAlertTitle": "Вы можете добавить сайты в белый список на этой странице", + "infoAlert0": "Сайты, внесенные в белый список, не будут учитываться", + "infoAlert1": "Сайты из белого списка не будут ограничены" + }, + "merge": { + "label": "Объединение сайтов", + "removeConfirmMsg": "{origin} будет удален из настроенных правил слияния.", + "originPlaceholder": "Оригинальный сайт", + "mergedPlaceholder": "Соединено", + "errorOrigin": "Недопустимый формат оригинального сайта.", + "duplicateMsg": "Это правило уже существует: {origin}", + "addConfirmMsg": "Для {origin} будут установлены индивидуальные правила слияния", + "infoAlertTitle": "правила слияния при подсчете сайтов на этой странице", + "infoAlert0": "Нажмите кнопку [Новый], будут показаны поля ввода сайта источника и будут отображаться слияния, заполнять и сохранять правило", + "infoAlert1": "Исходный сайт может быть заполнен определенным сайтом или регулярным выражением, например www.baidu.com, *.baidu.com, *.google.com.*, чтобы определить, какие сайты будут соответствовать этому правилу при слиянии", + "infoAlert2": "Объединенный сайт может быть заполнен определенным сайтом, номером или пустым местом", + "infoAlert3": "Число означает уровень слияния сайта. Например, правило '*.*.edu.cn >>> 3', то 'www.hust.edu.cn' будет объединено с 'hust.edu.cn'", + "infoAlert4": "Пустое значение означает, что оригинальный сайт не будет объединён", + "infoAlert5": "Если правило не совпадает, оно будет по умолчанию на уровне до {psl}", + "tagResult": { + "blank": "Не объединять", + "level": "Сохранять уровень {level}" + } + } + }, + "ar": { + "white": { + "label": "القائمة البيضاء", + "addConfirmMsg": "سيتم إضافة {url} إلى القائمة البيضاء.", + "removeConfirmMsg": "سيتم إزالة {url} من القائمة البيضاء.", + "infoAlertTitle": "يمكنك إضافة المواقع إلى القائمة البيضاء في هذه الصفحة", + "infoAlert0": "لن يتم احتساب المواقع المدرجة في القائمة البيضاء", + "infoAlert1": "لن يتم تقييد المواقع المدرجة في القائمة البيضاء" + }, + "merge": { + "label": "دمج قواعد الموقع", + "removeConfirmMsg": "سيتم إزالة {origin} من قواعد الدمج المخصصة.", + "originPlaceholder": "الموقع الأصلي", + "mergedPlaceholder": "تم الدمج", + "errorOrigin": "تنسيق الموقع الأصلي غير صالح.", + "duplicateMsg": "القاعدة موجودة فعلًا: {origin}", + "addConfirmMsg": "سيتم تعيين قواعد الدمج المخصصة لـ {origin}", + "infoAlertTitle": "قواعد الدمج عند حساب المواقع الموجودة على هذه الصفحة", + "infoAlert0": "انقر فوق الزر [جديد]، سيتم عرض مربعات الإدخال الخاصة بموقع المصدر وموقع الدمج، املأ القاعدة واحفظها", + "infoAlert1": "يمكن ملء الموقع الأصلي بموقع معين أو تعبير عادي، مثل www.baidu.com، و*.baidu.com، و*.google.com.*، لتحديد المواقع التي ستطابق هذه القاعدة أثناء الدمج", + "infoAlert2": "يمكن ملء الموقع المدمج بموقع محدد أو رَقَم أو مساحة فارغة", + "infoAlert3": "يشير الرقم إلى مستوى الموقع المدمج. على سبيل المثال، هناك قاعدة '*.*.edu.cn >>> 3'، ثم سيتم دمج 'www.hust.edu.cn' في 'hust.edu.cn'", + "infoAlert4": "الفراغ يعني أن الموقع الأصلي لن يتم دمجه", + "infoAlert5": "إذا لم يتم مطابقة أي قاعدة، فسيتم تعيينها افتراضيًا على المستوى قبل {psl}", + "tagResult": { + "blank": "لا دمج", + "level": "الحفاظ على المستوى {level}" + } + } + }, + "tr": { + "white": { + "label": "Beyaz Liste", + "addConfirmMsg": "{url} beyaz listeye eklenecektir.", + "removeConfirmMsg": "{url} beyaz listeden kaldırılacaktır.", + "infoAlertTitle": "Bu sayfada siteleri beyaz listeye ekleyebilirsiniz", + "infoAlert0": "Beyaz listeye alınan siteler sayılmayacaktır", + "infoAlert1": "Beyaz listeye alınan siteler kısıtlanmayacaktır", + "infoAlert2": "Birden fazla siteyi eşleştirmek için joker karakterler (*) kullanabilirsiniz, örneğin *.example.com/**, ve siteleri hariç tutmak için + ön ekini kullanabilirsiniz, örneğin +need.example.com/**" + }, + "merge": { + "label": "Birleştirme Kuralları", + "removeConfirmMsg": "{origin} özelleştirilmiş birleştirme kurallarından kaldırılacaktır.", + "originPlaceholder": "Orijinal site", + "mergedPlaceholder": "Birleştirildi", + "errorOrigin": "Orijinal sitenin formatı geçersiz.", + "duplicateMsg": "Kural zaten mevcut: {origin}", + "addConfirmMsg": "{origin} için özelleştirilmiş birleştirme kuralları ayarlanacaktır", + "infoAlertTitle": "bu sayfadaki siteleri sayarken birleştirme kuralları", + "infoAlert0": "[Yeni] düğmesine tıklayın, kaynak sitenin ve birleştirme sitesinin giriş kutuları görüntülenecektir, kuralı doldurun ve kaydedin", + "infoAlert1": "Orijinal site, birleştirme sırasında bu kuralla eşleşecek siteleri belirlemek için www.baidu.com, *.baidu.com, *.google.com.* gibi belirli bir site veya düzenli ifade ile doldurulabilir", + "infoAlert2": "Birleştirilen site, belirli bir site, bir sayı veya boşluk ile doldurulabilir", + "infoAlert3": "Bir sayı, birleştirilen sitenin seviyesini ifade eder. Örneğin, ‘*.*.edu.cn >>> 3’ kuralı varsa, ‘www.hust.edu.cn’ sitesi ‘hust.edu.cn’ sitesiyle birleştirilir", + "infoAlert4": "Boşluk, orijinal sitenin birleştirilmeyeceği anlamına gelir", + "infoAlert5": "Eşleşen kural yoksa, varsayılan olarak {psl} öncesindeki seviyeye geçilir", + "tagResult": { + "blank": "Birleştirilemez", + "level": "Seviyeyi {level} koruyun" + } + } + }, + "pl": { + "white": { + "label": "Whitelista", + "addConfirmMsg": "{url} zostanie dodany do whitelisty.", + "removeConfirmMsg": "{url} zostanie usunięty z whitelisty.", + "infoAlertTitle": "Na tej stronie możesz dodawać strony do whitelisty", + "infoAlert0": "Strony znajdujące się na whiteliście nie będą liczone", + "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", + "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**" + }, + "merge": { + "label": "Reguły łączenia stron", + "removeConfirmMsg": "{origin} zostanie usunięty ze spersonalizowanych reguł scalania.", + "originPlaceholder": "Oryginalna strona", + "mergedPlaceholder": "Scalony", + "errorOrigin": "Format oryginalnej strony jest nieprawidłowy.", + "duplicateMsg": "Ta reguła już istnieje: {origin}", + "addConfirmMsg": "Spersonalizowane reguły scalania zostaną ustawione dla {origin}", + "infoAlertTitle": "reguły scalania podczas liczenia witryn na tej stronie", + "infoAlert0": "Kliknij przycisk [New One], pola wprowadzania strony źródłowej oraz strony scalenia zostaną wyświetlone, wypełnij i zapisz regułę", + "infoAlert1": "Oryginalna strona może być wypełniona konkretną stroną lub wyrażeniem regularnym, takim jak *.baidu.com, *.google.com.*, aby określić, które witryny będą odpowiadać tej regule podczas scalania", + "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", + "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", + "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", + "infoAlert5": "Jeśli żadna reguła nie jest dopasowana, będzie domyślnie przed {psl}", + "tagResult": { + "blank": "Nie scalaj", + "level": "Zachowaj poziom {level}" + } + } + }, + "it": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", + "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", + "infoAlertTitle": "È possibile aggiungere alla whitelist questa pagina", + "infoAlert0": "I siti in whitelist non saranno conteggiati", + "infoAlert1": "I siti in whitelist non saranno conteggiati", + "infoAlert2": "È possibile utilizzare un jolly(*) per abbinare più siti, come *.example.com/**, e utilizzare + come prefisso per escludere i siti, come +need.example.com/**" + }, + "merge": { + "label": "Raggruppa regole dei siti", + "removeConfirmMsg": "{origin} Verrà rimosso dalle regole di unione personalizzate.", + "originPlaceholder": "Sito originale", + "mergedPlaceholder": "Uniti", + "errorOrigin": "Il formato del sito originale non è valido.", + "duplicateMsg": "La regola esiste già: {origin}", + "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", + "infoAlertTitle": "le regole di unione quando si contano i siti su questa pagina", + "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola", + "infoAlert1": "Il sito originale può essere compilato con un sito specifico o un'espressione regolare, ad esempio www.baidu.com, *.baidu.com, *.google.com*, per determinare quali siti saranno interessati da questa regola durante l'unione", + "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto", + "infoAlert3": "Un numero indica il livello del sito a cui viene unito. Ad esempio, se esiste una regola del tipo '*.*.edu.cn >>> 3', allora 'www.hust.edu.cn' verrà unito a 'hust.edu.cn'", + "infoAlert4": "Vuoto significa che il sito originale non verrà unito", + "infoAlert5": "Se non viene individuata alcuna regola corrispondente, verrà utilizzato per impostazione predefinita il livello precedente {psl}", + "tagResult": { + "blank": "Non unite", + "level": "Mantiene Livello {level}" + } + } + } +} \ No newline at end of file diff --git a/src/i18n/message/app/rule.ts b/src/i18n/message/app/rule.ts new file mode 100644 index 000000000..0745cbb49 --- /dev/null +++ b/src/i18n/message/app/rule.ts @@ -0,0 +1,37 @@ +import resource from './rule-resource.json' + +export type RuleMessage = { + white: { + label: string + addConfirmMsg: string + removeConfirmMsg: string + infoAlertTitle: string + infoAlert0: string + infoAlert1: string + infoAlert2: string + } + merge: { + label: string + removeConfirmMsg: string + originPlaceholder: string + mergedPlaceholder: string + errorOrigin: string + duplicateMsg: string + addConfirmMsg: string + infoAlertTitle: string + infoAlert0: string + infoAlert1: string + infoAlert2: string + infoAlert3: string + infoAlert4: string + infoAlert5: string + tagResult: { + blank: string + level: string + } + } +} + +const _default: Messages = resource satisfies Messages + +export default _default \ No newline at end of file diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json deleted file mode 100644 index b574ce547..000000000 --- a/src/i18n/message/app/whitelist-resource.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "zh_CN": { - "addConfirmMsg": "{url} 将被加入至白名单", - "removeConfirmMsg": "{url} 将从白名单中移除", - "duplicateMsg": "已存在白名单中", - "infoAlertTitle": "你可以在这里配置网站白名单", - "infoAlert0": "白名单内网站的上网时长和打开次数不会被统计", - "infoAlert1": "白名单内网站的上网时间也不会被限制", - "infoAlert2": "您可以使用通配符 (*) 匹配多个站点,例如 *.example.com/**,并使用 + 作为前缀排除站点,例如 +need.example.com/**", - "errorInput": "域名格式错误" - }, - "zh_TW": { - "addConfirmMsg": "{url} 將會被加入白名單。", - "removeConfirmMsg": "{url} 將會被白名單移除。", - "duplicateMsg": "此網址已存在於白名單中", - "infoAlertTitle": "網站白名單設定", - "infoAlert0": "白名單內的網站將不會記錄瀏覽時長與造訪次數", - "infoAlert1": "白名單內的網站不受時間限制功能影響", - "infoAlert2": "您可以使用通配符(*)來匹配多個網站,例如*.example.com/​​**,並使用+作為前綴來排除網站,例如+need.example.com/​**", - "errorInput": "網域名稱格式錯誤" - }, - "en": { - "addConfirmMsg": "{url} will be added to the whitelist.", - "removeConfirmMsg": "{url} will be removed from the whitelist.", - "duplicateMsg": "Duplicated", - "infoAlertTitle": "You can whitelist sites on this page", - "infoAlert0": "Whitelisted sites will not be counted", - "infoAlert1": "Whitelisted sites will not be restricted", - "infoAlert2": "You can use a wildcards(*) to match multiple sites, such as *.example.com/**, and use + as a prefix to exclude sites, such as +need.example.com/**", - "errorInput": "Invalid site URL" - }, - "ja": { - "addConfirmMsg": "{url} がホワイトリストに追加されます。", - "removeConfirmMsg": "{url} はホワイトリストから削除されます", - "duplicateMsg": "繰り返される", - "infoAlertTitle": "このページでサイトのホワイトリストを設定できます", - "infoAlert0": "ホワイトリストのサイトはカウントされません。", - "infoAlert1": "ホワイトリストのサイトは制限されません。", - "errorInput": "無効なURL" - }, - "pt_PT": { - "addConfirmMsg": "{url} será adicionado à lista de permissões.", - "removeConfirmMsg": "{url} será removido da lista de permissões.", - "duplicateMsg": "Duplicado", - "infoAlertTitle": "Pode adicionar sites à lista de permissões nesta página", - "infoAlert0": "Os sites na lista de permissões não serão contabilizados", - "infoAlert1": "Os sites na lista de permissões não serão restringidos", - "infoAlert2": "Podes usar wildcards(*) para corresponder a vários sites, como *.example.com/**, e usar + como prefixo para excluir sites específicos, como +need.example.com/**", - "errorInput": "URL do site inválido" - }, - "uk": { - "addConfirmMsg": "{url} буде додано до білого списку.", - "removeConfirmMsg": "{url} буде вилучено з білого списку.", - "duplicateMsg": "Дублікат", - "infoAlertTitle": "На цій сторінці ви можете налаштувати білий список сайтів", - "infoAlert0": "Сайти в білому списку не враховуватимуться", - "infoAlert1": "Сайти в білому списку не матимуть обмежень", - "infoAlert2": "Можна використовувати змінні (*) для збігів декількох сайтів, наприклад *.example.com/**. Щоб виключити сайти, використовуйте +, наприклад +need.example.com/**", - "errorInput": "Неприпустима URL-адреса сайту" - }, - "es": { - "addConfirmMsg": "{url} se agregará a la lista blanca.", - "removeConfirmMsg": "{url} será eliminado de la lista blanca.", - "duplicateMsg": "Duplicado", - "infoAlertTitle": "Puedes configurar una lista blanca de sitios en esta página", - "infoAlert0": "Los sitios en la lista blanca no serán contados", - "infoAlert1": "Los sitios en la lista blanca no serán restringidos", - "infoAlert2": "Puedes usar un comodín (*) para coincidir varios sitios a la vez: *.ejemplo.com/**, y usar + como prefijo para excluir sitios, como +meme.ejemplo.com/**", - "errorInput": "URL del sitio inválida" - }, - "de": { - "addConfirmMsg": "{url} wird zur Whitelist hinzugefügt.", - "removeConfirmMsg": "{url} wird von der Whitelist entfernt.", - "duplicateMsg": "Dupliziert", - "infoAlertTitle": "Sie können eine Whitelist vonseiten auf dieser Seite festlegen", - "infoAlert0": "Websites auf der Whitelist werden nicht gezählt", - "infoAlert1": "Websites auf der Whitelist werden nicht eingeschränkt", - "infoAlert2": "Sie können einen Platzhalter (*) verwenden, um mehrere Websites wie *.example.com/** zusammenzufassen, und + als Präfix verwenden, um Webseiten wie +need.example.com/** auszuschließen", - "errorInput": "Ungültige Seiten-URL" - }, - "fr": { - "addConfirmMsg": "{url} sera ajouté à la liste blanche.", - "removeConfirmMsg": "{url} sera supprimé de la liste blanche.", - "duplicateMsg": "Doublon", - "infoAlertTitle": "Vous pouvez ajouter des sites à la liste blanche sur cette page", - "infoAlert0": "Les sites sur liste blanche ne seront pas comptés", - "infoAlert1": "Les sites sur liste blanche ne seront pas restreints", - "infoAlert2": "Vous pouvez utiliser un signe générique(*) pour cibler plusieurs sites, comme *.example.com/**, et utiliser + comme un préfixe pour exclure des sites, comme +need.example.com/**", - "errorInput": "URL du site invalide" - }, - "ru": { - "addConfirmMsg": "{url} будет добавлен в белый список.", - "removeConfirmMsg": "{url} будет удален из белого списка.", - "duplicateMsg": "Дублированный", - "infoAlertTitle": "Вы можете добавить сайты в белый список на этой странице", - "infoAlert0": "Сайты, внесенные в белый список, не будут учитываться", - "infoAlert1": "Сайты из белого списка не будут ограничены", - "errorInput": "Неверный URL-адрес сайта" - }, - "ar": { - "addConfirmMsg": "سيتم إضافة {url} إلى القائمة البيضاء.", - "removeConfirmMsg": "سيتم إزالة {url} من القائمة البيضاء.", - "duplicateMsg": "مكررة", - "infoAlertTitle": "يمكنك إضافة المواقع إلى القائمة البيضاء في هذه الصفحة", - "infoAlert0": "لن يتم احتساب المواقع المدرجة في القائمة البيضاء", - "infoAlert1": "لن يتم تقييد المواقع المدرجة في القائمة البيضاء", - "errorInput": "عنوان الموقع غير صالح" - }, - "tr": { - "addConfirmMsg": "{url} beyaz listeye eklenecektir.", - "removeConfirmMsg": "{url} beyaz listeden kaldırılacaktır.", - "duplicateMsg": "Yinelenen", - "infoAlertTitle": "Bu sayfada siteleri beyaz listeye ekleyebilirsiniz", - "infoAlert0": "Beyaz listeye alınan siteler sayılmayacaktır", - "infoAlert1": "Beyaz listeye alınan siteler kısıtlanmayacaktır", - "infoAlert2": "Birden fazla siteyi eşleştirmek için joker karakterler (*) kullanabilirsiniz, örneğin *.example.com/**, ve siteleri hariç tutmak için + ön ekini kullanabilirsiniz, örneğin +need.example.com/**", - "errorInput": "Geçersiz site URL'si" - }, - "pl": { - "addConfirmMsg": "{url} zostanie dodany do whitelisty.", - "removeConfirmMsg": "{url} zostanie usunięty z whitelisty.", - "duplicateMsg": "Duplikat", - "infoAlertTitle": "Na tej stronie możesz dodawać strony do whitelisty", - "infoAlert0": "Strony znajdujące się na whiteliście nie będą liczone", - "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", - "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**", - "errorInput": "Niepoprawny URL strony" - }, - "it": { - "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", - "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", - "duplicateMsg": "Duplicato", - "infoAlertTitle": "È possibile aggiungere alla whitelist questa pagina", - "infoAlert0": "I siti in whitelist non saranno conteggiati", - "infoAlert1": "I siti in whitelist non saranno conteggiati", - "infoAlert2": "È possibile utilizzare un jolly(*) per abbinare più siti, come *.example.com/**, e utilizzare + come prefisso per escludere i siti, come +need.example.com/**", - "errorInput": "URL del sito non valido" - } -} \ No newline at end of file diff --git a/src/i18n/message/app/whitelist.ts b/src/i18n/message/app/whitelist.ts deleted file mode 100644 index 54741f6f5..000000000 --- a/src/i18n/message/app/whitelist.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import resource from './whitelist-resource.json' - -export type WhitelistMessage = { - addConfirmMsg: string - removeConfirmMsg: string - duplicateMsg: string - infoAlertTitle: string - infoAlert0: string - infoAlert1: string - infoAlert2: string - errorInput: string -} - -const _default: Messages = resource - -export default _default \ No newline at end of file diff --git a/src/pages/app/Layout/icons/About.tsx b/src/pages/app/Layout/icons/About.tsx deleted file mode 100644 index a284cd7dc..000000000 --- a/src/pages/app/Layout/icons/About.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type FunctionalComponent } from "vue" - -const About: FunctionalComponent = () => ( - - - - - -) - -export default About \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Database.tsx b/src/pages/app/Layout/icons/Database.tsx deleted file mode 100644 index e3544ede2..000000000 --- a/src/pages/app/Layout/icons/Database.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Database: FunctionalComponent = () => ( - - - - - - -) - -export default Database \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Table.tsx b/src/pages/app/Layout/icons/Table.tsx deleted file mode 100644 index 7575d2896..000000000 --- a/src/pages/app/Layout/icons/Table.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Table: FunctionalComponent = () => ( - - - -) - -export default Table \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Website.tsx b/src/pages/app/Layout/icons/Website.tsx deleted file mode 100644 index 5132f081d..000000000 --- a/src/pages/app/Layout/icons/Website.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Website: FunctionalComponent = () => ( - - - -) - -export default Website \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Whitelist.tsx b/src/pages/app/Layout/icons/Whitelist.tsx deleted file mode 100644 index bd8faabbd..000000000 --- a/src/pages/app/Layout/icons/Whitelist.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Whitelist: FunctionalComponent = () => ( - - - - -) - -export default Whitelist \ No newline at end of file diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index 04cc4ab96..bf1020f52 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -7,16 +7,13 @@ */ import { type I18nKey } from '@app/locale' -import { ANALYSIS_ROUTE, MERGE_ROUTE, RECORD_ROUTE } from '@app/router/constants' -import { Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, Rank, SetUp, Stopwatch, Timer, View } from "@element-plus/icons-vue" -import { Trend } from '@pages/icons' +import { ANALYSIS_ROUTE, RECORD_ROUTE, RULE_ROUTE } from '@app/router/constants' +import { + Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, SetUp, Stopwatch, Timer, View, +} from "@element-plus/icons-vue" +import { About, Database, Rule, Table, Trend, Website } from '@pages/icons' import { getGuidePageUrl } from "@util/constant/url" import { type Component } from 'vue' -import About from "../icons/About" -import Database from "../icons/Database" -import Table from "../icons/Table" -import Website from "../icons/Website" -import Whitelist from "../icons/Whitelist" type MenuBase = { title: I18nKey @@ -88,14 +85,9 @@ export const menuGroups = (): MenuGroup[] => [{ icon: Website, mobile: false, }, { - title: msg => msg.menu.whitelist, - route: '/additional/whitelist', - icon: Whitelist, - mobile: false, - }, { - title: msg => msg.menu.mergeRule, - route: MERGE_ROUTE, - icon: Rank, + title: msg => msg.menu.rule, + route: RULE_ROUTE, + icon: Rule, mobile: false, }, { title: msg => msg.base.option, diff --git a/src/pages/app/components/Limit/common.ts b/src/pages/app/components/Limit/common.ts deleted file mode 100644 index 230d7ef0a..000000000 --- a/src/pages/app/components/Limit/common.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function cleanCond(origin: string): string -export function cleanCond(origin: undefined): undefined -export function cleanCond(origin: string | undefined): string | undefined { - if (!origin) return undefined - - const startIdx = origin?.indexOf('//') - const endIdx = origin?.indexOf('?') - let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) - while (res.endsWith('/')) { - res = res.substring(0, res.length - 1) - } - return res || undefined -} \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Modify/Step2.tsx b/src/pages/app/components/Limit/components/Modify/Step2.tsx new file mode 100644 index 000000000..00e1fcad3 --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/Step2.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { useDialogSop } from '@app/components/common/DialogSop/context' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' +import CondEditor, { type CondEditorInstance } from '@pages/components/CondEditor' +import Flex from "@pages/components/Flex" +import { defineComponent, onUpdated, ref } from "vue" + +const _default = defineComponent(() => { + const { form: data } = useDialogSop() + const editor = ref() + onUpdated(() => editor.value?.focus()) + + return () => ( + + data.cond = l} + placeholder='e.g. www.demo.com, *.demo.com, demo.com/blog/*, demo.com/**, +www.demo.com/blog/list' + tip={t(msg => msg.limit.wildcardTip)} + /> + + ) +}) + +export default _default diff --git a/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx deleted file mode 100644 index 930615ce5..000000000 --- a/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { listTabs } from '@api/chrome/tab' -import { listSites } from '@api/sw/site' -import { cleanCond } from '@app/components/Limit/common' -import { t } from '@app/locale' -import { useDebounceState, useRequest } from '@hooks' -import Flex from '@pages/components/Flex' -import { extractHostname, isBrowserUrl } from '@util/pattern' -import { ElMessage, ElSelectV2, ElText, useNamespace, type SelectV2Instance } from 'element-plus' -import { computed, defineComponent, onMounted, ref, type StyleValue } from 'vue' - -type Props = { - onAdd: (url: string) => string | undefined -} - -const fetchAllHosts = async () => { - const tabs = await listTabs() - const hostSet = new Set() - tabs.map(({ url }) => { - if (!url) return - const { protocol, host } = extractHostname(url) - if (protocol === 'https' || protocol === 'http' && !isBrowserUrl(url)) { - hostSet.add(host) - } - }) - const sites = await listSites({ types: ['normal', 'virtual'] }) - sites.forEach(({ host }) => hostSet.add(host)) - return Array.from(hostSet) -} - -const useUrlSelect = ({ onAdd }: Props) => { - const { data: allHosts } = useRequest(fetchAllHosts, { defaultValue: [] }) - const [input, onFilter] = useDebounceState('', 50) - const inputUrl = computed(() => { - const clean = cleanCond(input.value) - if (!clean) return {} - const slashIdx = clean.indexOf('/') - return slashIdx === -1 ? { full: clean } : { full: clean, domain: clean.substring(0, slashIdx) } - }) - - const options = computed(() => { - const { full, domain } = inputUrl.value - const result: string[] = [] - if (full) { - result.push(full) - domain && result.push(domain) - allHosts.value.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) - } else { - allHosts.value.forEach(h => result.push(h)) - } - return result.map(value => ({ value, label: value })) - }) - - const selectInst = ref() - - const warnAndFocus = (msg: string) => { - ElMessage.warning(msg) - selectInst.value?.focus() - } - - const onSelected = (url: string) => { - const errMsg = onAdd(url) - if (errMsg) { - return warnAndFocus(errMsg) - } else { - selectInst.value?.handleClear() - } - } - - const selectNs = useNamespace('select') - - onMounted(() => { - const el = (selectInst.value?.$el as HTMLDivElement) - if (!el) return - const input = el.querySelector('input') - input?.addEventListener('keyup', ev => { - if (ev.code !== 'Enter') return - console.log(`.${selectNs.be('dropdown', 'item')}.is-hovering`) - const hovered = el.querySelector(`.${selectNs.be('dropdown', 'item')}.is-hovering`) - const first = options.value[0]?.value - if (!hovered && first) { - onSelected(first) - ev.stopImmediatePropagation() - } - }) - }) - - return { - options, onFilter, onSelected, - selectInst, - } -} - -const SiteInput = defineComponent(props => { - const { options, onFilter, onSelected, selectInst } = useUrlSelect(props) - - return () => ( - - - - {t(msg => msg.limit.wildcardTip)} - - - ) -}, { props: ['onAdd'] }) - -export default SiteInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Modify/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx deleted file mode 100644 index f93c47f4a..000000000 --- a/src/pages/app/components/Limit/components/Modify/Step2/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { useDialogSop } from '@app/components/common/DialogSop/context' -import type { ModifyForm } from '@app/components/Limit/types' -import { t } from '@app/locale' -import { Delete, WarnTriangleFilled } from "@element-plus/icons-vue" -import Flex from "@pages/components/Flex" -import { EXCLUDING_PREFIX } from '@util/constant/remain-host' -import { ElDivider, ElIcon, ElLink, ElScrollbar, ElText, type ScrollbarInstance } from "element-plus" -import { defineComponent, ref } from "vue" -import SiteInput from './SiteInput' - -const _default = defineComponent(() => { - const { form: data } = useDialogSop() - const scrollbar = ref() - - const handleAdd = (url: string) => { - const urls = data.cond - if (urls.includes(url)) return 'URL added already' - urls.unshift(url) - - data.urlMiss = false - scrollbar.value?.scrollTo(0) - } - - const handleRemove = (idx: number) => data.cond.splice(idx, 1) - - return () => ( - - - - - - {data.cond?.map((url, idx) => ( - - {url} - handleRemove(idx)} /> - - ))} - - - - - - - {t(msg => msg.limit.message.noUrl)} - - - - - ) -}) - -export default _default diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 574be120d..0085e1470 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -8,10 +8,10 @@ import { addLimit, updateLimits } from '@api/sw/limit' import DialogSop from '@app/components/common/DialogSop' import { initDialogSopContext } from '@app/components/common/DialogSop/context' -import { cleanCond } from '@app/components/Limit/common' import { useLimitData } from "@app/components/Limit/context" import type { ModifyForm, ModifyInstance } from '@app/components/Limit/types' import { t } from '@app/locale' +import { cleanCond } from '@pages/components/CondEditor' import { range } from '@util/array' import { computed, defineComponent, ref, toRaw } from "vue" import Step1 from './Step1' diff --git a/src/pages/app/components/Record/Table/columns/OperationColumn.tsx b/src/pages/app/components/Record/Table/columns/OperationColumn.tsx index b2db9b948..9f8d2134e 100644 --- a/src/pages/app/components/Record/Table/columns/OperationColumn.tsx +++ b/src/pages/app/components/Record/Table/columns/OperationColumn.tsx @@ -118,7 +118,7 @@ const _default = defineComponent(({ onDelete }) => { buttonIcon={Plus} buttonType="warning" buttonText={t(msg => msg.item.operation.add2Whitelist)} - confirmText={t(msg => msg.whitelist.addConfirmMsg, { url: row.siteKey?.host })} + confirmText={t(msg => msg.rule.white.addConfirmMsg, { url: row.siteKey?.host })} onConfirm={() => onAddWhitelist(row.siteKey.host)} /> )} @@ -128,7 +128,7 @@ const _default = defineComponent(({ onDelete }) => { buttonIcon={Open} buttonType="primary" buttonText={t(msg => msg.button.enable)} - confirmText={t(msg => msg.whitelist.removeConfirmMsg, { url: row.siteKey?.host })} + confirmText={t(msg => msg.rule.white.removeConfirmMsg, { url: row.siteKey?.host })} onConfirm={() => onRemoveWhitelist(row.siteKey.host)} /> )} diff --git a/src/pages/app/components/Rule/AlertBox.tsx b/src/pages/app/components/Rule/AlertBox.tsx new file mode 100644 index 000000000..7f70727b0 --- /dev/null +++ b/src/pages/app/components/Rule/AlertBox.tsx @@ -0,0 +1,15 @@ +import Flex from '@pages/components/Flex' +import { FunctionalComponent } from 'vue' +import AlertLines, { AlertLinesProps } from '../common/AlertLines' + +type Props = Pick + +const AlertBox: FunctionalComponent = (props, ctx) => ( + + + {ctx.slots.default?.()} + +) +AlertBox.displayName = 'AlertBox' + +export default AlertBox \ No newline at end of file diff --git a/src/pages/app/components/RuleMerge/ItemList.tsx b/src/pages/app/components/Rule/Merge/ItemList.tsx similarity index 88% rename from src/pages/app/components/RuleMerge/ItemList.tsx rename to src/pages/app/components/Rule/Merge/ItemList.tsx index 1581e3db1..37cc1d9d0 100644 --- a/src/pages/app/components/RuleMerge/ItemList.tsx +++ b/src/pages/app/components/Rule/Merge/ItemList.tsx @@ -14,7 +14,7 @@ import { defineComponent, ref } from "vue" import AddButton from './components/AddButton' import Item, { type ItemInstance } from './components/Item' -const _default = defineComponent(() => { +const _default = defineComponent<{}>(() => { const { data: items, refresh } = useRequest(listAllMergeRules, { defaultValue: [] }) const handleSucc = () => { ElMessage.success(t(msg => msg.operation.successMsg)) @@ -29,7 +29,7 @@ const _default = defineComponent(() => { const itemRefs = ref([]) const handleDelete = (origin: string) => { - const message = t(msg => msg.mergeRule.removeConfirmMsg, { origin }) + const message = t(msg => msg.rule.merge.removeConfirmMsg, { origin }) const title = t(msg => msg.operation.confirmTitle) ElMessageBox.confirm(message, title).then(() => remove(origin)) } @@ -37,7 +37,7 @@ const _default = defineComponent(() => { async function handleChange(origin: string, merged: string | number, index: number): Promise { const hasDuplicate = items.value.some((o, i) => o.origin === origin && i !== index) if (hasDuplicate) { - ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) + ElMessage.warning(t(msg => msg.rule.merge.duplicateMsg, { origin })) itemRefs.value?.[index]?.forceEdit?.() return } @@ -52,11 +52,11 @@ const _default = defineComponent(() => { const handleAdd = async (origin: string, merged: string | number): Promise => { const alreadyExist = items.value.some(item => item.origin === origin) if (alreadyExist) { - ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) + ElMessage.warning(t(msg => msg.rule.merge.duplicateMsg, { origin })) return false } const title = t(msg => msg.operation.confirmTitle) - const content = t(msg => msg.mergeRule.addConfirmMsg, { origin }) + const content = t(msg => msg.rule.merge.addConfirmMsg, { origin }) try { await ElMessageBox.confirm(content, title, { dangerouslyUseHTMLString: true }) add({ origin, merged }) diff --git a/src/pages/app/components/RuleMerge/components/AddButton.tsx b/src/pages/app/components/Rule/Merge/components/AddButton.tsx similarity index 100% rename from src/pages/app/components/RuleMerge/components/AddButton.tsx rename to src/pages/app/components/Rule/Merge/components/AddButton.tsx diff --git a/src/pages/app/components/RuleMerge/components/Item.tsx b/src/pages/app/components/Rule/Merge/components/Item.tsx similarity index 94% rename from src/pages/app/components/RuleMerge/components/Item.tsx rename to src/pages/app/components/Rule/Merge/components/Item.tsx index adad1e988..a37e8be2b 100644 --- a/src/pages/app/components/RuleMerge/components/Item.tsx +++ b/src/pages/app/components/Rule/Merge/components/Item.tsx @@ -26,9 +26,9 @@ export type ItemInstance = { function computeMergeTxt(mergedVal: number | string,): string { if (typeof mergedVal === 'number') { - return t(msg => msg.mergeRule.tagResult.level, { level: mergedVal + 1 }) + return t(msg => msg.rule.merge.tagResult.level, { level: mergedVal + 1 }) } - if (!mergedVal) return t(msg => msg.mergeRule.tagResult.blank) + if (!mergedVal) return t(msg => msg.rule.merge.tagResult.blank) return mergedVal } diff --git a/src/pages/app/components/RuleMerge/components/ItemInput.tsx b/src/pages/app/components/Rule/Merge/components/ItemInput.tsx similarity index 87% rename from src/pages/app/components/RuleMerge/components/ItemInput.tsx rename to src/pages/app/components/Rule/Merge/components/ItemInput.tsx index 423cb75ae..97b20cef2 100644 --- a/src/pages/app/components/RuleMerge/components/ItemInput.tsx +++ b/src/pages/app/components/Rule/Merge/components/ItemInput.tsx @@ -34,18 +34,18 @@ const _default = defineComponent(props => { else return mergedVal }, set(val: string) { - const newVal = tryParseInteger(val?.trim())[1] + const newVal = tryParseInteger(val.trim())[1] setMerged(typeof newVal === 'number' ? newVal - 1 : newVal) }, }) const handleSave = () => { const originVal = origin.value - const mergedVal = merged.value - if (originVal && mergedVal && isValidHost(originVal)) { + const mergedVal = merged.value ?? '' + if (originVal && isValidHost(originVal)) { props.onSave?.(originVal, mergedVal) } else { - ElMessage.warning(t(msg => msg.mergeRule.errorOrigin)) + ElMessage.warning(t(msg => msg.rule.merge.errorOrigin)) } } @@ -59,7 +59,7 @@ const _default = defineComponent(props => { msg.mergeRule.originPlaceholder)} + placeholder={t(msg => msg.rule.merge.originPlaceholder)} clearable onClear={() => setOrigin('')} onInput={setOrigin} @@ -68,7 +68,7 @@ const _default = defineComponent(props => { /> msg.mergeRule.mergedPlaceholder)} + placeholder={t(msg => msg.rule.merge.mergedPlaceholder)} clearable onClear={() => mergedTxt.value = ''} onInput={val => mergedTxt.value = val} diff --git a/src/pages/app/components/Rule/Merge/index.tsx b/src/pages/app/components/Rule/Merge/index.tsx new file mode 100644 index 000000000..e9624c88b --- /dev/null +++ b/src/pages/app/components/Rule/Merge/index.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { PSL_HOMEPAGE } from '@util/constant/url' +import type { FunctionalComponent, StyleValue } from "vue" +import AlertBox from '../AlertBox' +import ItemList from "./ItemList" + +const pslStyle: StyleValue = { + fontSize: "var(--el-alert-description-font-size)", + color: "var(--el-color-info)", + marginInline: "2px", +} + +const Merge: FunctionalComponent = () => ( + msg.rule.merge.infoAlertTitle} + lines={[ + msg => msg.rule.merge.infoAlert0, + msg => msg.rule.merge.infoAlert1, + msg => msg.rule.merge.infoAlert2, + msg => msg.rule.merge.infoAlert3, + msg => msg.rule.merge.infoAlert4, + [msg => msg.rule.merge.infoAlert5, { + psl: Public Suffix List + }], + ]} + > + + +) +Merge.displayName = 'Merge' + +export default Merge diff --git a/src/pages/app/components/Rule/Whitelist.tsx b/src/pages/app/components/Rule/Whitelist.tsx new file mode 100644 index 000000000..c25666e07 --- /dev/null +++ b/src/pages/app/components/Rule/Whitelist.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listWhitelist, saveWhitelist } from '@api/sw/whitelist' +import { useDocumentVisibility, useManualRequest, useRequest } from '@hooks' +import CondEditor from '@pages/components/CondEditor' +import { defineComponent } from "vue" +import AlertBox from './AlertBox' + +const PLACEHOLDER = 'e.g. github.com, +github.com/sheepzh/**, *.youtube.com, __local_pdf__' + +const Whitelist = defineComponent<{}>(() => { + const docVisible = useDocumentVisibility() + const { data, refresh } = useRequest(listWhitelist, { defaultValue: [], deps: [docVisible] }) + const { refresh: handleChange } = useManualRequest(saveWhitelist, { onSuccess: refresh }) + + return () => ( + msg.rule.white.infoAlertTitle} + lines={[ + msg => msg.rule.white.infoAlert0, + msg => msg.rule.white.infoAlert1, + msg => msg.rule.white.infoAlert2, + ]} + > + + + ) +}) + +export default Whitelist diff --git a/src/pages/app/components/Rule/index.tsx b/src/pages/app/components/Rule/index.tsx new file mode 100644 index 000000000..0d732861f --- /dev/null +++ b/src/pages/app/components/Rule/index.tsx @@ -0,0 +1,62 @@ +import { t } from '@app/locale' +import { ElTabPane, ElTabs } from 'element-plus' +import { createStringUnionGuard } from 'typescript-guard' +import { computed, defineComponent, type StyleValue } from 'vue' +import { useRoute, useRouter, type LocationQuery } from 'vue-router' +import ContentContainer from '../common/ContentContainer' +import Merge from './Merge' +import Whitelist from './Whitelist' + +type RuleTab = 'white' | 'merge' +const isTab = createStringUnionGuard('white', 'merge') + +const PARAM = "i" + +export const useTab = () => { + const route = useRoute() + const router = useRouter() + + const tab = computed({ + set(value: RuleTab) { + const oldQuery = route.query + const query: LocationQuery = { + ...oldQuery, + [PARAM]: value, + } + router.replace({ query }) + }, + get() { + const query = route.query[PARAM] + const queryVal = Array.isArray(query) ? query[0] : query + return isTab(queryVal) ? queryVal : 'white' + }, + }) + + const setTab = (value: unknown) => isTab(value) && (tab.value = value) + + return { tab, setTab } +} + +const Rule = defineComponent<{}>(() => { + const { tab, setTab } = useTab() + + return () => ( + + + msg.rule.white.label)}> + + + msg.rule.merge.label)}> + + + + + ) +}) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/RuleMerge/index.tsx b/src/pages/app/components/RuleMerge/index.tsx deleted file mode 100644 index 7c4e536b6..000000000 --- a/src/pages/app/components/RuleMerge/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import Flex from "@pages/components/Flex" -import { PSL_HOMEPAGE } from '@util/constant/url' -import { ElCard } from "element-plus" -import type { FunctionalComponent, StyleValue } from "vue" -import AlertLines from '../common/AlertLines' -import ContentContainer from '../common/ContentContainer' -import ItemList from "./ItemList" - -const pslStyle: StyleValue = { - fontSize: "var(--el-alert-description-font-size)", - color: "var(--el-color-info)", - marginInline: "2px", -} - -const RuleMerge: FunctionalComponent = () => ( - - - - msg.mergeRule.infoAlertTitle} - lines={[ - msg => msg.mergeRule.infoAlert0, - msg => msg.mergeRule.infoAlert1, - msg => msg.mergeRule.infoAlert2, - msg => msg.mergeRule.infoAlert3, - msg => msg.mergeRule.infoAlert4, - [msg => msg.mergeRule.infoAlert5, { - psl: Public Suffix List - }], - ]} - /> - - - - -) -RuleMerge.displayName = 'RuleMerge' - -export default RuleMerge diff --git a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx b/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx deleted file mode 100644 index 9a90d1889..000000000 --- a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { useState, useSwitch } from '@hooks' -import { t } from '@app/locale' -import { ElButton } from "element-plus" -import { defineComponent, StyleValue } from "vue" -import WhiteInput from './WhiteInput' - -type Props = { - onSave: (white: string) => Promise -} - -const _default = defineComponent(({ onSave }) => { - const [editing, openEdit, closeEdit] = useSwitch(false) - const [white, _setWhite, resetWhite] = useState('') - - const handleAdd = () => { - resetWhite() - openEdit() - } - - return () => editing.value ? ( - onSave(white.value = val).then(succ => succ && closeEdit())} - onCancel={closeEdit} - end - /> - ) : ( - - {`+ ${t(msg => msg.button.create)}`} - - ) -}, { props: ['onSave'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx deleted file mode 100644 index 58f94ab07..000000000 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listSites } from '@api/sw/site' -import { t } from '@app/locale' -import { Check, CirclePlus, Close } from "@element-plus/icons-vue" -import { useRequest, useShadow } from '@hooks' -import Box from "@pages/components/Box" -import Flex from '@pages/components/Flex' -import { EXCLUDING_PREFIX, isRemainHost } from "@util/constant/remain-host" -import { isValidHost, judgeVirtualFast } from "@util/pattern" -import { ElButton, ElIcon, ElMessage, ElOption, ElSelect, ElTag } from "element-plus" -import { defineComponent, StyleValue } from "vue" - -type SearchItem = tt4b.site.SiteKey & { - exclude?: boolean -} - -async function remoteSearch(query: string): Promise { - let exclude = false - if (query?.startsWith(EXCLUDING_PREFIX)) { - exclude = true - query = query.slice(EXCLUDING_PREFIX.length) - } - if (!query) return [] - - const sites: SearchItem[] = await listSites({ fuzzyQuery: query }) - const idx = sites.findIndex(s => s.host === query) - - const target: SearchItem = (idx >= 0 ? sites.splice(idx, 1)[0] : null) - ?? { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } - - const result = [target, ...sites] - - result.forEach(s => s.exclude = exclude) - return result -} - -type Props = { - defaultValue?: string - onSave?: (white: string) => void - onCancel?: () => void - end?: boolean -} - -const _default = defineComponent(props => { - const [white, setWhite, resetWhite] = useShadow(() => props.defaultValue) - const { data: sites, refresh: search, loading: searching } = useRequest(remoteSearch) - - const handleSubmit = () => { - const val = white.value - if (!val) return - const host = val?.startsWith(EXCLUDING_PREFIX) ? val.substring(1) : val - if (isRemainHost(host) || isValidHost(host) || judgeVirtualFast(host)) { - props.onSave?.(val) - } else { - ElMessage.warning(t(msg => msg.whitelist.errorInput)) - } - } - const handleCancel = () => { - resetWhite() - props.onCancel?.() - } - - return () => ( - - msg.item.host)} - clearable - onClear={() => setWhite(undefined)} - filterable - remote - loading={searching.value} - remoteMethod={search} - > - {sites.value?.map(({ host, type, exclude }) => - - - - - {host} - - {t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCase?.()} - - - )} - - - - - ) -}, { props: ['defaultValue', 'onCancel', 'onSave', 'end'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx deleted file mode 100644 index 3d33ceef4..000000000 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import EditableTag, { type EditableTagProps } from '@app/components/common/EditableTag' -import { useShadow, useSwitch } from '@hooks' -import { EXCLUDING_PREFIX } from '@util/constant/remain-host' -import { judgeVirtualFast } from "@util/pattern" -import { computed, defineComponent } from "vue" -import WhiteInput from "./WhiteInput" - -type Props = { - white: string - onChange: (white: string) => Promise - onDelete: (white: string) => void -} - -function computeType(white: string): EditableTagProps['type'] { - if (white.startsWith(EXCLUDING_PREFIX)) { - return 'info' - } else if (judgeVirtualFast(white)) { - return 'warning' - } else { - return 'primary' - } -} - -const _default = defineComponent(props => { - const [white, , resetWhite] = useShadow(() => props.white) - const type = computed(() => computeType(white.value)) - const [editing, openEditing, closeEditing] = useSwitch() - - const handleCancel = () => { - resetWhite() - closeEditing() - } - - return () => editing.value ? ( - props.onChange?.(white.value = val)?.then(succ => succ && closeEditing())} - onCancel={handleCancel} - /> - ) : ( - white.value && props.onDelete(white.value)} - type={type.value} - /> - ) -}, { props: ['white', 'onChange', 'onDelete'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Whitelist/WhitePanel/index.tsx b/src/pages/app/components/Whitelist/WhitePanel/index.tsx deleted file mode 100644 index 5ab5099f1..000000000 --- a/src/pages/app/components/Whitelist/WhitePanel/index.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" -import { t } from '@app/locale' -import Flex from "@pages/components/Flex" -import { ElMessage, ElMessageBox } from "element-plus" -import { defineComponent, onBeforeMount, reactive } from "vue" -import AddButton from './AddButton' -import WhiteItem from './WhiteItem' - -const _default = defineComponent(() => { - const whitelist = reactive([]) - onBeforeMount(() => listWhitelist().then(l => whitelist.push(...l))) - - const handleChanged = async (val: string, index: number): Promise => { - const duplicate = whitelist.some((white, i) => white === val && i !== index) - if (duplicate) { - ElMessage.warning(t(msg => msg.whitelist.duplicateMsg)) - // Reopen - return false - } - const exists = whitelist[index] - if (exists) await deleteWhitelist(exists) - await addWhitelist(val) - whitelist[index] = val - ElMessage.success(t(msg => msg.operation.successMsg)) - return true - } - - const handleAdd = async (val: string): Promise => { - const exists = whitelist.some(item => item === val) - if (exists) { - ElMessage.warning(t(msg => msg.whitelist.duplicateMsg)) - return false - } - - const msg = t(msg => msg.whitelist.addConfirmMsg, { url: val }) - const title = t(msg => msg.operation.confirmTitle) - return ElMessageBox.confirm(msg, title, { dangerouslyUseHTMLString: true }) - .then(async () => { - await addWhitelist(val) - whitelist.push(val) - ElMessage.success(t(msg => msg.operation.successMsg)) - return true - }) - .catch(() => false) - } - - const handleClose = (whiteItem: string) => { - const confirmMsg = t(msg => msg.whitelist.removeConfirmMsg, { url: whiteItem }) - const confirmTitle = t(msg => msg.operation.confirmTitle) - ElMessageBox - .confirm(confirmMsg, confirmTitle, { dangerouslyUseHTMLString: true }) - .then(() => deleteWhitelist(whiteItem)) - .then(() => { - ElMessage.success(t(msg => msg.operation.successMsg)) - const index = whitelist.indexOf(whiteItem) - index !== -1 && whitelist.splice(index, 1) - }) - .catch(() => { }) - } - - return () => ( - - {whitelist.map((white, index) => ( - handleChanged(val, index)} - onDelete={handleClose} - /> - ))} - - - ) -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Whitelist/index.tsx b/src/pages/app/components/Whitelist/index.tsx deleted file mode 100644 index 867070cdc..000000000 --- a/src/pages/app/components/Whitelist/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import Flex from "@pages/components/Flex" -import { ElCard } from "element-plus" -import { type FunctionalComponent } from "vue" -import AlertLines from '../common/AlertLines' -import ContentContainer from '../common/ContentContainer' -import WhitePanel from "./WhitePanel" - -const Whitelist: FunctionalComponent = () => ( - - - - msg.whitelist.infoAlertTitle} - lines={[ - msg => msg.whitelist.infoAlert0, - msg => msg.whitelist.infoAlert1, - msg => msg.whitelist.infoAlert2, - ]} - /> - - - - -) -Whitelist.displayName = 'Whitelist' - -export default Whitelist diff --git a/src/pages/app/components/common/AlertLines.tsx b/src/pages/app/components/common/AlertLines.tsx index 4ee9df148..a2597104b 100644 --- a/src/pages/app/components/common/AlertLines.tsx +++ b/src/pages/app/components/common/AlertLines.tsx @@ -13,26 +13,21 @@ export type AlertLinesProps = { type?: AlertProps['type'] } -const AlertLines: FunctionalComponent = ({ lines, title, type }) => { - return ( - - {lines?.map((l, i) => ( -
  • - {Array.isArray(l) - ? tN(l[0], l[1]) - : (typeof l === 'string' ? l : t(l))} -
  • - ))} -
    - ) -} - +const AlertLines: FunctionalComponent = ({ lines, title, type }) => ( + + {lines?.map((l, i) => ( +
  • + {Array.isArray(l) ? tN(l[0], l[1]) : (typeof l === 'string' ? l : t(l))} +
  • + ))} +
    +) AlertLines.displayName = 'AlertLines' export default AlertLines \ No newline at end of file diff --git a/src/pages/app/components/common/EditableTag.tsx b/src/pages/app/components/common/EditableTag.tsx index 59a9fdbb2..188aca317 100644 --- a/src/pages/app/components/common/EditableTag.tsx +++ b/src/pages/app/components/common/EditableTag.tsx @@ -3,11 +3,10 @@ import Flex from "@pages/components/Flex" import { ElTag, type TagProps } from "element-plus" import { defineComponent, h, useSlots, type StyleValue } from "vue" -export type EditableTagProps = PartialPick & { +type EditableTagProps = PartialPick & { text?: string - onEdit?: () => void - closable?: boolean - onClose?: () => void + onEdit?: NoArgCallback + onClose?: NoArgCallback } const EDIT_ICON_STYLE: StyleValue = { diff --git a/src/pages/app/router/constants.ts b/src/pages/app/router/constants.ts index 485c854e0..f903d2818 100644 --- a/src/pages/app/router/constants.ts +++ b/src/pages/app/router/constants.ts @@ -15,7 +15,4 @@ export { APP_RECORD_ROUTE as RECORD_ROUTE, type AppLimitQuery as LimitQuery, type AppRecordQuery as RecordQuery } from "@/shared/route" -/** - * @since 1.8.0 - */ -export const MERGE_ROUTE = '/additional/rule-merge' +export const RULE_ROUTE = '/additional/rule' \ No newline at end of file diff --git a/src/pages/app/router/index.ts b/src/pages/app/router/index.ts index 106293dd4..c2d5dc1fa 100644 --- a/src/pages/app/router/index.ts +++ b/src/pages/app/router/index.ts @@ -7,7 +7,7 @@ import { type App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" -import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, MERGE_ROUTE, OPTION_ROUTE, RECORD_ROUTE } from "./constants" +import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, OPTION_ROUTE, RECORD_ROUTE, RULE_ROUTE } from "./constants" const dataRoutes: RouteRecordRaw[] = [ { @@ -47,16 +47,13 @@ const behaviorRoutes: RouteRecordRaw[] = [ const additionalRoutes: RouteRecordRaw[] = [ { path: '/additional', - redirect: '/additional/whitelist' + redirect: RULE_ROUTE, + }, { + path: RULE_ROUTE, + component: () => import('../components/Rule') }, { path: '/additional/site-manage', component: () => import('../components/SiteManage') - }, { - path: '/additional/whitelist', - component: () => import('../components/Whitelist') - }, { - path: MERGE_ROUTE, - component: () => import('../components/RuleMerge') }, { path: OPTION_ROUTE, component: () => import('../components/Option') diff --git a/src/pages/components/CondEditor.tsx b/src/pages/components/CondEditor.tsx new file mode 100644 index 000000000..b9940ddf6 --- /dev/null +++ b/src/pages/components/CondEditor.tsx @@ -0,0 +1,170 @@ +import { listSites } from '@api/sw/site' +import { useRequest } from '@hooks' +import Flex from '@pages/components/Flex' +import { EXCLUDING_PREFIX } from '@util/constant/remain-host' +import { judgeVirtualFast } from '@util/pattern' +import { ElAutocomplete, ElMessage, ElScrollbar, ElTag, ElText, type AutocompleteInstance, type TagProps } from 'element-plus' +import { computed, defineComponent, nextTick, ref, type FunctionalComponent, type StyleValue } from 'vue' +import { cvtPxScale } from './common' + +const judgeExclude = (url: string): [isExcluding: boolean, cleanUrl: string] => { + const isExcluding = url.startsWith(EXCLUDING_PREFIX) + const cleanUrl = isExcluding ? url.substring(EXCLUDING_PREFIX.length) : url + return [isExcluding, cleanUrl] +} + +export function cleanCond(origin: string): string +export function cleanCond(origin: undefined): undefined +export function cleanCond(origin: string | undefined): string | undefined { + if (!origin) return undefined + + const [isExcluding, clean] = judgeExclude(origin) + + const startIdx = clean.indexOf('//') + const endIdx = clean.indexOf('?') + let res = clean.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) + while (res.endsWith('/')) { + res = res.substring(0, res.length - 1) + } + if (!res) return undefined + return isExcluding ? `${EXCLUDING_PREFIX}${res}` : res +} + +const useSuggestion = () => { + const { data: allHosts } = useRequest(async () => { + const sites = await listSites({ types: ['normal', 'virtual'] }) + return sites.map(s => s.host) + }, { defaultValue: [] }) + + const fetchSuggestions = (query: string, callback: (results: { value: string }[]) => void) => { + const clean = cleanCond(query) ?? query.trim() + if (!clean) { + callback(allHosts.value.slice(0, 10).map(value => ({ value }))) + return + } + const result: string[] = [clean] + const slashIdx = clean.indexOf('/') + if (slashIdx !== -1) { + const domain = clean.substring(0, slashIdx) + if (domain !== clean) result.push(domain) + } + allHosts.value.forEach(host => { + if (host.includes(clean) && !result.includes(host)) result.push(host) + }) + callback(result.slice(0, 10).map(value => ({ value }))) + } + return { fetchSuggestions } +} + +const tagType = (url: string): TagProps['type'] => { + if (url.startsWith(EXCLUDING_PREFIX)) return 'info' + if (judgeVirtualFast(url)) return 'warning' + return 'primary' +} + +const CONTAINER_STYLE: StyleValue = { + borderRadius: 'var(--el-border-radius-base)', + border: '1px solid var(--el-border-color)', + backgroundColor: 'var(--el-fill-color-blank)', +} + +const AUTOCOMPLETE_STYLE: StyleValue = { + '--el-input-border-color': 'transparent', + '--el-input-bg-color': 'transparent', + '--el-input-hover-border-color': 'transparent', + '--el-input-focus-border-color': 'transparent', +} + + +const TagList: FunctionalComponent<{ list: string[], onRemove: ArgCallback }> = ({ list, onRemove }) => ( +
    + + + {list.map(url => ( + onRemove(url)} + > + {url} + + ))} + + +
    +) + +type CondEditorProps = ModelValue & { + tip?: string + height?: string | number + placeholder?: string +} + +export interface CondEditorInstance { + focus(): void +} + +const sortUrl = (a: string, b: string) => { + const [aExcluding, aClean] = judgeExclude(a) + const [bExcluding, bClean] = judgeExclude(b) + if (aClean === bClean) { + if (aExcluding && !bExcluding) return 1 + if (!aExcluding && bExcluding) return -1 + return 0 + } + return aClean.localeCompare(bClean) +} + +const CondEditor = defineComponent((props, ctx) => { + const list = computed(() => [...props.modelValue].sort(sortUrl)) + const inputValue = ref('') + const { fetchSuggestions } = useSuggestion() + + const autocomplete = ref() + const focus = () => autocomplete.value?.focus() + + const handleAdd = (url: string) => { + const cleaned = cleanCond(url) ?? url.trim() + if (!cleaned) return + if (list.value.includes(cleaned)) { + ElMessage.warning('URL added already') + return + } + props.onChange?.([...list.value, cleaned]) + inputValue.value = '' + nextTick(focus) + } + + const handleRemove = (url: string) => props.onChange?.(list.value.filter(u => u !== url)) + + ctx.expose({ focus } satisfies CondEditorInstance) + + return () => ( + + + + inputValue.value = String(val)} + fetchSuggestions={fetchSuggestions} + onSelect={item => typeof item.value === 'string' && handleAdd(item.value)} + {...{ + onKeydown: (ev: Event) => ev instanceof KeyboardEvent && ev.key === 'Enter' && handleAdd(inputValue.value) + }} + placeholder={props.placeholder} + style={AUTOCOMPLETE_STYLE} + v-slots={{ prefix: () => > }} + /> + + {props.tip && {props.tip}} + + ) +}, { props: ['modelValue', 'onChange', 'tip', 'height', 'placeholder'] }) + +export default CondEditor \ No newline at end of file diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx index bc8a42bc8..cb31c1b86 100644 --- a/src/pages/icons.tsx +++ b/src/pages/icons.tsx @@ -61,4 +61,40 @@ export const Enter: Icon = () => ( +) + +export const Rule: Icon = () => ( + + + + +) + +export const Database: Icon = () => ( + + + + + + +) + +export const About: Icon = () => ( + + + + + +) + +export const Table: Icon = () => ( + + + +) + +export const Website: Icon = () => ( + + + ) \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index a1bb64688..2c243ea9e 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -26,7 +26,8 @@ export const DEFAULT_TRACKING: tt4b.option.TrackingRequired = { // 10 minutes autoPauseInterval: 600, countLocalFiles: false, - countTabGroup: true, + // Additional permission required, so default to false + countTabGroup: false, weekStart: 'default', storage: 'classic', } as const diff --git a/test-e2e/common/cond-editor.ts b/test-e2e/common/cond-editor.ts new file mode 100644 index 000000000..7a47ae76e --- /dev/null +++ b/test-e2e/common/cond-editor.ts @@ -0,0 +1,21 @@ +import type { Page } from 'puppeteer' +import { sleep } from './util' + +/** + * Fill URLs into a CondEditor component on the page. + * + * @param page - Puppeteer page instance + * @param urls - URLs to add + * @param ancestor - Optional ancestor selector to scope the CondEditor (e.g. '.el-dialog') + */ +export async function fillCondEditor(page: Page, urls: string[], ancestor?: string) { + const selector = ancestor ? `${ancestor} .cond-editor input` : '.cond-editor input' + const input = await page.waitForSelector(selector, { visible: true }) + for (const url of urls) { + await input!.click() + await page.keyboard.type(url) + await sleep(.2) + await page.keyboard.press('Enter') + await sleep(.2) + } +} diff --git a/test-e2e/common/whitelist.ts b/test-e2e/common/whitelist.ts index a8e6f9d88..37c036a4d 100644 --- a/test-e2e/common/whitelist.ts +++ b/test-e2e/common/whitelist.ts @@ -1,29 +1,18 @@ import { type LaunchContext } from "./base" +import { fillCondEditor } from './cond-editor' import { sleep } from './util' export async function createWhitelist(context: LaunchContext, white: string) { - const whitePage = await context.openAppPage('/additional/whitelist') + const whitePage = await context.openAppPage('/additional/rule?i=white') - const btn = await whitePage.waitForSelector('.el-button') - await btn?.click() + await fillCondEditor(whitePage, [white]) await sleep(.2) - const input = await whitePage.$('.el-select__input') - await input?.focus() - await whitePage.keyboard.type(white) - await sleep(.4) - await whitePage.keyboard.press('ArrowDown') - await sleep(.2) - await whitePage.keyboard.press('Enter') - - await whitePage.click('.el-button:nth-child(3)') - const checkBtn = await whitePage.waitForSelector('.el-overlay.is-message-box .el-button.el-button--primary') - await checkBtn?.click() setTimeout(() => whitePage.close(), 200) } export async function removeAllWhitelist(context: LaunchContext) { - const whitePage = await context.openAppPage('/additional/whitelist') + const whitePage = await context.openAppPage('/additional/rule?i=white') await whitePage.evaluate(async () => { await chrome.storage.local.remove('__timer__WHITELIST') }) diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index bf242d03d..dc1c97aff 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -1,4 +1,5 @@ import type { ElementHandle, Frame, Page } from "puppeteer" +import { fillCondEditor } from "../common/cond-editor" import { sleep } from "../common/util" export async function waitForLimitFrame(page: Page, timeout = 5000): Promise { @@ -30,15 +31,7 @@ export async function createLimitRule(rule: tt4b.limit.Rule, page: Page) { await new Promise(resolve => setTimeout(resolve, 400)) await page.click('.el-dialog .el-button.el-button--primary') // 2. Fill the condition - const configInput = await page.$('.el-dialog #site-input') - for (const url of rule.cond || []) { - await configInput!.focus() - await page.keyboard.type(url) - await sleep(.1) - await page.keyboard.press('ArrowDown') - await sleep(.1) - await page.keyboard.press('Enter') - } + await fillCondEditor(page, rule.cond || [], '.el-dialog') await sleep(.1) await page.click('.el-dialog .el-button.el-button--primary') // 3. Fill the rule diff --git a/types/common.d.ts b/types/common.d.ts index 804190fc5..81c768d1b 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -20,6 +20,8 @@ type PartialPick = Partial> type RequiredPick = Required> +type Exactly = T & Record, never> + /** * Tuple with length * diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts index 5c4488713..1c7996db5 100644 --- a/types/tt4b.d.ts +++ b/types/tt4b.d.ts @@ -1141,6 +1141,7 @@ declare namespace tt4b { // Whitelist & _MakeRegistry<'whitelist.all', undefined, string[]> & _MakeRegistry<'whitelist.add' | 'whitelist.delete', string> + & _MakeRegistry<'whitelist.save', string[]> & _MakeRegistry<'whitelist.contain', { host: string; url: string }, boolean> // Backup & _MakeRegistry<'backup.sync' | 'backup.checkAuth', undefined, string | undefined> From feceb2d52d4a43d7b101d62af4fe39ee6911d6ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:22:37 +0000 Subject: [PATCH 08/95] chore(psl): update PSL list by bot --- src/background/psl/rules.json | 36 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/background/psl/rules.json b/src/background/psl/rules.json index 482160e4a..a98b3336c 100644 --- a/src/background/psl/rules.json +++ b/src/background/psl/rules.json @@ -251,7 +251,6 @@ "*": 1 } }, - "bookonline": 1, "botdash": 1, "brave": { "c": { @@ -2164,6 +2163,7 @@ "eu": 1 } }, + "hstgr": 1, "jelastic": { "c": { "vip": 1 @@ -2542,6 +2542,7 @@ "jl": 1, "js": 1, "jx": 1, + "khsj": 1, "ln": 1, "mil": 1, "mo": 1, @@ -4757,7 +4758,6 @@ } }, "reservd": 1, - "reserve-online": 1, "rhcloud": 1, "rice-labs": 1, "routingthecloud": 1, @@ -5472,6 +5472,16 @@ "*": 1 } }, + "storage": { + "c": { + "t3": 1 + } + }, + "storageapi": { + "c": { + "t3": 1 + } + }, "vercel": 1, "vivenushop": 1, "webhare": { @@ -5687,11 +5697,6 @@ }, "email": { "c": { - "crisp": { - "c": { - "on": 1 - } - }, "intouch": 1, "tawk": { "c": { @@ -6430,6 +6435,7 @@ "id": { "c": { "ac": 1, + "ai": 1, "biz": 1, "co": 1, "desa": 1, @@ -9803,6 +9809,7 @@ "lincoln": 1, "link": { "c": { + "canva": 1, "cyon": 1, "dweb": { "c": { @@ -11831,6 +11838,7 @@ "online": { "c": { "barsy": 1, + "book": 1, "eero": 1, "eero-stage": 1, "leapcell": 1, @@ -14189,13 +14197,7 @@ }, "l": 1 }, - "nd": { - "c": { - "cc": 1, - "lib": 1 - }, - "l": 1 - }, + "nd": 1, "ne": { "c": { "cc": 1, @@ -15013,11 +15015,17 @@ "zone": { "c": { "lima": 1, + "prg1-zerops": 1, "stackit": 1, "triton": { "c": { "*": 1 } + }, + "zerops": { + "c": { + "*": 1 + } } }, "l": 1 From c8901c27d5e7bd4263a2cbe8c44e191902587082 Mon Sep 17 00:00:00 2001 From: bgme Date: Mon, 1 Jun 2026 11:48:16 +0800 Subject: [PATCH 09/95] feat(web-dav): never send cookies in the request --- src/api/web-dav.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index d8cb042d3..5d22367fa 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -34,7 +34,7 @@ export async function judgeDirExist(context: WebDAVContext, dirPath: string): Pr const method = 'PROPFIND' headers.append('Accept', 'text/plain,application/xml') headers.append('Depth', '1') - const response = await fetch(url, { method, headers }) + const response = await fetch(url, { method, headers, credentials: 'omit' }) const status = response?.status if (status == 207) { return true @@ -62,7 +62,7 @@ export async function makeDirs(context: WebDAVContext, dirPath: string) { if (!exists) { const url = `${endpoint}/${currentPath}` const headers = authHeaders(auth) - const response = await fetch(url, { method: 'MKCOL', headers }) + const response = await fetch(url, { method: 'MKCOL', headers, credentials: 'omit' }) handleWriteResponse(response) } } @@ -72,7 +72,7 @@ export async function deleteDir(context: WebDAVContext, dirPath: string) { const { auth, endpoint } = context || {} const url = `${endpoint}/${dirPath}` const headers = authHeaders(auth) - const response = await fetchDelete(url, { headers }) + const response = await fetchDelete(url, { headers, credentials: 'omit' }) const status = response.status if (status === 403) { throw new Error("Unauthorized to delete directory") @@ -87,7 +87,7 @@ export async function writeFile(context: WebDAVContext, filePath: string, conten const headers = authHeaders(auth) headers.set("Content-Type", "application/octet-stream") const url = `${endpoint}/${filePath}` - const response = await fetch(url, { headers, method: 'put', body: content }) + const response = await fetch(url, { headers, method: 'put', body: content, credentials: 'omit' }) handleWriteResponse(response) } @@ -106,7 +106,7 @@ export async function readFile(context: WebDAVContext, filePath: string): Promis const headers = authHeaders(auth) const url = `${endpoint}/${filePath}` try { - const response = await fetchGet(url, { headers }) + const response = await fetchGet(url, { headers, credentials: 'omit' }) const status = response?.status if (status === 200) { return response.text() From f1af9c50ecbd942e2576ab5a90eda1fc1e55b7d3 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 1 Jun 2026 13:01:14 +0800 Subject: [PATCH 10/95] fix(limit): display error on the popup page --- package.json | 26 ++-- .../limit/modal/components/Reason.tsx | 117 +++++++++--------- .../Limit/components/Table/index.tsx | 4 +- .../Option/categories/Backup/ClientTable.tsx | 2 +- .../app/components/Record/Table/index.tsx | 10 +- .../popup/components/Limit/Content/Wrapper.ts | 2 +- 6 files changed, 83 insertions(+), 78 deletions(-) diff --git a/package.json b/package.json index 7f5b4a477..535322ebb 100644 --- a/package.json +++ b/package.json @@ -29,20 +29,20 @@ "license": "MIT", "devDependencies": { "@commitlint/types": "^21.0.1", - "@crowdin/crowdin-api-client": "^1.55.2", + "@crowdin/crowdin-api-client": "^1.55.3", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.11", - "@rspack/cli": "^2.0.4", - "@rspack/core": "^2.0.4", - "@rstest/core": "^0.10.2", - "@rstest/coverage-istanbul": "^0.10.2", + "@rsdoctor/rspack-plugin": "^1.5.12", + "@rspack/cli": "^2.0.5", + "@rspack/core": "^2.0.5", + "@rstest/core": "^0.10.3", + "@rstest/coverage-istanbul": "^0.10.3", "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", "@types/node": "^25.9.1", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^21.0.1", + "commitlint": "^21.0.2", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", @@ -50,11 +50,11 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.14.2", + "knip": "^6.15.0", "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^25.0.4", + "puppeteer": "^25.1.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "6.0.3", @@ -64,12 +64,12 @@ "@element-plus/icons-vue": "^2.3.2", "@emotion/css": "^11.13.5", "echarts": "^6.1.0", - "element-plus": "2.14.0", + "element-plus": "2.14.1", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", - "typescript-guard": "0.2.5", - "vue": "^3.5.34", - "vue-router": "^5.0.7" + "typescript-guard": "0.2.6", + "vue": "^3.5.35", + "vue-router": "^5.1.0" }, "engines": { "node": ">=22" diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index b658c16d0..4fc4a95dc 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -25,68 +25,65 @@ const renderBaseItems = (rule: tt4b.limit.Rule | undefined, url: string) => <> -const TimeDescriptions = defineComponent({ - props: { - // Seconds - time: Number, - // Milliseconds - waste: Number, - count: Number, - visit: Number, - ruleLabel: String, - dataLabel: String, - }, - setup(props) { - const { reason, url, delayDuration } = useApp() - const rule = useRule() - const { style, size } = useDescriptions() +type DescriptionProps = { + time?: number + waste?: number + count?: number + visit?: number + ruleLabel?: string + dataLabel?: string +} - const timeLimited = computed(() => meetTimeLimit( - { wasted: props.waste ?? 0, maxLimit: (props.time ?? 0) * MILL_PER_SECOND }, - { - count: reason.value?.delayCount ?? 0, - duration: delayDuration.value, - allow: !!reason.value?.allowDelay, - }, - )) - const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) +const TimeDescriptions = defineComponent(props => { + const { reason, url, delayDuration } = useApp() + const rule = useRule() + const { style, size } = useDescriptions() - return () => ( - - {renderBaseItems(rule.value, url)} - - - {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} - {props.count && {t(msg => msg.shared.limit.visits, { n: props.count })}} - - - - - - {formatPeriodCommon(props.waste ?? 0)} - - - {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} - - - - msg.limit.item.delayCount)} - labelAlign="right" - > - {reason.value?.delayCount ?? 0} - - - ) - }, -}) + const timeLimited = computed(() => meetTimeLimit( + { wasted: props.waste ?? 0, maxLimit: (props.time ?? 0) * MILL_PER_SECOND }, + { + count: reason.value?.delayCount ?? 0, + duration: delayDuration.value, + allow: !!reason.value?.allowDelay, + }, + )) + const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) + + return () => ( + + {renderBaseItems(rule.value, url)} + + + {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} + {t(msg => msg.shared.limit.visits, { n: props.count })} + + + + + + {formatPeriodCommon(props.waste ?? 0)} + + + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} + + + + msg.limit.item.delayCount)} + labelAlign="right" + > + {reason.value?.delayCount ?? 0} + + + ) +}, { props: ['time', 'waste', 'count', 'visit', 'ruleLabel', 'dataLabel'] }) const _default = defineComponent(() => { const { reason, visitTime, url } = useApp() diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index 1a6fe0870..ca9f2cab3 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -69,7 +69,7 @@ const _default = defineComponent((_, ctx) => { }, { defaultValue: false }) ctx.expose({ - getSelected: () => table.value?.getSelectionRows?.() ?? [] + getSelected: () => (table.value?.getSelectionRows?.() ?? []) as tt4b.limit.Item[], } satisfies LimitInstance) return () => ( @@ -80,7 +80,7 @@ const _default = defineComponent((_, ctx) => { height="100%" data={list.value} defaultSort={sort.value} - onSort-change={(val: Sort) => setSort({ prop: val?.prop, order: val?.order })} + onSort-change={val => val.prop && val.order && setSort({ prop: val?.prop, order: val?.order })} > }>( maxHeight="40vh" class="backup-client-table" highlightCurrentRow - onCurrent-change={(row: tt4b.backup.Client) => handleRowSelect(row)} + onCurrent-change={row => row && handleRowSelect(row)} emptyText={loading.value ? 'Loading data ...' : 'Empty data'} > { } } +const isRecordSort = createObjectGuard({ + order: createStringUnionGuard('ascending', 'descending'), + prop: createStringUnionGuard('date', 'host', 'focus', 'run', 'time'), + init: isAny, + silent: isAny, +}) + const _default = defineComponent((_, ctx) => { const rtl = isRtl() const [page, setPage] = useState({ size: 20, num: 1 }) @@ -116,7 +124,7 @@ const _default = defineComponent((_, ctx) => { height="100%" defaultSort={sort.value} onSelection-change={setSelection} - onSort-change={(val: RecordSort) => sort.value = val} + onSort-change={val => isRecordSort(val) && (sort.value = val)} > {visible.value.index && } {visible.value.date && } diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts index a515e2426..d07199634 100644 --- a/src/pages/popup/components/Limit/Content/Wrapper.ts +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -145,7 +145,7 @@ class Wrapper extends EchartsWrapper { const [timeUsed, timeLimit] = time const timeMax = timeLimit ? timeLimit * MILL_PER_SECOND : 0 const timeLabel = timeMax - ? t(msg => msg.content.limit.remain, { remaining: formatPeriodCommon(timeMax - timeUsed, true) }) + ? t(msg => msg.content.limit.remain, { remaining: formatPeriodCommon(Math.max(0, timeMax - timeUsed), true) }) : noLimitText const timeOpts: GaugeOptions = { name: 'time', center: leftPos, From f6d316efb32ee87125b596ec04f9efbdfb3e9247 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 1 Jun 2026 13:03:14 +0800 Subject: [PATCH 11/95] v4.3.4 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72a500e9..d0a1c58ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.4] - 2026-06-01 + +- Fixed some bugs + ## [4.3.3] - 2026-05-23 - Added Norwegian Bokmal, Hungarian, Indonesian to translate diff --git a/package.json b/package.json index 535322ebb..c3acbcb33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.3", + "version": "4.3.4", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 3e44a8cdf0b0798a6f3ea7310708a1d7ff81461d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 2 Jun 2026 11:35:55 +0800 Subject: [PATCH 12/95] fix: use numeric comparison for id generation in cate/limit database (#788) --- src/background/database/cate-database.ts | 5 +++-- src/background/database/limit-database.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/background/database/cate-database.ts b/src/background/database/cate-database.ts index 9c0b69336..da866003d 100644 --- a/src/background/database/cate-database.ts +++ b/src/background/database/cate-database.ts @@ -52,8 +52,9 @@ class CateDatabase extends BaseDatabase { return { id: parseInt(existId), name } } - const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1 - items[id] = { n: name ?? items[id]?.n } + const ids = Object.keys(items).map(Number).filter(Number.isFinite) + const id = (ids.length ? Math.max(...ids) : 0) + 1 + items[id] = {n: name} await this.saveItems(items) return { name, id } diff --git a/src/background/database/limit-database.ts b/src/background/database/limit-database.ts index d5d37ef0a..9ca91d0ce 100644 --- a/src/background/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -147,7 +147,8 @@ type Items = Record function migrate(exist: Items, toMigrate: unknown) { if (!isRecord(toMigrate)) return - const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 + const ids = Object.keys(exist).map(Number).filter(Number.isFinite) + const idBase = (ids.length ? Math.max(...ids) : 0) + 1 Object.values(toMigrate).forEach((value, idx) => { const id = idBase + idx const itemValue: ItemValue = value as ItemValue From 82fcd70e2ab07a9ddb8af3df5271775e4705d111 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:30:07 +0000 Subject: [PATCH 13/95] i18n(download): download translations by bot --- src/i18n/message/app/data-manage-resource.json | 5 ++++- src/i18n/message/app/limit-resource.json | 3 +++ src/i18n/message/app/menu-resource.json | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index e4ccbd5b2..0ab562c98 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -398,7 +398,10 @@ "paramError": "Sprawdź poprawność parametrów!", "deleteConfirm": "Wyfiltrowano w sumie {count} wpisów. Czy chcesz je usunąć?", "migrationAlert": "Przenieś dane między przeglądarkami za pomocą importu i eksportu", - "importError": "Błędne rozszerzenie pliku" + "importError": "Błędne rozszerzenie pliku", + "exportData": "Eksportuj dane", + "restoreData": "Przywróć dane", + "restoreFromOther": "Przywróć z {ext}" }, "it": { "totalMemoryAlert": "Il browser fornisce {size}MB per memorizzare i dati locali per ogni estensione", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 84fb6f4dd..b768999aa 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -576,6 +576,7 @@ "reminder": "Zaman sınırı dolmasına {min} dakikadan az kaldı!" }, "pl": { + "onlyEffective": "Tylko efektywne", "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", "item": { @@ -617,6 +618,8 @@ "strictTip": "Aktywowano, żadna operacja nie jest dozwolona przed zwolnieniem!", "incorrectPsw": "Nieprawidłowe hasło", "incorrectAnswer": "Niepoprawna odpowiedź", + "twoFaInputTip": "Reguła została uruchomiona lub zablokowana. Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej, aby kontynuować.", + "incorrect2fa": "Nieprawidłowy kod 2FA", "pi": "{digitCount} cyfr od {startIndex} do {endIndex} części dziesiętnej liczby π", "confession": "Czas ucieka" }, diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 9feee4539..d6df05a9b 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -115,6 +115,7 @@ "habit": "Habitudes", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", + "rule": "Règles", "other": "Autres fonctionnalités", "about": "À propos" }, @@ -167,6 +168,7 @@ "habit": "Nawyki", "additional": "Dodatkowe funkcje", "siteManage": "Zarządzanie stronami", + "rule": "Reguła", "other": "Pozostałe funkcje", "about": "O rozszerzeniu" }, From d1d75e52571e8aacd52f2c723d58e9a1e89bdfa2 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 5 Jun 2026 10:31:06 +0800 Subject: [PATCH 14/95] fix: not to reset the scroll position after limit layer hidden (#790) --- src/content-script/index.ts | 8 ++++---- src/content-script/limit/index.ts | 6 +++--- src/content-script/limit/modal/instance.ts | 2 -- src/pages/app/components/Limit/context.ts | 4 +++- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/content-script/index.ts b/src/content-script/index.ts index bf05214f8..c99134ca8 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -42,7 +42,7 @@ function getOrSetFlag(): boolean { async function main() { const dispatcher = new Dispatcher() - // Execute in every injections + // Execute in every injection const normalTracker = new NormalTracker({ onReport: data => trySendMsg2Runtime('track.time', data), onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', false), @@ -59,8 +59,8 @@ async function main() { const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) if (isWhitelist) return - initLocale() - printInfo(host) + void initLocale() + void printInfo(host) await processLimit(url, dispatcher) processTimeline() @@ -69,4 +69,4 @@ async function main() { await trySendMsg2Runtime('cs.injected') } -main() +void main() diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index fabea9e7b..f3e28fd97 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -12,11 +12,11 @@ export default async function processLimit(url: string, dispatcher: Dispatcher) const modal = new ModalInstance(url) const context: ModalContext = { modal, url } - const mesageAdaptor = new MessageAdaptor(context, delayDuration) + const messageAdaptor = new MessageAdaptor(context, delayDuration) const visitProcessor = new VisitProcessor(context, delayDuration) const processors: Processor[] = [ - mesageAdaptor, + messageAdaptor, visitProcessor, new PeriodProcessor(context), ] @@ -26,7 +26,7 @@ export default async function processLimit(url: string, dispatcher: Dispatcher) dispatcher .register('limitChanged', () => void processors.forEach(p => p.onLimitChanged())) - .register('limitTimeMeet', items => void mesageAdaptor.onLimitTimeMeet(items)) + .register('limitTimeMeet', items => void messageAdaptor.onLimitTimeMeet(items)) .register('limitReminder', data => void reminder.show(data)) .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) .registerAudibleChange(visitProcessor.tracker) diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts index 9ebee3115..c97aef856 100644 --- a/src/content-script/limit/modal/instance.ts +++ b/src/content-script/limit/modal/instance.ts @@ -159,8 +159,6 @@ class ModalInstance implements MaskModal { await this.init() } await exitFullscreen() - // Scroll to top - scrollTo(0, 0) pauseAllVideo() pauseAllAudio() diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index 9c4bb508a..f7372d1c2 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -67,6 +67,7 @@ const verifyCanModify = async (...items: tt4b.limit.Item[]) => { export const useLimitProvider = () => { const { url, action, id } = initialQuery() + let modifyTriggered = false const initialUrl = action === 'create' ? undefined : url if (action === 'create') { @@ -81,9 +82,10 @@ export const useLimitProvider = () => { defaultValue: [], deps: [() => filter.url, () => filter.effective], onSuccess: data => { - if (action !== 'modify') return + if (action !== 'modify' || modifyTriggered) return const target = data.find(i => i.id === id) target && setTimeout(() => modifyInst.value?.modify(target)) + modifyTriggered = true } }, ) From 0604321679d546bbc815e06f0a5bb444448ec17d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 5 Jun 2026 10:33:56 +0800 Subject: [PATCH 15/95] v4.3.5 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a1c58ca..2e2cc95c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.5] - 2026-06-05 + +- Fixed some bugs (#788, #790) + ## [4.3.4] - 2026-06-01 - Fixed some bugs diff --git a/package.json b/package.json index c3acbcb33..1d72adbdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.4", + "version": "4.3.5", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From e34389c9447a5f105bdd50a00d69c005b26c6f20 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 6 Jun 2026 00:57:29 +0800 Subject: [PATCH 16/95] chore: change the position --- script/user-chart/render.ts | 38 +++++++++++++++---------------------- script/zip.sh | 25 ------------------------ 2 files changed, 15 insertions(+), 48 deletions(-) delete mode 100755 script/zip.sh diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 32a873b51..e5a3fb8ce 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -4,7 +4,8 @@ import { } from "@api/gist" import { init, - type ComposeOption, type EChartsType, type GridComponentOption, type LineSeriesOption, type TitleComponentOption, + type ComposeOption, + type GridComponentOption, type LineSeriesOption, type TitleComponentOption } from "echarts" import { writeFileSync } from "fs" import { exit } from 'process' @@ -13,18 +14,15 @@ import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type User type EcOption = ComposeOption< | LineSeriesOption | TitleComponentOption - | GridComponentOption> -const ALL_BROWSERS: Browser[] = ['chrome', 'edge', 'firefox'] + | GridComponentOption +> +const ALL_BROWSERS: Browser[] = ['edge', 'chrome', 'firefox'] -type OriginData = { - [browser in Browser]: UserCount -} +type OriginData = Record type ChartData = { xAxis: string[] - yAxises: { - [browser in Browser]: number[] - } + yAxises: Record } const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ @@ -36,7 +34,7 @@ function preProcess(originData: OriginData): ChartData { let allDates = Array.from(dateSet).filter(d => VALID_DATE_RE.test(d)).sort() // 2. smooth the count - const ctx: { [browser in Browser]: SmoothContext } = { + const ctx: Record = { chrome: new SmoothContext(), firefox: new SmoothContext(), edge: new SmoothContext(), @@ -73,7 +71,7 @@ class SmoothContext { if (newVal) { this.smooth(newVal) } else { - this.increaseStep() + this.step += 1 } } @@ -82,37 +80,31 @@ class SmoothContext { return } const unitVal = (currentValue - this.lastVal) / (this.step + 1) - Object.keys(Array.from(new Array(this.step))) - .map(key => parseInt(key)) - .map(i => Math.floor(unitVal * (i + 1) + this.lastVal)) - .forEach(smoothedVal => this.data.push(smoothedVal)) + + const smoothedValues = Array.from({ length: this.step }, (_, i) => Math.floor(unitVal * (i + 1) + this.lastVal)) + this.data.push(...smoothedValues) this.data.push(currentValue) // Reset this.lastVal = currentValue this.step = 0 } - increaseStep(): void { - this.step += 1 - } - end(): number[] { - Object.keys(Array.from(new Array(this.step))) - .forEach(() => this.data.push(this.lastVal)) + Array.from({ length: this.step }).forEach(() => this.data.push(this.lastVal)) return this.data } } function render2Svg(chartData: ChartData): string { const { xAxis, yAxises } = chartData - const chart: EChartsType = init(null, null, { + const chart = init(null, null, { renderer: 'svg', ssr: true, width: 960, height: 640 }) const totalUserCount = Object.values(yAxises) - .map(v => v[v.length - 1] || 0) + .map(v => v[v.length - 1] ?? 0) .reduce((a, b) => a + b) const option: EcOption = { title: { diff --git a/script/zip.sh b/script/zip.sh deleted file mode 100755 index b36efda56..000000000 --- a/script/zip.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -FOLDER=$( - cd "$(dirname "$0")/.." - pwd -) -TARGET_PATH="${FOLDER}/aaa" - -EXCLUDE_ARGS="" - -if [ -f "${FOLDER}/.gitignore" ]; then - while IFS= read -r line || [ -n "$line" ]; do - [[ -z "$line" || "$line" =~ ^# ]] && continue - line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') - [ -z "$line" ] && continue - - pattern="${line#/}" - EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=${pattern}" - done < "${FOLDER}/.gitignore" -fi - -EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=.git" - -cd "${FOLDER}" -COPYFILE_DISABLE=1 tar -zcf ${TARGET_PATH} ${EXCLUDE_ARGS} ./ From 37ea4bde930470c7a742761ba465a66aaf034dee Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 6 Jun 2026 01:17:00 +0800 Subject: [PATCH 17/95] feat: add contributor count --- src/api/crowdin.ts | 2 +- src/pages/app/components/HelpUs/MemberList.tsx | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts index f70f76586..dab5b6b93 100644 --- a/src/api/crowdin.ts +++ b/src/api/crowdin.ts @@ -58,7 +58,7 @@ export async function getTranslationStatus(): Promise { export async function getMembers(): Promise { const result: MemberInfo[] = [] - const limit = 10 + const limit = 500 let offset = 0 while (true) { const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/members?limit=${limit}&offset=${offset}` diff --git a/src/pages/app/components/HelpUs/MemberList.tsx b/src/pages/app/components/HelpUs/MemberList.tsx index 15997ab1f..a8e43f56e 100644 --- a/src/pages/app/components/HelpUs/MemberList.tsx +++ b/src/pages/app/components/HelpUs/MemberList.tsx @@ -9,23 +9,20 @@ import { getMembers } from "@api/crowdin" import { t } from '@app/locale' import { useRequest } from '@hooks' import Box from "@pages/components/Box" -import Flex from "@pages/components/Flex" +import Flex from '@pages/components/Flex' import Img from '@pages/components/Img' import { ElDivider } from "element-plus" import { defineComponent } from "vue" const _default = defineComponent(() => { const { data: list } = useRequest(async () => { - const members = await getMembers() || [] - return members.sort((a, b) => (a.joinedAt || "").localeCompare(b.joinedAt || "")) + const members = await getMembers() + return members.sort((a, b) => a.joinedAt.localeCompare(b.joinedAt)) }) return () => ( - {t(msg => msg.helpUs.contributors)} - + {t(msg => msg.helpUs.contributors)} ({list.value?.length ?? '-'}) + {list.value?.map(({ avatarUrl, username }, idx, arr) => ( Date: Sat, 6 Jun 2026 18:47:15 +0800 Subject: [PATCH 18/95] refactor: clean dead code --- src/api/chrome/notifications.ts | 15 ++-- src/background/badge-manager.ts | 4 +- .../database/common/indexed-storage.ts | 61 +++++++------ .../database/stat-database/index.ts | 39 ++++---- .../idb.ts => timeline-database.ts} | 23 +++-- .../database/timeline-database/classic.ts | 66 -------------- .../database/timeline-database/index.ts | 26 ------ .../database/timeline-database/types.ts | 12 --- src/background/index.ts | 6 +- .../install-handler/version/index.ts | 2 - .../version/indexed-migrator.ts | 32 ------- .../service/notification/browser/notifier.ts | 8 +- src/i18n/message/app/index.ts | 6 +- src/i18n/message/app/time-format.ts | 2 +- src/i18n/message/base.ts | 1 - src/i18n/message/button.ts | 1 - src/i18n/message/calendar.ts | 1 - src/i18n/message/cs/index.ts | 2 +- src/i18n/message/item.ts | 1 - src/i18n/message/popup/index.ts | 2 +- src/pages/app/components/About/DescLink.tsx | 8 +- .../app/components/About/Description.tsx | 12 +-- .../app/components/About/InstallationLink.tsx | 6 +- .../components/Summary/Calendar/Wrapper.ts | 4 +- .../Analysis/components/Summary/index.tsx | 11 ++- src/pages/app/components/Analysis/context.ts | 4 +- .../app/components/Dashboard/ChartTitle.tsx | 15 ++-- .../components/Habit/Period/Stack/Wrapper.ts | 4 +- .../components/Habit/Period/Trend/Wrapper.ts | 4 +- .../Habit/Site/DailyTrend/Wrapper.ts | 4 +- .../app/components/Habit/Site/TopK/Wrapper.ts | 6 +- src/pages/app/components/Habit/context.ts | 2 +- src/pages/app/components/Record/types.d.ts | 2 +- .../components/Rule/Merge/components/Item.tsx | 33 ++++--- .../components/common/ContentContainer.tsx | 34 +++---- .../app/components/common/DialogSop/index.tsx | 84 +++++++++-------- .../app/components/common/EditableTag.tsx | 37 -------- .../common/filter/TimeFormatFilterItem.tsx | 6 +- .../app/components/common/kanban/Card.tsx | 22 ++--- .../common/kanban/IndicatorCell.tsx | 76 ++++++---------- src/pages/app/util/limit/types.ts | 2 +- src/pages/app/util/time.ts | 4 +- src/pages/components/Box.tsx | 35 ++++---- src/pages/components/Flex.tsx | 58 ++++++------ src/pages/components/Grid.tsx | 89 +++++++++++++++++-- .../database/stat-database/classic.test.ts | 2 +- types/tt4b.d.ts | 2 +- 47 files changed, 374 insertions(+), 502 deletions(-) rename src/background/database/{timeline-database/idb.ts => timeline-database.ts} (91%) delete mode 100644 src/background/database/timeline-database/classic.ts delete mode 100644 src/background/database/timeline-database/index.ts delete mode 100644 src/background/database/timeline-database/types.ts delete mode 100644 src/background/install-handler/version/indexed-migrator.ts delete mode 100644 src/i18n/message/base.ts delete mode 100644 src/i18n/message/button.ts delete mode 100644 src/i18n/message/calendar.ts delete mode 100644 src/i18n/message/item.ts delete mode 100644 src/pages/app/components/common/EditableTag.tsx diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts index 598d04697..0612bc096 100644 --- a/src/api/chrome/notifications.ts +++ b/src/api/chrome/notifications.ts @@ -1,17 +1,18 @@ import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" +import { getIconUrl } from './runtime' -type NotificationTopic = 'time' +type Topic = 'time' +type ChromeOptions = chrome.notifications.NotificationCreateOptions +type Options = Omit -export async function createNotification( - topic: NotificationTopic, - options: MakeRequired -): Promise { +export async function createNotification(topic: Topic, options: Options): Promise { + const param = { ...options, iconUrl: getIconUrl() } if (IS_MV3) { - return await chrome.notifications.create(topic, options) + return await chrome.notifications.create(topic, param) } else { return new Promise((resolve, reject) => { - chrome.notifications.create(topic, options, (id: string) => { + chrome.notifications.create(topic, param, (id: string) => { const error = handleError('createNotification') if (error) { reject(new Error(error)) diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 84618f279..f39ccab17 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -138,6 +138,6 @@ class BadgeManager { } } -const badgeTextManager = new BadgeManager() +const badgeManager = new BadgeManager() -export default badgeTextManager +export default badgeManager diff --git a/src/background/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts index 531e0ea76..665fb1364 100644 --- a/src/background/database/common/indexed-storage.ts +++ b/src/background/database/common/indexed-storage.ts @@ -103,50 +103,49 @@ export type IndexResult = { } export abstract class BaseIDBStorage> { - private DB_NAME = `tt4b_${chrome.runtime.id}` as const - - private db: IDBDatabase | undefined - private static initPromises = new Map>() + #DB_NAME = `tt4b_${chrome.runtime.id}` as const + #db: IDBDatabase | undefined + static #initPromises = new Map>() abstract indexes: Index[] abstract key: Key | Key[] abstract table: Table protected async initDb(): Promise { - if (this.db) return this.db + if (this.#db) return this.#db - let initPromise = BaseIDBStorage.initPromises.get(this.table) + let initPromise = BaseIDBStorage.#initPromises.get(this.table) if (!initPromise) { - initPromise = this.doInitDb() - BaseIDBStorage.initPromises.set(this.table, initPromise) + initPromise = this.#doInitDb() + BaseIDBStorage.#initPromises.set(this.table, initPromise) } try { - this.db = await initPromise - this.setupDbCloseHandler(this.db) - return this.db + this.#db = await initPromise + this.#setupDbCloseHandler(this.#db) + return this.#db } catch (error) { - BaseIDBStorage.initPromises.delete(this.table) + BaseIDBStorage.#initPromises.delete(this.table) throw error } } - private setupDbCloseHandler(db: IDBDatabase): void { + #setupDbCloseHandler(db: IDBDatabase): void { db.onversionchange = () => db.close() db.onclose = () => { - if (this.db !== db) return + if (this.#db !== db) return - this.db = undefined - BaseIDBStorage.initPromises.delete(this.table) + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) } } - private async doInitDb(): Promise { + async #doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB const checkDb = await new Promise((resolve, reject) => { - const checkRequest = factory.open(this.DB_NAME) + const checkRequest = factory.open(this.#DB_NAME) checkRequest.onsuccess = () => resolve(checkRequest.result) checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) }) @@ -155,7 +154,7 @@ export abstract class BaseIDBStorage> { } // Only used for testing, be careful when using in production - public async clear(): Promise { + async clear(): Promise { await this.withStore(store => store.clear(), 'readwrite') } @@ -163,16 +162,16 @@ export abstract class BaseIDBStorage> { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB const checkDb = await new Promise((resolve, reject) => { - const checkRequest = factory.open(this.DB_NAME) + const checkRequest = factory.open(this.#DB_NAME) checkRequest.onsuccess = () => resolve(checkRequest.result) checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) checkRequest.onblocked = () => { - console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`) + console.warn(`Database check blocked for "${this.table}" (DB: ${this.#DB_NAME}), waiting for other connections to close`) } }) const storeExisted = checkDb.objectStoreNames.contains(this.table) - const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb) + const needUpgrade = !storeExisted || this.#needUpgradeIndexes(checkDb) if (!needUpgrade) { checkDb.close() @@ -183,7 +182,7 @@ export abstract class BaseIDBStorage> { checkDb.close() return new Promise((resolve, reject) => { - const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + const upgradeRequest = factory.open(this.#DB_NAME, currentVersion + 1) upgradeRequest.onupgradeneeded = () => { try { @@ -205,7 +204,7 @@ export abstract class BaseIDBStorage> { let store = upgradeDb.objectStoreNames.contains(this.table) ? transaction.objectStore(this.table) : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) - this.createIndexes(store) + this.#createIndexes(store) } catch (error) { console.error("Failed to upgrade database in onupgradeneeded", error) upgradeRequest.transaction?.abort() @@ -225,10 +224,10 @@ export abstract class BaseIDBStorage> { } upgradeRequest.onblocked = () => { - const blockingTables = Array.from(BaseIDBStorage.initPromises.keys()) + const blockingTables = Array.from(BaseIDBStorage.#initPromises.keys()) .filter(table => table !== this.table) console.warn( - `Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` + + `Database upgrade blocked for table "${this.table}" (DB: ${this.#DB_NAME}), ` + `waiting for other connections to close. ` + `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}` ) @@ -236,7 +235,7 @@ export abstract class BaseIDBStorage> { }) } - private needUpgradeIndexes(db: IDBDatabase): boolean { + #needUpgradeIndexes(db: IDBDatabase): boolean { try { const transaction = db.transaction(this.table, 'readonly') const store = transaction.objectStore(this.table) @@ -256,7 +255,7 @@ export abstract class BaseIDBStorage> { } } - private createIndexes(store: IDBObjectStore) { + #createIndexes(store: IDBObjectStore) { const existingIndexes = store.indexNames for (const index of this.indexes) { @@ -298,12 +297,12 @@ export abstract class BaseIDBStorage> { } if (errorType === 'StoreNotFound') { - this.db?.close() + this.#db?.close() await this.upgrade() } - this.db = undefined - BaseIDBStorage.initPromises.delete(this.table) + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) db = await this.initDb() } } diff --git a/src/background/database/stat-database/index.ts b/src/background/database/stat-database/index.ts index f22b15ced..98a67b0f9 100644 --- a/src/background/database/stat-database/index.ts +++ b/src/background/database/stat-database/index.ts @@ -38,58 +38,61 @@ class StatDatabaseWrapper implements StateDatabaseComposite { classic: new ClassicStatDatabase(), indexed_db: new IDBStatDatabase(), }) - private current = () => this.holder.current + + get #current() { + return this.holder.current + } get(host: string, date: Date): Promise { - return this.current().get(host, date) + return this.#current.get(host, date) } batchSelect(keys: tt4b.core.RowKey[]): Promise { - return this.current().batchSelect(keys) + return this.#current.batchSelect(keys) } select(condition?: StatCondition): Promise { - return this.current().select(condition) + return this.#current.select(condition) } accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise { - return this.current().accumulate(host, date, item) + return this.#current.accumulate(host, date, item) } batchAccumulate(data: Record, date: Date | string): Promise> { - return this.current().batchAccumulate(data, date) + return this.#current.batchAccumulate(data, date) } accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise { - return this.current().accumulateGroup(groupId, date, item) + return this.#current.accumulateGroup(groupId, date, item) } delete(...rows: tt4b.core.RowKey[]): Promise { - return this.current().delete(...rows) + return this.#current.delete(...rows) } deleteByHost(host: string, range?: string | [string, string]): Promise { - return this.current().deleteByHost(host, range) + return this.#current.deleteByHost(host, range) } deleteByGroup(groupId: number, range?: string | [string, string]): Promise { - return this.current().deleteByGroup(groupId, range) + return this.#current.deleteByGroup(groupId, range) } selectGroup(condition?: StatCondition): Promise { - return this.current().selectGroup(condition) + return this.#current.selectGroup(condition) } deleteGroup(...rows: [groupId: number, date: string][]): Promise { - return this.current().deleteGroup(...rows) + return this.#current.deleteGroup(...rows) } forceUpdate(...rows: tt4b.core.Row[]): Promise { - return this.current().forceUpdate(...rows) + return this.#current.forceUpdate(...rows) } forceUpdateGroup(...rows: tt4b.core.Row[]): Promise { - return this.current().forceUpdateGroup(...rows) + return this.#current.forceUpdateGroup(...rows) } async migrateStorage(type: tt4b.option.StorageType): Promise<[tt4b.core.Row[], tt4b.core.Row[]]> { @@ -103,18 +106,18 @@ class StatDatabaseWrapper implements StateDatabaseComposite { } async afterStorageMigrated([tabs, groups]: [tt4b.core.Row[], tt4b.core.Row[]]): Promise { - await this.current().delete(...tabs) + await this.#current.delete(...tabs) const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) - await this.current().deleteGroup(...groupKeys) + await this.#current.deleteGroup(...groupKeys) } async importData(data: unknown): Promise { const rows = this.parseImportRows(data) - await this.forceUpdate(...rows) + await this.#current.forceUpdate(...rows) } async exportData(): Promise { - return this.select({ virtual: true }) + return this.#current.select({ virtual: true }) } private parseImportRows(data: unknown): tt4b.core.Row[] { diff --git a/src/background/database/timeline-database/idb.ts b/src/background/database/timeline-database.ts similarity index 91% rename from src/background/database/timeline-database/idb.ts rename to src/background/database/timeline-database.ts index a99bbfb16..23536ef47 100644 --- a/src/background/database/timeline-database/idb.ts +++ b/src/background/database/timeline-database.ts @@ -2,8 +2,15 @@ import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' import { BaseIDBStorage, iterateCursor, req2Promise, type Index, type IndexResult, type Key, type Table, -} from '../common/indexed-storage' -import type { TimelineCondition, TimelineDatabase } from './types' +} from './common/indexed-storage' + +type TimelineCondition = { + host?: string + /** + * Start time in milliseconds, inclusive + */ + start?: number +} const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 @@ -40,7 +47,7 @@ class CleanThrottle { } } -export default class IDBTimelineDatabase extends BaseIDBStorage implements TimelineDatabase { +class TimelineDatabase extends BaseIDBStorage { indexes: Index[] = [ 'host', 'start', ] @@ -91,7 +98,7 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { const rows = await this.withStore(async store => { - const { cursorReq, coverage = {} } = this.judgeIndex(store, cond) + const { cursorReq, coverage = {} } = this.#judgeIndex(store, cond) const rows = await iterateCursor(cursorReq) const { start: cs, host: ch } = cond ?? {} return rows.filter(tick => { @@ -111,7 +118,7 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { + #judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult { const { host, start } = cond ?? {} if (host) { return { @@ -127,4 +134,8 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { - if (start && tick.start < start) { - return false - } - if (host && tick.host !== host) { - return false - } - return true - }) -} - -/** - * @deprecated Use IDBTimelineDatabase instead, this is for old version data migration - */ -export default class ClassicTimelineDatabase extends BaseDatabase implements TimelineDatabase { - - private async getData(): Promise { - const data = await this.storage.getOne(DB_KEY) - return data ?? {} - } - - async batchSave(_ticks: tt4b.timeline.Tick[]): Promise { - console.warn("ClassicTimelineDatabase is deprecated, data will not be saved to it. This invoking is not expected") - return - } - - async select(cond?: TimelineCondition): Promise { - const data = await this.getData() - const ticks: tt4b.timeline.Tick[] = [] - Object.values(data).forEach(hostData => { - Object.entries(hostData).forEach(([host, items]) => { - items.forEach(({ s: start, d: duration }) => ticks.push({ host, start, duration })) - }) - }) - return filter(ticks, cond) - } - - async clear(): Promise { - await this.storage.remove(DB_KEY) - } -} diff --git a/src/background/database/timeline-database/index.ts b/src/background/database/timeline-database/index.ts deleted file mode 100644 index e7b43a8c0..000000000 --- a/src/background/database/timeline-database/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import ClassicTimelineDatabase from './classic' -import IDBTimelineDatabase from './idb' -import type { TimelineCondition, TimelineDatabase } from './types' - -class TimelineDatabaseWrapper implements TimelineDatabase { - private classic = new ClassicTimelineDatabase() - private idb = new IDBTimelineDatabase() - - batchSave(ticks: tt4b.timeline.Tick[]): Promise { - return this.idb.batchSave(ticks) - } - - select(cond?: TimelineCondition): Promise { - return this.idb.select(cond) - } - - async migrateFromClassic(): Promise { - const ticks = await this.classic.select() - await this.idb.batchSave(ticks) - await this.classic.clear() - } -} - -const timelineDatabase = new TimelineDatabaseWrapper() - -export default timelineDatabase \ No newline at end of file diff --git a/src/background/database/timeline-database/types.ts b/src/background/database/timeline-database/types.ts deleted file mode 100644 index 6b770e24c..000000000 --- a/src/background/database/timeline-database/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type TimelineCondition = { - host?: string - /** - * Start time in milliseconds, inclusive - */ - start?: number -} - -export interface TimelineDatabase { - batchSave(ticks: tt4b.timeline.Tick[]): Promise - select(cond?: TimelineCondition): Promise -} \ No newline at end of file diff --git a/src/background/index.ts b/src/background/index.ts index c717dbaa1..8aa6060e7 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -7,7 +7,7 @@ import { trySendMsg2Tab } from "@api/chrome/tab" import { initBrowserAction, initSidePanel } from './action' -import badgeTextManager from "./badge-manager" +import badgeManager from "./badge-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" import { initAfterInstalled } from './install-handler' @@ -47,11 +47,11 @@ initScheduler() initWhitelistMenuManager() // Badge manager -badgeTextManager.init(messageDispatcher) +badgeManager.init(messageDispatcher) // Listen to tab changed new TabListener() - .onActivated(({ url, tabId }) => badgeTextManager.updateFocus({ url, tabId })) + .onActivated(({ url, tabId }) => badgeManager.updateFocus({ url, tabId })) .onUpdated((tabId, { audible }) => audible !== undefined && trySendMsg2Tab(tabId, 'syncAudible', audible)) .start() diff --git a/src/background/install-handler/version/index.ts b/src/background/install-handler/version/index.ts index 3c6767dc2..daf126b15 100644 --- a/src/background/install-handler/version/index.ts +++ b/src/background/install-handler/version/index.ts @@ -8,7 +8,6 @@ import { getVersion } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" import HostMergeInitializer from "./host-merge-initializer" -import IndexedDBMigrator from './indexed-migrator' import LocalFileInitializer from "./local-file-initializer" import type { Migrator } from "./types" import WhitelistInitializer from "./whitelist-initializer" @@ -27,7 +26,6 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new IndexedDBMigrator(), ) } diff --git a/src/background/install-handler/version/indexed-migrator.ts b/src/background/install-handler/version/indexed-migrator.ts deleted file mode 100644 index 63ee0169b..000000000 --- a/src/background/install-handler/version/indexed-migrator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { BaseIDBStorage } from '@db/common/indexed-storage' -import { IDBStatDatabase } from '@db/stat-database/idb' -import timelineDatabase from '@db/timeline-database' -import IDBTimelineDatabase from '@db/timeline-database/idb' -import type { Migrator } from './types' - -async function upgradeIndexedDB() { - try { - const storages: BaseIDBStorage[] = [new IDBStatDatabase(), new IDBTimelineDatabase()] - for (const storage of storages) { - await storage.upgrade() - } - console.log('IndexedDB upgraded successfully') - } catch (error) { - console.error('Failed to upgrade IndexedDB', error) - } -} - -class IndexedMigrator implements Migrator { - onInstall(): void { - } - - async onUpdate(_version: string): Promise { - await upgradeIndexedDB() - - timelineDatabase.migrateFromClassic() - .then(() => console.log('Timeline data migrated to IndexedDB')) - .catch(e => console.error('Failed to migrate timeline data to IndexedDB', e)) - } -} - -export default IndexedMigrator \ No newline at end of file diff --git a/src/background/service/notification/browser/notifier.ts b/src/background/service/notification/browser/notifier.ts index 8075ac771..bc9902c7d 100644 --- a/src/background/service/notification/browser/notifier.ts +++ b/src/background/service/notification/browser/notifier.ts @@ -1,6 +1,5 @@ import { createNotification } from "@api/chrome/notifications" import { hasPerm, requestPerm } from "@api/chrome/permission" -import { getIconUrl } from "@api/chrome/runtime" import { t } from '@i18n' import calendarMessages from "@i18n/message/common/calendar" import metaMessages from "@i18n/message/common/meta" @@ -51,11 +50,6 @@ export default class BrowserNotifier implements Notifier { const message = `Focus time: ${focusStr}, Visits: ${visit}, Sites: ${siteCount}` - await createNotification('time', { - type: 'basic', - iconUrl: getIconUrl(), - title, - message, - }) + await createNotification('time', { type: 'basic', title, message }) } } diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 92716d4ce..da6e63755 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ -import baseMessages, { type BaseMessage } from "../base" -import buttonMessages, { type ButtonMessage } from "../button" +import baseMessages, { type BaseMessage } from "../common/base" +import buttonMessages, { type ButtonMessage } from "../common/button" import calendarMessages, { type CalendarMessage } from "../common/calendar" +import itemMessages, { type ItemMessage } from "../common/item" import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from '../common/shared' import limitModalMessages, { type ModalMessage } from "../cs/modal" -import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" import aboutMessages, { type AboutMessage } from "./about" import analysisMessages, { type AnalysisMessage } from "./analysis" diff --git a/src/i18n/message/app/time-format.ts b/src/i18n/message/app/time-format.ts index f726bea85..35e17abf8 100644 --- a/src/i18n/message/app/time-format.ts +++ b/src/i18n/message/app/time-format.ts @@ -7,7 +7,7 @@ import resource from './time-format-resource.json' -export type TimeFormatMessage = { [key in tt4b.app.TimeFormat]: string } +export type TimeFormatMessage = { [key in tt4b.ui.TimeFormat]: string } const _default: Messages = resource diff --git a/src/i18n/message/base.ts b/src/i18n/message/base.ts deleted file mode 100644 index 8a3aeacbf..000000000 --- a/src/i18n/message/base.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, type BaseMessage } from "./common/base" diff --git a/src/i18n/message/button.ts b/src/i18n/message/button.ts deleted file mode 100644 index 6fb6911fe..000000000 --- a/src/i18n/message/button.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, type ButtonMessage } from "./common/button" diff --git a/src/i18n/message/calendar.ts b/src/i18n/message/calendar.ts deleted file mode 100644 index cdfb56765..000000000 --- a/src/i18n/message/calendar.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, type CalendarMessage } from "./common/calendar" diff --git a/src/i18n/message/cs/index.ts b/src/i18n/message/cs/index.ts index 5fdfbfa88..791f279f3 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -1,6 +1,6 @@ import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" -import calendarMessages, { type CalendarMessage } from "../calendar" +import calendarMessages, { type CalendarMessage } from "../common/calendar" import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from '../common/shared' import { merge, type MessageRoot } from "../merge" diff --git a/src/i18n/message/item.ts b/src/i18n/message/item.ts deleted file mode 100644 index fdc430cd1..000000000 --- a/src/i18n/message/item.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, type ItemMessage } from "./common/item" diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index 3b220b708..3d7501dfd 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -7,9 +7,9 @@ import baseMessages, { type BaseMessage } from "../common/base" import calendarMessages, { type CalendarMessage } from "../common/calendar" +import itemMessages, { type ItemMessage } from "../common/item" import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from "../common/shared" -import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" import contentMessages, { type ContentMessage } from "./content" import footerMessages, { type FooterMessage } from "./footer" diff --git a/src/pages/app/components/About/DescLink.tsx b/src/pages/app/components/About/DescLink.tsx index 3491f5d77..c2a8b21f8 100644 --- a/src/pages/app/components/About/DescLink.tsx +++ b/src/pages/app/components/About/DescLink.tsx @@ -1,12 +1,10 @@ import { useXsState } from "@hooks" import Flex from '@pages/components/Flex' import { ElLink } from "element-plus" -import { defineComponent, h, useSlots } from "vue" -import { type JSX } from 'vue/jsx-runtime' +import { type Component, defineComponent, h, useSlots } from "vue" -const _default = defineComponent<{ href?: string, icon?: JSX.Element }>(props => { +const _default = defineComponent<{ href?: string, icon?: Component }>(props => { const { icon, href } = props - const { default: default_, } = useSlots() const isXs = useXsState() return () => ( @@ -14,7 +12,7 @@ const _default = defineComponent<{ href?: string, icon?: JSX.Element }>(props => {icon && !isXs.value && {h(icon)} } - {default_ ? h(default_) : href ?? ''} + {useSlots().default?.() ?? href ?? ''} ) }, { props: ['href', 'icon'] }) diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index 5a8be7f0d..dc48ed8ef 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -107,7 +107,7 @@ const _default = defineComponent<{}>(() => { msg.base.sourceCode)} labelAlign="right"> - } /> + msg.about.label.license)} labelAlign="right"> @@ -115,7 +115,7 @@ const _default = defineComponent<{}>(() => { msg.base.changeLog)} labelAlign="right"> - } /> + msg.about.label.support)} labelAlign="right"> {pages.email} @@ -135,18 +135,18 @@ const _default = defineComponent<{}>(() => { msg.about.label.thanks)} labelAlign="right">
    - }>VueJS + VueJS
    - }>Echarts + Echarts
    - }>Element Plus + Element Plus
    {locale !== 'zh_CN' && ( - } href={BUY_ME_A_COFFEE_PAGE}>{BUY_ME_A_COFFEE_PAGE} + {BUY_ME_A_COFFEE_PAGE} )} diff --git a/src/pages/app/components/About/InstallationLink.tsx b/src/pages/app/components/About/InstallationLink.tsx index e0d38c977..eec9d9c78 100644 --- a/src/pages/app/components/About/InstallationLink.tsx +++ b/src/pages/app/components/About/InstallationLink.tsx @@ -1,8 +1,8 @@ -import { useXsState } from '@hooks' import { css } from '@emotion/css' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { colorUsage, colorVariant } from '@pages/util/style' -import { computed, defineComponent, h, StyleValue, useSlots } from "vue" +import { computed, defineComponent, type StyleValue, useSlots } from "vue" type Props = { name: string, href: string } @@ -56,7 +56,7 @@ const InstallationLink = defineComponent(({ href, name }) => { style={{ textDecorationLine: 'none' }} >
    - {icon && h(icon)} + {icon?.()}
    {name}
    diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts index 7ae74b138..47041e983 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts @@ -76,7 +76,7 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { function optionOf( data: _Value[], weekDays: string[], - format: tt4b.app.TimeFormat, domWidth: number, + format: tt4b.ui.TimeFormat, domWidth: number, ): EcOption { const xAxisLabelMap = getXAxisLabelMap(data) const axisTextColor = getSecondaryTextColor() @@ -140,7 +140,7 @@ function optionOf( export type BizOption = { rows: tt4b.stat.Row[] - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat } class Wrapper extends EchartsWrapper { diff --git a/src/pages/app/components/Analysis/components/Summary/index.tsx b/src/pages/app/components/Analysis/components/Summary/index.tsx index 5e1aa7034..2cfcaf0e0 100644 --- a/src/pages/app/components/Analysis/components/Summary/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/index.tsx @@ -54,11 +54,16 @@ const _default = defineComponent(() => { msg.analysis.summary.title}> - + - timeFormat: Ref + timeFormat: Ref rows: Ref } @@ -51,7 +51,7 @@ const NAMESPACE = 'siteAnalysis' export const initAnalysis = () => { const target = ref(parseQuery()) - const [cached, setCached] = useLocalStorage('analysis_timeFormat', isTimeFormat, 'default') + const [cached, setCached] = useLocalStorage('analysis_timeFormat', isTimeFormat, 'default') const timeFormat = ref(cached) watch(timeFormat, setCached) diff --git a/src/pages/app/components/Dashboard/ChartTitle.tsx b/src/pages/app/components/Dashboard/ChartTitle.tsx index 943018dd6..f04baf993 100644 --- a/src/pages/app/components/Dashboard/ChartTitle.tsx +++ b/src/pages/app/components/Dashboard/ChartTitle.tsx @@ -1,13 +1,10 @@ import Box from '@pages/components/Box' -import { defineComponent, h, useSlots } from "vue" +import type { FunctionalComponent } from "vue" -const _default = defineComponent<{ text?: string }>(props => { - const { default: textSlot } = useSlots() - return () => ( - - {textSlot ? h(textSlot) : {props.text ?? ''}} - - ) -}, { props: ['text'] }) +const _default: FunctionalComponent<{ text?: string }> = ({ text }, { slots }) => ( + + {slots.default?.() ?? {text ?? '-'}} + +) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts index 0d15c76b5..5a076e816 100644 --- a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts @@ -15,12 +15,12 @@ type EcOption = ComposeOption< export type BizOption = { data: tt4b.period.Row[] - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat } const [COLOR] = getLineSeriesPalette() -const formatTooltip = (params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat) => { +const formatTooltip = (params: TopLevelFormatterParams, timeFormat: tt4b.ui.TimeFormat) => { const param = Array.isArray(params) ? params[0] : params const [, total, , end] = param?.data as number[] return ` diff --git a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts index fa2d96421..a933f1ef1 100644 --- a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts @@ -21,11 +21,11 @@ type EcOption = ComposeOption< export type BizOption = { data: tt4b.period.Row[] - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat } -function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat): string { +function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: tt4b.ui.TimeFormat): string { const format = Array.isArray(params) ? params[0] : params if (!format) return 'NaN' const { value } = format diff --git a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index 1084b998d..224e99bf8 100644 --- a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -36,7 +36,7 @@ const TITLE = t(msg => msg.habit.site.trend.title) export type BizOption = { rows: tt4b.stat.Row[] dateRange: [Date, Date] - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat } const valueYAxis = (): YAXisOption => ({ @@ -47,7 +47,7 @@ const valueYAxis = (): YAXisOption => ({ splitLine: { show: false }, }) -const formatTimeTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat) => { +const formatTimeTooltip = (params: TopLevelFormatterParams, format: tt4b.ui.TimeFormat) => { if (!Array.isArray(params)) return '' const date = params?.[0]?.name if (!date) return '' diff --git a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts index 62448f342..4b8d089f1 100644 --- a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts @@ -26,7 +26,7 @@ type EcOption = ComposeOption< type BizOption = { rows: tt4b.stat.Row[] - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat } const TOP_NUM = 8 @@ -34,7 +34,7 @@ const TOP_NUM = 8 const MARGIN_LEFT_P = 8 const MARGIN_RIGHT_P = 8 -const formatFocusTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat): string => { +const formatFocusTooltip = (params: TopLevelFormatterParams, format: tt4b.ui.TimeFormat): string => { const param = Array.isArray(params) ? params[0] : params const { data } = param || {} const row = (data as any)?.row as tt4b.stat.Row @@ -75,7 +75,7 @@ function mergeDate(origin: tt4b.stat.Row[]): tt4b.stat.Row[] { return newRows } -async function generateOption(rows: tt4b.stat.Row[] = [], timeFormat: tt4b.app.TimeFormat, dom: HTMLElement): Promise { +async function generateOption(rows: tt4b.stat.Row[] = [], timeFormat: tt4b.ui.TimeFormat, dom: HTMLElement): Promise { const merged = mergeDate(rows) const topList = merged.sort((a, b) => b.focus - a.focus).splice(0, TOP_NUM).reverse() const max = topList[topList.length - 1]?.focus ?? 0 diff --git a/src/pages/app/components/Habit/context.ts b/src/pages/app/components/Habit/context.ts index 84db196f1..5443b9cf5 100644 --- a/src/pages/app/components/Habit/context.ts +++ b/src/pages/app/components/Habit/context.ts @@ -10,7 +10,7 @@ import { daysAgo } from "@util/time" import { reactive, Reactive } from "vue" export type FilterOption = { - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat dateRange: [Date, Date] } diff --git a/src/pages/app/components/Record/types.d.ts b/src/pages/app/components/Record/types.d.ts index d02a0cd0a..12ddb825a 100644 --- a/src/pages/app/components/Record/types.d.ts +++ b/src/pages/app/components/Record/types.d.ts @@ -14,7 +14,7 @@ export type RecordFilterOption = { /** * @since 1.1.7 */ - timeFormat: tt4b.app.TimeFormat + timeFormat: tt4b.ui.TimeFormat readRemote?: boolean } diff --git a/src/pages/app/components/Rule/Merge/components/Item.tsx b/src/pages/app/components/Rule/Merge/components/Item.tsx index a37e8be2b..10b21f903 100644 --- a/src/pages/app/components/Rule/Merge/components/Item.tsx +++ b/src/pages/app/components/Rule/Merge/components/Item.tsx @@ -4,13 +4,13 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import EditableTag from "@app/components/common/EditableTag" import { t } from '@app/locale' +import { Edit } from '@element-plus/icons-vue' import { useShadow, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" import { LOCAL_HOST_PATTERN } from "@util/constant/remain-host" -import { type TagProps } from "element-plus" -import { computed, defineComponent } from "vue" +import { ElTag, type TagProps } from "element-plus" +import { computed, defineComponent, type StyleValue } from "vue" import ItemInput from "./ItemInput" type Props = { @@ -38,6 +38,12 @@ function computeMergeType(mergedVal: number | string): TagProps["type"] | undefi return undefined } +const EDIT_ICON_STYLE: StyleValue = { + height: '14px', + width: '14px', + cursor: 'pointer', +} + const _default = defineComponent((props, ctx) => { const [origin, , refreshOrigin] = useShadow(() => props.origin) const [merged, , refreshMerged] = useShadow(() => props.merged, '') @@ -67,18 +73,23 @@ const _default = defineComponent((props, ctx) => { onCancel={handleCancel} /> ) : ( - props.onDelete?.(props.origin)} - onEdit={openEditing} + type={type.value} > - - {origin.value} - {'>>>'} - {mergeText.value} + + + {origin.value} + {'>>>'} + {mergeText.value} + + + + - + ) }, { props: ['merged', 'onChange', 'onDelete', 'origin'] }) diff --git a/src/pages/app/components/common/ContentContainer.tsx b/src/pages/app/components/common/ContentContainer.tsx index 1bba79788..9100b7c40 100644 --- a/src/pages/app/components/common/ContentContainer.tsx +++ b/src/pages/app/components/common/ContentContainer.tsx @@ -8,36 +8,24 @@ import { css } from '@emotion/css' import Flex from "@pages/components/Flex" import { ElCard, useNamespace } from "element-plus" -import { type FunctionalComponent, h, type StyleValue } from "vue" +import type { FunctionalComponent } from "vue" import ContentCard from "./ContentCard" -const FILTER_BODY_STYLE: StyleValue = { - paddingBottom: '18px', - paddingTop: '18px', - boxSizing: 'border-box', - width: '100%', - userSelect: 'none', -} - -const useContainerStyle = () => { +export const FilterContainer: FunctionalComponent = (_, ctx) => { const btnNs = useNamespace('button') - return css` + const clz = css` + padding-block: 18px; + user-select: none; + & .${btnNs.b()}+.${btnNs.b()} { margin-inline-start: 0px; } ` + return } -export const FilterContainer: FunctionalComponent = (_, ctx) => ( - -) - const _default: FunctionalComponent<{ class?: string }> = (props, ctx) => { - const { default: default_, filter, content } = ctx.slots + const { default: children, filter, content } = ctx.slots return ( = (props, ctx) => { boxSizing="border-box" gap={15} > - {filter && {h(filter)}} - {!!default_ && {h(default_)}} - {!default_ && content && } + {filter && {filter()}} + {!!children && {children()}} + {!children && content && {content()}} ) } diff --git a/src/pages/app/components/common/DialogSop/index.tsx b/src/pages/app/components/common/DialogSop/index.tsx index 7cc34244c..d1e773195 100644 --- a/src/pages/app/components/common/DialogSop/index.tsx +++ b/src/pages/app/components/common/DialogSop/index.tsx @@ -5,7 +5,7 @@ import { useXsState } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { type ButtonProps, DialogProps, ElButton, ElDialog, ElDivider, ElStep, ElSteps, ElText, useNamespace } from "element-plus" -import { defineComponent, h, type StyleValue, useSlots } from "vue" +import { defineComponent, type StyleValue, useSlots } from "vue" import { useDialogSop } from './context' type Props = { @@ -31,50 +31,46 @@ const DialogSop = defineComponent(props => { } ` - return () => { - const { default: children } = useSlots() - - return ( - - -
    - {isXs.value ? ( - - {props.stepTitles[step.value]} - - - ) : ( - - {props.stepTitles.map(stepTitle => )} - - )} -
    - - {!!children && h(children)} - - - - {t(msg => msg.button[isFirst.value ? 'cancel' : 'previous'])} - - - {isLast.value ? props.finishButton?.text ?? t(msg => msg.button.save) : t(msg => msg.button.next)} - - + return () => ( + + +
    + {isXs.value ? ( + + {props.stepTitles[step.value]} + + + ) : ( + + {props.stepTitles.map(stepTitle => )} + + )} +
    + + {useSlots().default?.()} + + + + {t(msg => msg.button[isFirst.value ? 'cancel' : 'previous'])} + + + {isLast.value ? props.finishButton?.text ?? t(msg => msg.button.save) : t(msg => msg.button.next)} + -
    - ) - } +
    +
    + ) }, { props: ['title', 'stepTitles', 'finishButton', 'width', 'top'] }) export default DialogSop diff --git a/src/pages/app/components/common/EditableTag.tsx b/src/pages/app/components/common/EditableTag.tsx deleted file mode 100644 index 188aca317..000000000 --- a/src/pages/app/components/common/EditableTag.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Edit } from "@element-plus/icons-vue" -import Flex from "@pages/components/Flex" -import { ElTag, type TagProps } from "element-plus" -import { defineComponent, h, useSlots, type StyleValue } from "vue" - -type EditableTagProps = PartialPick & { - text?: string - onEdit?: NoArgCallback - onClose?: NoArgCallback -} - -const EDIT_ICON_STYLE: StyleValue = { - height: '14px', - width: '14px', - cursor: 'pointer', -} - -const EditableTag = defineComponent(props => { - const { default: textSlot } = useSlots() - return () => ( - - - {textSlot ? h(textSlot) : props.text ?? ''} - - - - - - ) -}, { props: ['closable', 'onClose', 'onEdit', 'text', 'type'] }) - -export default EditableTag \ No newline at end of file diff --git a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx index 83ce00475..583ac07fc 100644 --- a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx +++ b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx @@ -9,20 +9,20 @@ import { t } from "@app/locale" import { defineComponent } from "vue" import SelectFilterItem from "./SelectFilterItem" -const TIME_FORMAT_LABELS: { [key in tt4b.app.TimeFormat]: string } = { +const TIME_FORMAT_LABELS: { [key in tt4b.ui.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) } -const _default = defineComponent>(props => { +const _default = defineComponent>(props => { return () => ( val && props.onChange?.(val as tt4b.app.TimeFormat)} + onChange={val => val && props.onChange?.(val as tt4b.ui.TimeFormat)} /> ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/common/kanban/Card.tsx b/src/pages/app/components/common/kanban/Card.tsx index 77363cfb7..25228dd8a 100644 --- a/src/pages/app/components/common/kanban/Card.tsx +++ b/src/pages/app/components/common/kanban/Card.tsx @@ -5,10 +5,11 @@ * https://opensource.org/licenses/MIT */ +import { t, type I18nKey } from '@app/locale' import { useXsState } from '@hooks' -import { I18nKey, t } from '@app/locale' +import Box from '@pages/components/Box' import { ElCard } from "element-plus" -import { defineComponent, h, useSlots, type StyleValue } from "vue" +import { defineComponent, useSlots, type StyleValue } from "vue" const TITLE_STYLE: StyleValue = { position: 'absolute', @@ -19,6 +20,10 @@ const TITLE_STYLE: StyleValue = { zIndex: 1000, } +const FILTER_CONTAINER_STYLE: StyleValue = { + borderBottom: '1px var(--el-border-color) var(--el-border-style)', +} + const _default = defineComponent<{ title: I18nKey }>(props => { const { default: default_, filter } = useSlots() const isXs = useXsState() @@ -27,16 +32,11 @@ const _default = defineComponent<{ title: I18nKey }>(props => {
    {t(props.title)}
    {!!filter && !isXs.value && ( -
    - {h(filter)} -
    + + {filter()} + )} - {!!default_ && h(default_)} + {default_?.()}
    ) }, { props: ['title'] }) diff --git a/src/pages/app/components/common/kanban/IndicatorCell.tsx b/src/pages/app/components/common/kanban/IndicatorCell.tsx index 0e6feade7..6765767f4 100644 --- a/src/pages/app/components/common/kanban/IndicatorCell.tsx +++ b/src/pages/app/components/common/kanban/IndicatorCell.tsx @@ -5,24 +5,21 @@ * https://opensource.org/licenses/MIT */ -import { useXsState } from '@hooks' import { tN, type I18nKey } from "@app/locale" import { BottomRight, InfoFilled, TopRight } from "@element-plus/icons-vue" +import { useXsState } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { colorVariant, getCssVariable } from '@pages/util/style' -import { range } from "@util/array" import { ElIcon, ElTooltip } from "element-plus" -import { computed, defineComponent, type CSSProperties } from "vue" +import { defineComponent, type CSSProperties, type FunctionalComponent } from "vue" import type { RingValue, ValueFormatter } from './types' -const SubVal = defineComponent<{ value: string }>(props => { - return () => ( - - {props.value} - - ) -}, { props: ['value'] }) +const SubVal: FunctionalComponent<{ value: string }> = ({ value }) => ( + + {value} + +) const computeComparison = (value: RingValue) => { const [current = 0, last = 0] = value @@ -42,51 +39,31 @@ const computeComparison = (value: RingValue) => { return count ? { color, Icon, count } : false } -const renderIcons = (val: ReturnType) => { - if (!val) return false - const { color, count, Icon } = val - +const ComparisonIcon: FunctionalComponent<{ value: RingValue }> = props => { + const comp = computeComparison(props.value) + if (!comp) return null + const { color, count, Icon } = comp return ( - - {range(count).map(() => )} + + {Array.from({ length: count }).map((_, idx) => )} ) } -const ComparisonIcon = defineComponent<{ value: RingValue }>(props => { - const comp = computed(() => computeComparison(props.value)) - return () => renderIcons(comp.value) -}, { props: ['value'] }) +const RingLine: FunctionalComponent<{ value: RingValue, formatter?: ValueFormatter }> = props => { + const { value, formatter } = props + const [current, last] = value + if (current === undefined && last === undefined) return -/** - * Compute ring text - * - * @param ring ring value - * @param formatter formatter - * @returns text or '-' - */ -function computeRingText(ring: RingValue, formatter?: ValueFormatter): string | undefined { - const [current, last] = ring - if (current === undefined && last === undefined) { - // return undefined if both are undefined - return undefined - } const delta = (current ?? 0) - (last ?? 0) - let result = formatter ? formatter(delta) : delta?.toString() - delta >= 0 && (result = '+' + result) - return result -} - -const RingLine = defineComponent<{ value: RingValue, formatter?: ValueFormatter }>(props => { - const text = computed(() => computeRingText(props.value, props.formatter)) - return () => text.value ? <> - + const deltaText = formatter?.(delta) ?? delta?.toString() + const text = delta >= 0 ? `+${deltaText}` : deltaText + return <> + - : <> - -}, { props: ['value', 'formatter'] }) +} type SubProps = { value?: string @@ -130,8 +107,8 @@ type Props = { containerStyle?: CSSProperties } -const _default = defineComponent(props => { - return () => ( +const IndicatorCell: FunctionalComponent = props => { + return ( (props => { /> ) -}, { props: ['mainName', 'mainValue', 'subInfo', 'subRing', 'subTips', 'subValue', 'containerStyle', 'valueFormatter'] }) +} +IndicatorCell.displayName = 'IndicatorCell' -export default _default \ No newline at end of file +export default IndicatorCell \ No newline at end of file diff --git a/src/pages/app/util/limit/types.ts b/src/pages/app/util/limit/types.ts index 1b1aaabe9..f3908a74b 100644 --- a/src/pages/app/util/limit/types.ts +++ b/src/pages/app/util/limit/types.ts @@ -37,6 +37,6 @@ export interface VerificationGenerator { generate(context: VerificationContext): VerificationPair } -export const isTimeFormat = createStringUnionGuard('default', 'hour', 'minute', 'second') +export const isTimeFormat = createStringUnionGuard('default', 'hour', 'minute', 'second') export const isOptionalIntArray = createOptionalGuard(createArrayGuard(isInt)) diff --git a/src/pages/app/util/time.ts b/src/pages/app/util/time.ts index e50e55b78..b86a1a47f 100644 --- a/src/pages/app/util/time.ts +++ b/src/pages/app/util/time.ts @@ -25,11 +25,11 @@ export function cvt2LocaleTime(date: string | undefined): string { } type PeriodFormatOption = { - format?: tt4b.app.TimeFormat + format?: tt4b.ui.TimeFormat hideUnit?: boolean } -const UNIT_MAP: { [unit in Exclude]: string } = { +const UNIT_MAP: { [unit in Exclude]: string } = { second: 's', minute: 'm', hour: 'h', diff --git a/src/pages/components/Box.tsx b/src/pages/components/Box.tsx index 64be2126e..037e92d5b 100644 --- a/src/pages/components/Box.tsx +++ b/src/pages/components/Box.tsx @@ -1,22 +1,19 @@ -import { defineComponent, h, useSlots } from "vue" -import { ALL_BASE_PROPS, type BaseProps, cvt2BaseStyle } from "./common" +import type { FunctionalComponent } from "vue" +import { type BaseProps, cvt2BaseStyle } from "./common" -const Box = defineComponent(props => { - const { default: defaultSlots } = useSlots() - - return () => ( -
    - {defaultSlots && h(defaultSlots)} -
    - ) -}, { props: ALL_BASE_PROPS }) +const Box: FunctionalComponent = (props, { slots }) => ( +
    + {slots.default?.()} +
    +) +Box.displayName = 'Box' export default Box \ No newline at end of file diff --git a/src/pages/components/Flex.tsx b/src/pages/components/Flex.tsx index 711c256fe..d19102124 100644 --- a/src/pages/components/Flex.tsx +++ b/src/pages/components/Flex.tsx @@ -1,5 +1,5 @@ -import { type CSSProperties, defineComponent, h, useSlots } from "vue" -import { ALL_BASE_PROPS, type BaseProps, cvt2BaseStyle, cvtPxScale } from "./common" +import type { CSSProperties, FunctionalComponent } from "vue" +import { type BaseProps, cvt2BaseStyle, cvtPxScale } from "./common" const cvtFlexWrap = (wrap: boolean | CSSProperties['flexWrap']): CSSProperties['flexWrap'] => { if (typeof wrap === 'string') return wrap @@ -22,35 +22,33 @@ type Props = { target?: HTMLAnchorElement['target'] } & BaseProps -const Flex = defineComponent(props => { +const Flex: FunctionalComponent = (props, { slots }) => { const Comp = props.as ?? 'div' - return () => { - const { default: defaultSlots } = useSlots() - return ( - - {defaultSlots && h(defaultSlots)} - - ) - } -}, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'rowGap', 'wrap', 'as'] }) + return ( + + {slots.default?.()} + + ) +} +Flex.displayName = 'Flex' export default Flex \ No newline at end of file diff --git a/src/pages/components/Grid.tsx b/src/pages/components/Grid.tsx index 8a7af6768..af3ce655e 100644 --- a/src/pages/components/Grid.tsx +++ b/src/pages/components/Grid.tsx @@ -1,29 +1,104 @@ -import { CSSProperties, defineComponent, h, useSlots } from 'vue' +import { computed, defineComponent, nextTick, onMounted, onUnmounted, ref, toRef, useSlots } from 'vue' import { ALL_BASE_PROPS, cvt2BaseStyle, cvtPxScale, type BaseProps } from './common' +function useGridPlaceholder() { + const containerRef = ref() + const columnCount = ref(0) + const childCount = ref(0) + + const measure = () => { + if (!containerRef.value) return + + const columns = getComputedStyle(containerRef.value) + .gridTemplateColumns + .split(' ') + .filter(Boolean).length + columnCount.value = columns + + childCount.value = containerRef.value + .querySelectorAll(':scope > *:not([data-grid-placeholder])') + .length + } + + let observer: ResizeObserver | null = null + let mutationObserver: MutationObserver | null = null + + onMounted(() => { + nextTick(measure) + + observer = new ResizeObserver(measure) + if (containerRef.value) observer.observe(containerRef.value) + + mutationObserver = new MutationObserver(measure) + if (containerRef.value) mutationObserver.observe(containerRef.value, { childList: true }) + }) + + onUnmounted(() => { + observer?.disconnect() + mutationObserver?.disconnect() + }) + + const placeholderCount = computed(() => { + if (!columnCount.value || !childCount.value) return 0 + const remainder = childCount.value % columnCount.value + return remainder === 0 ? 0 : columnCount.value - remainder + }) + + return { + containerRef, + columnCount, + placeholderCount, + } +} + +type Gap = number | string + type Props = { - gap?: string | number - templateColumns?: CSSProperties['gridTemplateColumns'] + columnGap?: Gap | [Gap, Gap] + rowGap?: number | string + minColumnWidth?: number | string + maxColumnWidth?: number | string } & BaseProps +const calcGridGap = (val: Gap | [Gap, Gap] | undefined): string | undefined => { + if (!val) return undefined + return Array.isArray(val) + ? `clamp(${cvtPxScale(val[0])}, 2vw, ${cvtPxScale(val[1])})` + : cvtPxScale(val) +} + const Grid = defineComponent(props => { const { default: defaultSlots } = useSlots() + const minColumnWidth = toRef(props, 'minColumnWidth', 200) + const maxColumnWidth = toRef(props, 'maxColumnWidth', '1fr') + const columnGap = computed(() => calcGridGap(props.columnGap)) + const rowGap = toRef(props, 'rowGap', 16) + + const { containerRef, placeholderCount } = useGridPlaceholder() return () => (
    - {defaultSlots && h(defaultSlots)} + {defaultSlots?.()} + {Array.from({ length: placeholderCount.value }, (_, idx) => ( +
    +
    {list.map(url => ( diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts index 56d54ba2e..d6f20d95c 100644 --- a/types/tt4b.d.ts +++ b/types/tt4b.d.ts @@ -64,13 +64,13 @@ declare namespace tt4b { */ cid?: string backup?: { - [key in tt4b.backup.Type]?: { + [key in backup.Type]?: { ts: number msg?: string } } notification?: { - [key in tt4b.notification.Method]?: { + [key in notification.Method]?: { ts: number endDate: string msg?: string @@ -79,7 +79,7 @@ declare namespace tt4b { /** * Two-factor auth */ - twoFa?: tt4b.TwoFactorAuth + twoFa?: TwoFactorAuth } type TwoFactorAuth = { @@ -181,7 +181,7 @@ declare namespace tt4b { namespace site { type SiteKey = { host: string - type: tt4b.site.Type + type: Type } type SiteInfo = SiteKey & { alias?: string @@ -213,7 +213,7 @@ declare namespace tt4b { type Query = { fuzzyQuery?: string cateIds?: Arrayable - types?: Arrayable + types?: Arrayable } type PageQuery = Query & common.PageQuery @@ -383,7 +383,7 @@ declare namespace tt4b { * @since 3.1.0 */ type ReminderInfo = { - items: tt4b.limit.Item[] + items: Item[] // Minutes duration: number } @@ -402,7 +402,7 @@ declare namespace tt4b { type Summary = { url: string site: site.SiteInfo - items: tt4b.limit.Item[] + items: Item[] } } @@ -509,38 +509,38 @@ declare namespace tt4b { } /** - * tt4b.backup.Coordinator of data synchronizer + * backup.Coordinator of data synchronizer */ interface Coordinator { /** * Register for client */ - updateClients(context: tt4b.backup.CoordinatorContext, clients: Client[]): Promise + updateClients(context: CoordinatorContext, clients: Client[]): Promise /** * List all clients */ - listAllClients(context: tt4b.backup.CoordinatorContext): Promise + listAllClients(context: CoordinatorContext): Promise /** * Download fragmented data from cloud * * @param targetCid The client id, default value is the local one in context */ - download(context: tt4b.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise + download(context: CoordinatorContext, start: string, end: string, targetCid?: string): Promise /** * Upload fragmented data to cloud * @param rows */ - upload(context: tt4b.backup.CoordinatorContext, rows: tt4b.core.Row[]): Promise + upload(context: CoordinatorContext, rows: core.Row[]): Promise /** * Test auth * * @returns errorMsg or null/undefined */ - testAuth(auth: Auth, ext: tt4b.backup.TypeExt): Promise + testAuth(auth: Auth, ext: TypeExt): Promise /** * Clear data */ - clear(context: tt4b.backup.CoordinatorContext, client: tt4b.backup.Client): Promise + clear(context: CoordinatorContext, client: Client): Promise } type Type = @@ -549,7 +549,7 @@ declare namespace tt4b { // Sync into Obsidian via its plugin Local REST API // @since 1.9.4 | 'obsidian_local_rest_api' - // @since 2 .4.5 + // @since 2.4.5 | 'web_dav' type AuthType = @@ -599,11 +599,11 @@ declare namespace tt4b { type ExportData = { __meta__: ExportMeta - __stat__?: tt4b.core.Row[] - __limit__?: tt4b.limit.Rule[] - __merge__?: tt4b.merge.Rule[] + __stat__?: core.Row[] + __limit__?: limit.Rule[] + __merge__?: merge.Rule[] __whitelist__?: string[] - __cate__?: tt4b.site.Cate[] + __cate__?: site.Cate[] } } @@ -613,13 +613,13 @@ declare namespace tt4b { namespace imported { type ConflictResolution = 'overwrite' | 'accumulate' - type Row = Required & tt4b.core.Result & { - exist?: tt4b.core.Result + type Row = Required & core.Result & { + exist?: core.Result } type Data = { // Whether there is data for this dimension - [dimension in tt4b.core.Dimension]?: boolean + [dimension in core.Dimension]?: boolean } & { rows: Row[] } @@ -632,7 +632,7 @@ declare namespace tt4b { namespace stat { type SiteTarget = { - siteKey: tt4b.site.SiteKey + siteKey: site.SiteKey } type CateTarget = { cateKey: number @@ -673,7 +673,7 @@ declare namespace tt4b { timeRange?: [number, number?] virtual?: boolean } - & tt4b.common.SortBy<'date' | 'host' | tt4b.core.Dimension> + & common.SortBy<'date' | 'host' | core.Dimension> & { query?: string host?: string | string[] @@ -691,7 +691,7 @@ declare namespace tt4b { date?: [start?: string, end?: string] | string } - type SitePageQuery = SiteQuery & tt4b.common.PageQuery + type SitePageQuery = SiteQuery & common.PageQuery type SiteRowFlat = SiteTarget & DateKey & @@ -726,14 +726,14 @@ declare namespace tt4b { type SiteRow = SiteRowFlat & SiteMergeExtend type CateQuery = _BaseQuery - & tt4b.common.SortBy<'date' | 'focus' | 'time'> + & common.SortBy<'date' | 'focus' | 'time'> & { query?: string inclusiveRemote?: boolean cateIds?: number[] } - type CatePageQuery = CateQuery & tt4b.common.PageQuery + type CatePageQuery = CateQuery & common.PageQuery type CateRowFlat = CateTarget & DateKey & @@ -763,13 +763,13 @@ declare namespace tt4b { focusRange?: Vector<2> timeRange?: [number, number?] } - & tt4b.common.SortBy<'date' | 'title' | 'focus' | 'time'> + & common.SortBy<'date' | 'title' | 'focus' | 'time'> & { query?: string groupIds?: number[] } - type GroupPageQuery = GroupQuery & tt4b.common.PageQuery + type GroupPageQuery = GroupQuery & common.PageQuery type GroupMergeExtend = { mergedRows?: GroupRowFlat[] @@ -884,7 +884,7 @@ declare namespace tt4b { chartAnimationDuration: number } - type AppearanceRequired = MakeRequired + type AppearanceRequired = MakeRequired type TrackingOption = { /** @@ -921,7 +921,7 @@ declare namespace tt4b { storage: StorageType } - type TrackingRequired = MakeRequired + type TrackingRequired = MakeRequired type LimitOption = { /** @@ -958,7 +958,7 @@ declare namespace tt4b { limitReminderDuration?: number } - type LimitRequired = MakeRequired + type LimitRequired = MakeRequired /** * The options of backup @@ -1009,7 +1009,7 @@ declare namespace tt4b { /** * Notification cycle: none, daily, or weekly */ - notificationCycle: tt4b.notification.Cycle + notificationCycle: notification.Cycle /** * Offset time in minutes relative to the start of the cycle */ @@ -1017,7 +1017,7 @@ declare namespace tt4b { /** * Notification method: browser or callback */ - notificationMethod: tt4b.notification.Method + notificationMethod: notification.Method /** * HTTP callback endpoint URL */ @@ -1030,8 +1030,7 @@ declare namespace tt4b { export type DefaultOption = & AppearanceRequired & TrackingRequired & LimitRequired - & tt4b.option.BackupOption & tt4b.option.AccessibilityOption - & tt4b.option.NotificationOption + & BackupOption & AccessibilityOption & NotificationOption type AllOption = & AppearanceOption From 3d488697c71b436aa9a6afc515a282a2164aa9d4 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 8 Jun 2026 00:18:10 +0800 Subject: [PATCH 21/95] refactor(ts): add alias for background --- script/psl.ts | 2 +- src/background/service/components/host-merge-ruler.ts | 2 +- test/background/service/limit-service.test.ts | 2 +- tsconfig.json | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/script/psl.ts b/script/psl.ts index fc33d526f..9e9a96e32 100644 --- a/script/psl.ts +++ b/script/psl.ts @@ -1,8 +1,8 @@ /** * Build psl tree */ -import { type PslTree } from '@/background/psl' import { fetchGet } from '@api/http' +import { type PslTree } from '@bg/psl' import { writeFileSync } from 'fs' import path from 'path' diff --git a/src/background/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts index 1c527b775..96d2e94f2 100644 --- a/src/background/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getPsl } from '@/background/psl' +import { getPsl } from '@bg/psl' import FIFOCache from '@util/fifo-cache' import { isIpAndPort, judgeVirtualFast } from "@util/pattern" diff --git a/test/background/service/limit-service.test.ts b/test/background/service/limit-service.test.ts index d7cb49a0c..68a9aaa21 100644 --- a/test/background/service/limit-service.test.ts +++ b/test/background/service/limit-service.test.ts @@ -13,7 +13,7 @@ beforeAll(async () => { getManifest: () => ({ manifest_version: 3 }), }, }) - const mod = await import("@/background/service/limit-service") + const mod = await import("@bg/service/limit-service") calcTimeState = mod.calcTimeState }) diff --git a/tsconfig.json b/tsconfig.json index f2323fc2d..e29660786 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,9 @@ "@service/*": [ "./src/background/service/*" ], + "@bg/*": [ + "./src/background/*" + ], "@util/*": [ "./src/util/*" ], From 65dab39f1cc581dbc4105e67082c643b7c1dab47 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 8 Jun 2026 16:14:43 +0800 Subject: [PATCH 22/95] chore: add watermark --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index acdd1f9dd..7e740cdbc 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,5 @@ It's simple and much helpful! ## Thanks Timer (relaunch) - Timer is one browser extension to stat site visits and time. | Product Hunt + + From 441eeeb6d17912350785fc323a5d74b76138131c Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 11 Jun 2026 00:20:50 +0800 Subject: [PATCH 23/95] fix: mobile issues (#795) --- package.json | 12 +-- src/pages/app/Layout/menu/item.ts | 1 - .../app/components/Record/List/index.tsx | 22 +++--- src/pages/hooks/useScrollRequest.ts | 78 +++++++------------ 4 files changed, 46 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index 1d72adbdc..3abe58892 100644 --- a/package.json +++ b/package.json @@ -29,17 +29,17 @@ "license": "MIT", "devDependencies": { "@commitlint/types": "^21.0.1", - "@crowdin/crowdin-api-client": "^1.55.3", + "@crowdin/crowdin-api-client": "^1.55.4", "@emotion/babel-plugin": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.12", - "@rspack/cli": "^2.0.5", - "@rspack/core": "^2.0.5", + "@rspack/cli": "^2.0.7", + "@rspack/core": "^2.0.7", "@rstest/core": "^0.10.3", "@rstest/coverage-istanbul": "^0.10.3", - "@types/chrome": "0.1.42", + "@types/chrome": "0.1.43", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.9.1", + "@types/node": "^25.9.2", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", "commitlint": "^21.0.2", @@ -50,7 +50,7 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.15.0", + "knip": "^6.16.1", "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index bf1020f52..c225b2f9f 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -88,7 +88,6 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.rule, route: RULE_ROUTE, icon: Rule, - mobile: false, }, { title: msg => msg.base.option, route: '/additional/option', diff --git a/src/pages/app/components/Record/List/index.tsx b/src/pages/app/components/Record/List/index.tsx index 399db1a33..5f1a37652 100644 --- a/src/pages/app/components/Record/List/index.tsx +++ b/src/pages/app/components/Record/List/index.tsx @@ -1,8 +1,9 @@ import { t } from '@app/locale' +import { Pointer } from '@element-plus/icons-vue' import { css } from '@emotion/css' import { useScrollRequest } from '@hooks' import { getHost } from "@util/stat" -import { ElCard, useNamespace } from "element-plus" +import { ElButton, ElCard, ElScrollbar, useNamespace } from "element-plus" import { defineComponent, ref } from "vue" import { queryPage } from "../common" import { useRecordFilter } from "../context" @@ -34,14 +35,14 @@ const useStyle = () => { const _default = defineComponent<{}>((_, ctx) => { const filterOption = useRecordFilter() - const { data, loading, loadMoreAsync, end, reset } = useScrollRequest(async (num, size) => { + const { data, loading, loadMore, end, reset } = useScrollRequest(async (num, size) => { const pagination = await queryPage( filterOption, { order: "descending", prop: "focus" }, { num, size }, ) return pagination.list - }, { manual: true, resetDeps: () => ({ ...filterOption }) }) + }, { resetDeps: () => ({ ...filterOption }) }) const selected = ref([]) @@ -59,11 +60,7 @@ const _default = defineComponent<{}>((_, ctx) => { return () => (
    -
    + {data.value.map((row, idx) => ( ((_, ctx) => { /> ))} -
    -

    - {end.value ? t(msg => msg.record.noMore) : (loading.value ? 'Loading ...' : 'Load More')} -

    + {end.value + ?

    {t(msg => msg.record.noMore)}

    + : More + } +
    ) }) diff --git a/src/pages/hooks/useScrollRequest.ts b/src/pages/hooks/useScrollRequest.ts index bf1cf4a96..d0bec63ac 100644 --- a/src/pages/hooks/useScrollRequest.ts +++ b/src/pages/hooks/useScrollRequest.ts @@ -1,8 +1,7 @@ -import { onBeforeMount, ref, type Ref, watch, type WatchSource } from "vue" +import { ref, type Ref, watch, type WatchSource } from "vue" +import { useRequest } from './useRequest' -type Option = { - manual?: boolean - defaultValue?: T[] +type Option = { pageSize?: number resetDeps?: WatchSource | WatchSource[] } @@ -11,59 +10,42 @@ type Result = { data: Ref end: Ref loading: Ref - loadMore: () => void - loadMoreAsync: () => Promise - reset: () => void + loadMore: () => Promise + reset: NoArgCallback } -export const useScrollRequest = (getter: (pageNo: number, pageSize: number) => Promise, option?: Option): Result => { - const { - defaultValue, - manual, - pageSize: outerPageSize, - resetDeps, - } = option || {} - const data = ref(defaultValue ?? []) as Ref +export const useScrollRequest = (getter: (pageNo: number, pageSize: number) => Promise, option?: Option): Result => { + const { pageSize = 10, resetDeps } = option ?? {} const end = ref(false) - const loading = ref(false) - const pageNo = ref(0) - const pageSize = outerPageSize || 10 - - const loadMoreAsync = async () => { - if (end.value) return - try { - loading.value = true - const no = pageNo.value = (pageNo.value + 1) - const newData = await getter?.(no, pageSize) || [] - data.value = [...(data.value || []), ...(newData || [])] - const newLen = newData?.length ?? 0 - if (!newLen || newLen < pageSize) { - end.value = true - } - } finally { - loading.value = false - } + const queriedPage = ref(0) + const data: Ref = ref([]) + + const { refreshAsync, loading } = useRequest( + (pageNo: number) => getter(pageNo, pageSize), + { + defaultParam: [1], + onSuccess: (list, pageNo) => { + list.length && (data.value = [...data.value, ...list]) + end.value = list.length < pageSize + list.length && (queriedPage.value = pageNo) + }, + }, + ) + + const loadMore = async () => { + if (loading.value || end.value) return + return refreshAsync(queriedPage.value + 1) } const reset = async () => { + if (loading.value) return end.value = false - pageNo.value = 0 data.value = [] - await loadMoreAsync() + queriedPage.value = 0 + await refreshAsync(1) } - !manual && onBeforeMount(loadMoreAsync) - - if (resetDeps && (!Array.isArray(resetDeps) || resetDeps?.length)) { - watch(resetDeps, reset) - } + resetDeps && watch(resetDeps, reset) - return { - data, - end, - loading, - loadMore: () => loadMoreAsync(), - loadMoreAsync, - reset, - } + return { data, end, loading, loadMore, reset } } \ No newline at end of file From 846f13aa9c76ae03b0fb784dd60935fc6a68145f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 12 Jun 2026 00:13:10 +0800 Subject: [PATCH 24/95] refactor: e2e testing --- package.json | 10 +-- rspack/rspack.e2e.ts | 4 + test-e2e/backup/gist.test.ts | 10 +-- test-e2e/common/base.ts | 74 +++++++++++------ test-e2e/common/whitelist-skip.test.ts | 5 +- ...daily-time.test.ts => daily-limit.test.ts} | 79 +++++++++++++++++-- test-e2e/limit/daily-visit.test.ts | 78 ------------------ test-e2e/limit/delay-duration.test.ts | 8 +- test-e2e/limit/visit-limit.test.ts | 8 +- test-e2e/tracker/base.test.ts | 8 +- test-e2e/tracker/run-time.test.ts | 32 ++++---- 11 files changed, 153 insertions(+), 163 deletions(-) rename test-e2e/limit/{daily-time.test.ts => daily-limit.test.ts} (54%) delete mode 100644 test-e2e/limit/daily-visit.test.ts diff --git a/package.json b/package.json index 3abe58892..1a86d99ca 100644 --- a/package.json +++ b/package.json @@ -31,15 +31,15 @@ "@commitlint/types": "^21.0.1", "@crowdin/crowdin-api-client": "^1.55.4", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.12", - "@rspack/cli": "^2.0.7", - "@rspack/core": "^2.0.7", + "@rsdoctor/rspack-plugin": "^1.5.13", + "@rspack/cli": "^2.0.8", + "@rspack/core": "^2.0.8", "@rstest/core": "^0.10.3", "@rstest/coverage-istanbul": "^0.10.3", "@types/chrome": "0.1.43", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", "commitlint": "^21.0.2", @@ -68,7 +68,7 @@ "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", "typescript-guard": "0.2.6", - "vue": "^3.5.35", + "vue": "^3.5.38", "vue-router": "^5.1.0" }, "engines": { diff --git a/rspack/rspack.e2e.ts b/rspack/rspack.e2e.ts index 633fbfa1d..632cf4795 100644 --- a/rspack/rspack.e2e.ts +++ b/rspack/rspack.e2e.ts @@ -4,6 +4,10 @@ import { E2E_OUTPUT_PATH } from "./constant" import generateOption from "./rspack.common" manifest.name = E2E_NAME +// Grant all permissions as required for e2e testing +const permissions = manifest.permissions ??= [] +permissions.push(...(manifest.optional_permissions ?? [])) +manifest.optional_permissions = [] const options = generateOption({ outputPath: E2E_OUTPUT_PATH, diff --git a/test-e2e/backup/gist.test.ts b/test-e2e/backup/gist.test.ts index 43c1741f9..42a451f5f 100644 --- a/test-e2e/backup/gist.test.ts +++ b/test-e2e/backup/gist.test.ts @@ -1,4 +1,4 @@ -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { readRecordsOfFirstPage } from '../common/record' import { MOCK_URL, sleep } from '../common/util' import { BackupOptionWrapper } from './common' @@ -6,16 +6,10 @@ import { BackupOptionWrapper } from './common' const GIST_MOCK_ORIGIN = 'http://127.0.0.1:12347' const GIST_MOCK_TOKEN = 'github_gist_mock_token' -let context: LaunchContext - const describeOptional = process.env.GITHUB_ACTIONS ? describe.skip : describe describeOptional('Backup with gist', () => { - beforeEach(async () => { - context = await launchBrowser({ bgProxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) - }) - - afterEach(() => context.close()) + const context = useLaunchContext({ bgProxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) test('create and update gist', async () => { // Fill in gist parameters diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index 04ac99e12..2bc5fa04f 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -41,35 +41,38 @@ async function setupBgProxies(serviceWorker: Target, proxies: HostProxy[]): Prom return session } -export interface LaunchContext { - browser: Browser - extensionId: string - - close(): Promise - - openAppPage(route: string): Promise - - newPage(url?: string): Promise - - newPageAndWaitCsInjected(url: string): Promise +async function waitForNetworkIdleShortly(page: Page): Promise { + await page.waitForNetworkIdle({ idleTime: 20 }) } -class LaunchContextWrapper implements LaunchContext { +export class LaunchContext { + #b: Browser | null = null + #eid: string | null = null + #cdp: CDPSession | null = null + + constructor(private readonly options?: BrowserOptions) { } - constructor( - readonly browser: Browser, readonly extensionId: string, - private cdpSession: CDPSession | undefined - ) { } + async launch(): Promise { + const { browser, extensionId, cdpSession } = await launchBrowser(this.options) + this.#b = browser + this.#cdp = cdpSession ?? null + this.#eid = extensionId + // remove whitelist added by service_worker + await removeAllWhitelist(this) + } async close(): Promise { - this.cdpSession?.detach().catch(() => { }) - await this.browser.close() + this.#cdp?.detach().catch(() => { }) + await this.#b?.close() + this.#b = null + this.#eid = null + this.#cdp = null } async openAppPage(route: string): Promise { const page = await this.browser.newPage() - await page.goto(`chrome-extension://${this.extensionId}/static/app.html#${route}`) - await page.waitForNetworkIdle() + await page.goto(`chrome-extension://${this.#eid}/static/app.html#${route}`) + await waitForNetworkIdleShortly(page) return page } @@ -82,9 +85,19 @@ class LaunchContextWrapper implements LaunchContext { async newPageAndWaitCsInjected(url: string): Promise { const page = await this.browser.newPage() await page.goto(url) - await page.waitForSelector(`#__TIMER_INJECTION_FLAG__${this.extensionId}`) + await page.waitForSelector(`#__TIMER_INJECTION_FLAG__${this.#eid}`) return page } + + get browser(): Browser { + if (!this.#b) throw new Error('Browser not initialized') + return this.#b + } + + get extensionId(): string { + if (!this.#eid) throw new Error('Extension id not detected') + return this.#eid + } } type BrowserOptions = { @@ -92,13 +105,20 @@ type BrowserOptions = { bgProxies?: HostProxy[] } -export async function launchBrowser(options?: BrowserOptions): Promise { +type LaunchResult = { + browser: Browser + extensionId: string + cdpSession?: CDPSession +} + +async function launchBrowser(options?: BrowserOptions): Promise { const { dirPath = E2E_OUTPUT_PATH, bgProxies } = options ?? {} const args = [ `--disable-extensions-except=${dirPath}`, `--load-extension=${dirPath}`, '--start-maximized', '--no-sandbox', + '--lang=en', ] // GitHub-hosted runners use a small /dev/shm; Chrome can crash or hang without this flag. if (process.env['GITHUB_ACTIONS'] === 'true') { @@ -119,10 +139,12 @@ export async function launchBrowser(options?: BrowserOptions): Promise context.launch()) + afterEach(() => context.close()) return context } \ No newline at end of file diff --git a/test-e2e/common/whitelist-skip.test.ts b/test-e2e/common/whitelist-skip.test.ts index 32852fadf..ea92ce978 100644 --- a/test-e2e/common/whitelist-skip.test.ts +++ b/test-e2e/common/whitelist-skip.test.ts @@ -1,8 +1,9 @@ -import { launchBrowser } from './base' +import { LaunchContext } from './base' import { createWhitelist } from './whitelist' // Run to test the function, but skip it in normal test runs test.skip('create whitelist', async () => { - const context = await launchBrowser() + const context = new LaunchContext() + await context.launch() await createWhitelist(context, 'example.com') }) \ No newline at end of file diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-limit.test.ts similarity index 54% rename from test-e2e/limit/daily-time.test.ts rename to test-e2e/limit/daily-limit.test.ts index 695af8be3..b0bbe0da5 100644 --- a/test-e2e/limit/daily-time.test.ts +++ b/test-e2e/limit/daily-limit.test.ts @@ -1,15 +1,11 @@ -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { MOCK_URL, sleep } from '../common/util' import { createLimitRule, fillTimeLimit, isLimitModalVisible, waitForLimitFrame } from "./common" -let context: LaunchContext +describe('Daily limit', () => { + const context = useLaunchContext() -describe('Daily time limit', () => { - beforeEach(async () => { context = await launchBrowser() }) - - afterEach(() => context.close()) - - test('basic', async () => { + test('basic time limit', async () => { const limitTime = 2 const limitPage = await context.openAppPage('/behavior/limit') const demoRule: tt4b.limit.Rule = { @@ -87,4 +83,71 @@ describe('Daily time limit', () => { const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() }, 60000) + + test("Daily visit limit", async () => { + const limitPage = await context.openAppPage('/behavior/limit') + const demoRule: tt4b.limit.Rule = { + id: 1, name: 'TEST DAILY VISIT LIMIT', + cond: [MOCK_URL], + time: 0, count: 1, + enabled: true, allowDelay: false, locked: false, + } + + // 1. Insert limit rule + await createLimitRule(demoRule, limitPage) + + // 2. Open test page + const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) + + // Assert not limited + await limitPage.bringToFront() + // Wait refreshing the table + await sleep(.1) + const infoTag = await limitPage.$$('.el-table .el-table__body-wrapper table tbody tr td:nth-child(6) .el-tag.el-tag--info') + expect(infoTag.length).toEqual(2) + + // 3. Reload page + await testPage.bringToFront() + await testPage.reload({ waitUntil: 'domcontentloaded' }) + + // Waiting for limit message handling + await sleep(2) + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + const { name, count } = await limitFrame.evaluate(() => { + const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') + const trs = descEl?.querySelectorAll('tr') + const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent + const count = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent + return { name, count } + }) + + expect(name).toBe(demoRule.name) + expect(count!.split?.(' ')[0]).toBe('2') + + // 4. Change visit limit + await limitPage.bringToFront() + await limitPage.click('.el-card__body .el-table tr td .el-button--primary') + + await sleep(.1) + await limitPage.click('.el-dialog .el-button.el-button--primary') + + await sleep(.1) + await limitPage.click('.el-dialog .el-button.el-button--primary') + + await sleep(.1) + const visitInput = await limitPage.$('.el-dialog .el-input-number input') + await visitInput!.focus() + await limitPage.keyboard.type('2') + await limitPage.click('.el-dialog .el-button.el-button--success') + + // 5. The modal disappear + await testPage.bringToFront() + await sleep(.5) + const modalExist = await isLimitModalVisible(testPage) + expect(modalExist).toBeFalsy() + }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/daily-visit.test.ts b/test-e2e/limit/daily-visit.test.ts deleted file mode 100644 index 3298c0298..000000000 --- a/test-e2e/limit/daily-visit.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { launchBrowser, type LaunchContext } from '../common/base' -import { MOCK_URL, sleep } from '../common/util' -import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from "./common" - -let context: LaunchContext - -describe('Daily time limit', () => { - beforeEach(async () => { context = await launchBrowser() }) - - afterEach(() => context.close()) - - test("Daily visit limit", async () => { - const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: tt4b.limit.Rule = { - id: 1, name: 'TEST DAILY LIMIT', - cond: [MOCK_URL], - time: 0, count: 1, - enabled: true, allowDelay: false, locked: false, - } - - // 1. Insert limit rule - await createLimitRule(demoRule, limitPage) - - // 2. Open test page - const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) - - // Assert not limited - await limitPage.bringToFront() - // Wait refreshing the table - await sleep(.1) - const infoTag = await limitPage.$$('.el-table .el-table__body-wrapper table tbody tr td:nth-child(6) .el-tag.el-tag--info') - expect(infoTag.length).toEqual(2) - - // 3. Reload page - await testPage.bringToFront() - await testPage.reload({ waitUntil: 'domcontentloaded' }) - - // Waiting for limit message handling - await sleep(2) - const limitFrame = await waitForLimitFrame(testPage) - await limitFrame.waitForFunction(() => { - const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') - return td?.textContent && td.textContent !== '-' - }, { timeout: 5000 }) - const { name, count } = await limitFrame.evaluate(() => { - const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') - const trs = descEl?.querySelectorAll('tr') - const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent - const count = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent - return { name, count } - }) - - expect(name).toBe(demoRule.name) - expect(count!.split?.(' ')[0]).toBe('2') - - // 4. Change visit limit - await limitPage.bringToFront() - await limitPage.click('.el-card__body .el-table tr td .el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - await limitPage.click('.el-dialog .el-button.el-button--primary') - - await sleep(.1) - const visitInput = await limitPage.$('.el-dialog .el-input-number input') - await visitInput!.focus() - await limitPage.keyboard.type('2') - await limitPage.click('.el-dialog .el-button.el-button--success') - - // 5. The modal disappear - await testPage.bringToFront() - await sleep(.5) - const modalExist = await isLimitModalVisible(testPage) - expect(modalExist).toBeFalsy() - }, 60000) -}) \ No newline at end of file diff --git a/test-e2e/limit/delay-duration.test.ts b/test-e2e/limit/delay-duration.test.ts index c7822d4e3..765d3de0c 100644 --- a/test-e2e/limit/delay-duration.test.ts +++ b/test-e2e/limit/delay-duration.test.ts @@ -1,6 +1,6 @@ import { formatTimeYMD, MILL_PER_SECOND } from '@util/time' import type { Page } from 'puppeteer' -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { MOCK_URL, sleep } from '../common/util' import { clickDelay, createLimitRule, isLimitModalVisible } from './common' @@ -59,11 +59,7 @@ const DEMO_RULE = { } as const satisfies tt4b.limit.Rule describe('Limit delay duration', () => { - let context: LaunchContext - - beforeEach(async () => { context = await launchBrowser() }) - - // afterEach(() => context.close()) + const context = useLaunchContext() test('Delay with customized duration', async () => { const optionPage = await context.openAppPage('/additional/option?i=limit') diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts index 8871f9336..c5e586661 100644 --- a/test-e2e/limit/visit-limit.test.ts +++ b/test-e2e/limit/visit-limit.test.ts @@ -1,13 +1,9 @@ -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { MOCK_URL, sleep } from '../common/util' import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from './common' describe('Time limit per visit', () => { - let context: LaunchContext - - beforeEach(async () => { context = await launchBrowser() }) - - afterEach(() => context.close()) + const context = useLaunchContext() test("Delay", async () => { const limitPage = await context.openAppPage('/behavior/limit') diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index 2be47352e..9fc983597 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -1,14 +1,10 @@ -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { readRecordsOfFirstPage } from "../common/record" import { MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from '../common/util' import { createWhitelist } from "../common/whitelist" -let context: LaunchContext - describe('Tracking', () => { - beforeEach(async () => { context = await launchBrowser() }) - - afterEach(() => context.close()) + const context = useLaunchContext() test('basic tracking', async () => { const page = await context.newPageAndWaitCsInjected(MOCK_URL) diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index 1f219cac1..2f4633d26 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -1,26 +1,22 @@ -import { launchBrowser, type LaunchContext } from '../common/base' +import { useLaunchContext } from '../common/base' import { parseTime2Sec, readRecordsOfFirstPage } from "../common/record" import { MOCK_HOST, MOCK_URL, sleep } from '../common/util' import { createWhitelist } from "../common/whitelist" -let context: LaunchContext - -async function clickRunTimeChange(siteHost: string): Promise { - const sitePage = await context.openAppPage("/additional/site-manage") - await sitePage.focus('input[placeholder]') - await sitePage.keyboard.type(siteHost) - await sitePage.keyboard.press('Enter') - await sleep(.1) - await sitePage.evaluate(() => { - const runTimeSwitch = document.querySelector('table > tbody > tr > td.el-table_1_column_7 .el-switch') - runTimeSwitch?.click() - }) -} - describe('Run time tracking', () => { - beforeEach(async () => { context = await launchBrowser() }) - - afterEach(() => context.close()) + const context = useLaunchContext() + + async function clickRunTimeChange(siteHost: string): Promise { + const sitePage = await context.openAppPage("/additional/site-manage") + await sitePage.focus('input[placeholder]') + await sitePage.keyboard.type(siteHost) + await sitePage.keyboard.press('Enter') + await sleep(.1) + await sitePage.evaluate(() => { + const runTimeSwitch = document.querySelector('table > tbody > tr > td.el-table_1_column_7 .el-switch') + runTimeSwitch?.click() + }) + } test('Basically track', async () => { await context.newPageAndWaitCsInjected(MOCK_URL) From cbb710ec2e62b7b2355e1eef5dff7bb561725b2f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 12 Jun 2026 01:10:41 +0800 Subject: [PATCH 25/95] refactor: clean code --- script/setup-e2e.sh | 2 +- .../database/common/indexed-storage.ts | 4 +- src/background/i18n.ts | 9 ++++ .../service/notification/browser/notifier.ts | 17 ++----- src/i18n/message/app/limit-resource.json | 3 +- src/i18n/message/app/limit.ts | 1 + src/i18n/message/bg/index.ts | 18 +++++++ .../message/bg/notification-resource.json | 5 ++ src/i18n/message/bg/notification.ts | 7 +++ src/i18n/message/common/shared-resource.json | 6 --- src/i18n/message/common/shared.ts | 1 - src/i18n/message/popup/content.ts | 2 +- .../components/Modify/Step3/PeriodInput.tsx | 6 +-- .../Limit/components/Modify/Step3/index.tsx | 18 +++++-- src/pages/app/components/Limit/context.ts | 5 +- src/pages/app/components/Option/Tabs.tsx | 4 +- .../components/Option/components/index.tsx | 6 +-- .../Record/Table/columns/DateColumn.tsx | 26 ---------- .../app/components/Record/Table/index.tsx | 17 +++++-- .../Modify/Step3 => components}/TimeInput.tsx | 50 +++++++++++-------- src/pages/popup/components/Footer/Menu.tsx | 6 +-- src/pages/popup/context.ts | 10 ++-- src/pages/popup/router.ts | 11 ++-- src/pages/popup/types.d.ts | 2 - src/util/limit.ts | 4 +- src/util/site.ts | 2 +- types/tt4b.d.ts | 2 + 27 files changed, 135 insertions(+), 109 deletions(-) create mode 100644 src/background/i18n.ts create mode 100644 src/i18n/message/bg/index.ts create mode 100644 src/i18n/message/bg/notification-resource.json create mode 100644 src/i18n/message/bg/notification.ts delete mode 100644 src/pages/app/components/Record/Table/columns/DateColumn.tsx rename src/pages/{app/components/Limit/components/Modify/Step3 => components}/TimeInput.tsx (78%) diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index 09b1da86e..519972bca 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -126,7 +126,7 @@ install_e2e_dependencies() { if node "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" &> /dev/null; then log_success "Browser downloaded successfully" else - log_error "Failed to download browser for puppeteer" + log_error "Failed to download browser for puppeteer. Retry manually: npx puppeteer browsers install" exit 1 fi else diff --git a/src/background/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts index 665fb1364..fdc2148c6 100644 --- a/src/background/database/common/indexed-storage.ts +++ b/src/background/database/common/indexed-storage.ts @@ -144,13 +144,11 @@ export abstract class BaseIDBStorage> { async #doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB - const checkDb = await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const checkRequest = factory.open(this.#DB_NAME) checkRequest.onsuccess = () => resolve(checkRequest.result) checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) }) - - return checkDb } // Only used for testing, be careful when using in production diff --git a/src/background/i18n.ts b/src/background/i18n.ts new file mode 100644 index 000000000..3d972d8d4 --- /dev/null +++ b/src/background/i18n.ts @@ -0,0 +1,9 @@ +import { t as _t, type I18nKey as _I18nKey } from "@i18n" +import messages, { type BgMessage } from "@i18n/message/bg" + +export type I18nKey = _I18nKey + +export function t(key: I18nKey, param?: any) { + const props = { key, param } + return _t(messages, props) +} \ No newline at end of file diff --git a/src/background/service/notification/browser/notifier.ts b/src/background/service/notification/browser/notifier.ts index bc9902c7d..be31a71b0 100644 --- a/src/background/service/notification/browser/notifier.ts +++ b/src/background/service/notification/browser/notifier.ts @@ -1,8 +1,6 @@ import { createNotification } from "@api/chrome/notifications" import { hasPerm, requestPerm } from "@api/chrome/permission" -import { t } from '@i18n' -import calendarMessages from "@i18n/message/common/calendar" -import metaMessages from "@i18n/message/common/meta" +import { t } from '@bg/i18n' import { formatPeriodCommon } from '@util/time' import type { NotificationData, NotificationRequest, Notifier } from '../types' @@ -37,18 +35,13 @@ export default class BrowserNotifier implements Notifier { const errMsg = await this.assertPerm() if (errMsg) return errMsg - const { - cycle, - meta: { locale }, - summary: { focus, visit, siteCount }, - } = data + const { cycle, summary: { focus, visit, siteCount } } = data - const appName = t(metaMessages, { key: msg => msg.name }, locale) - const calendar = t(calendarMessages, { key: cycle === 'daily' ? msg => msg.range.yesterday : msg => msg.range.lastWeek }, locale) + const appName = t(msg => msg.meta.name) + const calendar = t(msg => msg.calendar.range[cycle === 'daily' ? 'yesterday' : 'lastWeek']) const title = `${appName} - ${calendar}` const focusStr = formatPeriodCommon(focus, true) - - const message = `Focus time: ${focusStr}, Visits: ${visit}, Sites: ${siteCount}` + const message = t(msg => msg.notification.dailySummary, { focus: focusStr, visit, siteCount }) await createNotification('time', { type: 'basic', title, message }) } diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index b768999aa..6f630036a 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -112,7 +112,8 @@ "effectiveDay": "Effective On", "allowDelay": "Allow Delay", "or": "or", - "notEffective": "Not effective" + "notEffective": "Not effective", + "unlimited": "Unlimited" }, "step": { "base": "Basic Information", diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index 92b06fcbd..e2593de18 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -29,6 +29,7 @@ export type LimitMessage = { detail: string or: string notEffective: string + unlimited: string } button: { test: string diff --git a/src/i18n/message/bg/index.ts b/src/i18n/message/bg/index.ts new file mode 100644 index 000000000..acb729e5a --- /dev/null +++ b/src/i18n/message/bg/index.ts @@ -0,0 +1,18 @@ +import calendarMessages, { type CalendarMessage } from '../common/calendar' +import metaMessages, { type MetaMessage } from '../common/meta' +import { merge, MessageRoot } from '../merge' +import notificationMessages, { type NotificationMessage } from './notification' + +export type BgMessage = { + meta: MetaMessage + calendar: CalendarMessage + notification: NotificationMessage +} + +const CHILD_MESSAGES: MessageRoot = { + meta: metaMessages, + calendar: calendarMessages, + notification: notificationMessages, +} + +export default merge(CHILD_MESSAGES) \ No newline at end of file diff --git a/src/i18n/message/bg/notification-resource.json b/src/i18n/message/bg/notification-resource.json new file mode 100644 index 000000000..d302817f6 --- /dev/null +++ b/src/i18n/message/bg/notification-resource.json @@ -0,0 +1,5 @@ +{ + "en": { + "dailySummary": "Focus time: {focus}, Visits: {visit}, Sites: {siteCount}" + } +} \ No newline at end of file diff --git a/src/i18n/message/bg/notification.ts b/src/i18n/message/bg/notification.ts new file mode 100644 index 000000000..edb0e44a3 --- /dev/null +++ b/src/i18n/message/bg/notification.ts @@ -0,0 +1,7 @@ +import resources from "./notification-resource.json" + +export type NotificationMessage = { + dailySummary: string +} + +export default resources satisfies Messages \ No newline at end of file diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index cfee40f22..d930cbf0d 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -14,7 +14,6 @@ "notSet": "Not Set" }, "limit": { - "limited": "Limited", "daily": "Daily limit", "weekly": "Weekly limit", "period": "Blocked periods", @@ -36,7 +35,6 @@ "notSet": "未分类" }, "limit": { - "limited": "已受限", "daily": "每日上限", "weekly": "每周上限", "period": "不可访问的时间段", @@ -58,7 +56,6 @@ "notSet": "未設定" }, "limit": { - "limited": "制限あり", "daily": "1日の限度", "weekly": "週間の限度", "period": "許可されない期間", @@ -143,7 +140,6 @@ "notSet": "Non impostato" }, "limit": { - "limited": "Limitata", "daily": "Limite giornaliero", "weekly": "Limite settimanale", "period": "Periodi bloccati", @@ -186,7 +182,6 @@ "notSet": "Nicht festgelegt" }, "limit": { - "limited": "Begrenzt", "daily": "Tägliches Limit", "weekly": "Wöchentliches Limit", "period": "Unzulässiger Zeitraum", @@ -208,7 +203,6 @@ "notSet": "Non Défini" }, "limit": { - "limited": "Limité", "daily": "Limite quotidienne", "weekly": "Limite hebdomadaire", "period": "Périodes bloquées", diff --git a/src/i18n/message/common/shared.ts b/src/i18n/message/common/shared.ts index 2d19e3169..41ea1c830 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -9,7 +9,6 @@ export type SharedMessage = { notSet: string } limit: { - limited: string daily: string weekly: string period: string diff --git a/src/i18n/message/popup/content.ts b/src/i18n/message/popup/content.ts index 1e1fa85e8..c5e5d976c 100644 --- a/src/i18n/message/popup/content.ts +++ b/src/i18n/message/popup/content.ts @@ -37,6 +37,6 @@ export type ContentMessage = { } } -const contentMessages = resource as Messages +const contentMessages = resource satisfies Messages export default contentMessages \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 6c409502f..a287bb867 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -87,11 +87,11 @@ const usePickerStyle = () => { return css` width: 120px !important; padding: 0 5px !important; - border-top-right-radius: 0px; - border-bottom-right-radius: 0px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; & .${rangeNs.e('close-icon')} { - width: 0px; + width: 0; } & .${rangeNs.b('input')} { diff --git a/src/pages/app/components/Limit/components/Modify/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx index b288cea1c..38dd6d890 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx @@ -10,12 +10,13 @@ import type { ModifyForm } from '@app/components/Limit/types' import { t } from '@app/locale' import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" +import TimeInput from '@pages/components/TimeInput' import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" import PeriodInput from "./PeriodInput" -import TimeInput from "./TimeInput" const MAX_HOUR_WEEKLY = 7 * 24 +const UNLIMITED = t(msg => msg.limit.item.unlimited) const _default = defineComponent(() => { const { form: data } = useDialogSop() @@ -26,7 +27,7 @@ const _default = defineComponent(() => { msg.shared.limit.daily)}> - data.time = v} /> + data.time = v} /> {!isXs.value && t(msg => msg.limit.item.or)} { msg.shared.limit.weekly)}> - data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> + data.weekly = v} + hourMax={MAX_HOUR_WEEKLY} + /> {!isXs.value && t(msg => msg.limit.item.or)} { msg.limit.item.visitTime)}> - data.visitTime = v} /> + data.visitTime = v} + /> msg.shared.limit.period)}> data.periods = v} /> diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index f7372d1c2..c00295391 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -171,15 +171,14 @@ export const useLimitProvider = () => { } const changeLocked = async (row: tt4b.limit.Item, newVal: boolean) => { - const locked = !!newVal try { - if (locked) { + if (newVal) { const msg = t(msg => msg.limit.message.lockConfirm) await ElMessageBox.confirm(msg, { type: 'warning' }) } else { await verifyCanModify(row) } - row.locked = locked + row.locked = newVal await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) diff --git a/src/pages/app/components/Option/Tabs.tsx b/src/pages/app/components/Option/Tabs.tsx index 5335e064f..746e7d1b2 100644 --- a/src/pages/app/components/Option/Tabs.tsx +++ b/src/pages/app/components/Option/Tabs.tsx @@ -3,7 +3,7 @@ import { Download, Refresh, Upload } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" import { ElButton, ElMessage, ElMessageBox, ElTabPane, ElTabs, useNamespace } from "element-plus" -import { defineComponent, h, useSlots } from "vue" +import { defineComponent, useSlots } from "vue" import ContentContainer from '../common/ContentContainer' import { createFileInput, exportSettings, importSettings } from "./export-import" import { type OptionCategory, useCategory } from './useCategory' @@ -83,7 +83,7 @@ const _default = defineComponent(props => { > {Object.entries(useSlots()).filter(([key]) => key !== 'default').map(([key, slot]) => ( - {!!slot && h(slot)} + {slot?.()} ))} diff --git a/src/pages/app/components/Option/components/index.tsx b/src/pages/app/components/Option/components/index.tsx index 2b4899687..ff3965ca1 100644 --- a/src/pages/app/components/Option/components/index.tsx +++ b/src/pages/app/components/Option/components/index.tsx @@ -1,10 +1,10 @@ import { InfoFilled } from '@element-plus/icons-vue' import { ElIcon, ElTooltip } from 'element-plus' -import { type FunctionalComponent, h, type StyleValue } from 'vue' +import type { FunctionalComponent, StyleValue } from 'vue' -const Tag: FunctionalComponent<{}> = (_, { slots: { default: default_ } }) => ( +const Tag: FunctionalComponent<{}> = (_, { slots }) => ( - {default_ && h(default_)} + {slots.default?.()} ) Tag.displayName = 'OptionTag' diff --git a/src/pages/app/components/Record/Table/columns/DateColumn.tsx b/src/pages/app/components/Record/Table/columns/DateColumn.tsx deleted file mode 100644 index 4df148ed7..000000000 --- a/src/pages/app/components/Record/Table/columns/DateColumn.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { RecordSort } from '@app/components/Record/types' -import { t } from '@app/locale' -import { cvt2LocaleTime } from '@app/util/time' -import { ElTableColumn, RenderRowData } from "element-plus" -import { type FunctionalComponent } from "vue" - -const DateColumn: FunctionalComponent = () => ( - msg.item.date)} - minWidth={135} - align="center" - sortable="custom" - > - {({ row }: RenderRowData) => {cvt2LocaleTime(row.date)}} - -) - -export default DateColumn \ No newline at end of file diff --git a/src/pages/app/components/Record/Table/index.tsx b/src/pages/app/components/Record/Table/index.tsx index eec6cdb81..d7c712c60 100644 --- a/src/pages/app/components/Record/Table/index.tsx +++ b/src/pages/app/components/Record/Table/index.tsx @@ -10,7 +10,7 @@ import ContentCard from '@app/components/common/ContentCard' import Editable from '@app/components/common/Editable' import Pagination from '@app/components/common/Pagination' import { t } from '@app/locale' -import { periodFormatter } from '@app/util/time' +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { Histogram } from "@element-plus/icons-vue" import { useDocumentVisibility, useManualRequest, useRequest, useState } from '@hooks' import Flex from "@pages/components/Flex" @@ -19,14 +19,13 @@ import { isRtl } from "@util/document" import { siteEqual } from "@util/site" import { getAlias, isSite } from "@util/stat" import { cvtDateRange2Str } from '@util/time' -import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type TableInstance } from "element-plus" +import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type RenderRowData, type TableInstance } from "element-plus" import { createObjectGuard, createStringUnionGuard, isAny } from 'typescript-guard' import { computed, defineComponent, ref, watch } from "vue" import { queryPage } from "../common" import { useRecordFilter, useRecordSort } from "../context" import type { DisplayComponent, RecordFilterOption, RecordSort } from "../types" import CateColumn from "./columns/CateColumn" -import DateColumn from "./columns/DateColumn" import GroupColumn from "./columns/GroupColumn" import HostColumn from "./columns/HostColumn" import OperationColumn from "./columns/OperationColumn" @@ -127,7 +126,17 @@ const _default = defineComponent((_, ctx) => { onSort-change={val => isRecordSort(val) && (sort.value = val)} > {visible.value.index && } - {visible.value.date && } + {visible.value.date && ( + msg.item.date)} + minWidth={135} + align="center" + sortable="custom" + > + {({ row }: RenderRowData) => {cvt2LocaleTime(row.date)}} + + )} {visible.value.site && <> { return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' } -type TimeSpinnerProps = { +type TimeSpinnerProps = ModelValue & { max: number - modelValue: number visible: boolean - onChange?: (val: number) => void } const TimeSpinner = defineComponent(props => { @@ -104,14 +103,14 @@ const TimeSpinner = defineComponent(props => { ) }, { props: ['max', 'modelValue', 'visible', 'onChange'] }) -const useTimeInput = (source: () => number) => { +const useTimeInput = (source: () => number | undefined) => { const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) const [hour, setHour] = useState(initialHour) const [minute, setMinute] = useState(initialMin) const [second, setSecond] = useState(initialSec) const reset = () => { - const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) + const [hour, min, sec] = computeSecond2LimitInfo(source() ?? 0) setHour(hour) setMinute(min) setSecond(sec) @@ -134,10 +133,12 @@ const useTimeInput = (source: () => number) => { } } -type TimeInputProps = { - modelValue: number +type TimeInputProps = Pick & ModelValue & { hourMax?: number - onChange?: (val: number) => void + placeholder?: string + style?: CSSProperties + hideSeconds?: boolean + width?: string } /** @@ -151,7 +152,15 @@ const TimeInput = defineComponent(props => { reset, getTotalSecond, } = useTimeInput(() => props.modelValue) - const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) + const inputAttr = computed>(() => { + const { modelValue, hideSeconds } = props + // Not support 0 as valid time, treat it as unlimited + if (!modelValue) return { modelValue: undefined, clearable: false } + const text = hideSeconds + ? `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m` + : `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s` + return { modelValue: text, clearable: true } + }) const ns = useNamespace('time') const nsDate = useNamespace('date') @@ -177,7 +186,7 @@ const TimeInput = defineComponent(props => { } const handleClear = (ev: MouseEvent) => { - props.onChange?.(0) + props.onChange?.(undefined) ev.stopPropagation() } @@ -196,13 +205,14 @@ const TimeInput = defineComponent(props => { !!props.modelValue && ( + suffix: () => inputAttr.value.clearable && (
    @@ -215,11 +225,11 @@ const TimeInput = defineComponent(props => { }}>
    -
    -
    +
    +
    - + {!props.hideSeconds && }
    @@ -242,6 +252,6 @@ const TimeInput = defineComponent(props => { ) -}, { props: ['hourMax', 'modelValue', 'onChange'] }) +}, { props: ['style', 'size', 'placeholder', 'hourMax', 'modelValue', 'onChange', 'hideSeconds', 'width'] }) export default TimeInput \ No newline at end of file diff --git a/src/pages/popup/components/Footer/Menu.tsx b/src/pages/popup/components/Footer/Menu.tsx index 6cee6d51c..b4916718d 100644 --- a/src/pages/popup/components/Footer/Menu.tsx +++ b/src/pages/popup/components/Footer/Menu.tsx @@ -1,13 +1,13 @@ import { Histogram, PieChart, Timer } from '@element-plus/icons-vue' import { useMenu } from '@popup/context' import { t } from '@popup/locale' -import type { PopupMenu } from '@popup/types' +import { isMenu } from '@popup/router' import { ElIcon, ElRadioButton, ElRadioGroup, ElTooltip } from "element-plus" import { type Component, defineComponent, h } from "vue" type MenuItem = { icon: Component - route: PopupMenu + route: tt4b.ui.PopupMenu label: string } @@ -31,7 +31,7 @@ const Menu = defineComponent(() => { const { menu, setMenu } = useMenu() return () => ( - setMenu(v as PopupMenu)}> + isMenu(v) && setMenu(v)}> {createItems().map(({ route, label, icon }) => ( diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index bf121da9d..dbba43ecb 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -10,7 +10,7 @@ import { computed, reactive, Ref, ref, type ShallowRef, toRaw, watch } from "vue import { useRoute, useRouter } from 'vue-router' import { t } from "./locale" import { isMenu } from './router' -import type { PopupMenu, PopupOption, PopupQuery } from './types' +import type { PopupOption, PopupQuery } from './types' type PopupContextValue = { reload: () => void @@ -19,22 +19,22 @@ type PopupContextValue = { query: PopupQuery option: PopupOption cateNameMap: ShallowRef> - menu: ShallowRef - setMenu: ArgCallback + menu: ShallowRef + setMenu: ArgCallback limitSummary: ShallowRef limitSummaryLoading: ShallowRef selectedLimit: Ref } const initMenu = () => { - const [stored, setStored] = useLocalStorage('popup_menu', isMenu, 'percentage') + const [stored, setStored] = useLocalStorage('popup_menu', isMenu, 'percentage') const route = useRoute() const router = useRouter() const myRoute = computed(() => { const menuMaybe = route.path.substring(1) return isMenu(menuMaybe) ? menuMaybe : stored }) - const setMyRoute = (val: PopupMenu) => { + const setMyRoute = (val: tt4b.ui.PopupMenu) => { setStored(val) router.push('/' + val) } diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index d1d30792d..84f51a1d4 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -1,16 +1,15 @@ import { useLocalStorage } from '@hooks' import { createStringUnionGuard } from 'typescript-guard' -import { type App } from "vue" +import type { App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" -import type { PopupMenu } from './types' -type Path = `/${PopupMenu}` +type Path = `/${tt4b.ui.PopupMenu}` type MyRoute = | (Omit & { path: Path }) | (Omit & { redirect: Path }) -const createRoutes = (stored: PopupMenu | undefined): MyRoute[] => [ +const createRoutes = (stored: tt4b.ui.PopupMenu | undefined): MyRoute[] => [ { path: '/', redirect: stored ? `/${stored}` : '/percentage', @@ -26,10 +25,10 @@ const createRoutes = (stored: PopupMenu | undefined): MyRoute[] => [ }, ] -export const isMenu = createStringUnionGuard('limit', 'percentage', 'ranking') +export const isMenu = createStringUnionGuard('limit', 'percentage', 'ranking') export default (app: App) => { - const [stored] = useLocalStorage('popup_menu', isMenu) + const [stored] = useLocalStorage('popup_menu', isMenu) const routes = createRoutes(stored) const history = createWebHashHistory() const router = createRouter({ routes, history }) diff --git a/src/pages/popup/types.d.ts b/src/pages/popup/types.d.ts index 96a0c0b95..805b5e6d9 100644 --- a/src/pages/popup/types.d.ts +++ b/src/pages/popup/types.d.ts @@ -24,5 +24,3 @@ export type PopupOption = { topN: number donutChart: boolean } - -export type PopupMenu = 'percentage' | 'ranking' | 'limit' \ No newline at end of file diff --git a/src/util/limit.ts b/src/util/limit.ts index 726938a4e..25b0b702d 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -67,14 +67,14 @@ export const meetTimeLimit = (limit: LimitInfo, delay: DelayInfo) => { export function hasDailyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { const { time, count, waste, visit, delayCount, allowDelay } = item - const delay = { count: delayCount, duration: delayDuration, allow: !!allowDelay } + const delay = { count: delayCount, duration: delayDuration, allow: allowDelay } const limit = { wasted: waste, maxLimit: (time ?? 0) * MILL_PER_SECOND } return meetTimeLimit(limit, delay) || meetLimit(count, visit) } export function hasWeeklyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { const { weekly, weeklyCount, weeklyWaste, weeklyVisit, weeklyDelayCount, allowDelay } = item - const delay = { count: weeklyDelayCount, duration: delayDuration, allow: !!allowDelay } + const delay = { count: weeklyDelayCount, duration: delayDuration, allow: allowDelay } const limit = { wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND } return meetTimeLimit(limit, delay) || meetLimit(weeklyCount, weeklyVisit) } diff --git a/src/util/site.ts b/src/util/site.ts index ac7cb7e78..28524c248 100644 --- a/src/util/site.ts +++ b/src/util/site.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -const SEPARATORS = /[-\|–_::,]/ +const SEPARATORS = /[-|–_::,]/ const INVALID_SITE_NAME = /(登录)|(我的)|(个人)|(主页)|(首页)|(Welcome)/ diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts index d6f20d95c..8cfe5c397 100644 --- a/types/tt4b.d.ts +++ b/types/tt4b.d.ts @@ -97,6 +97,8 @@ declare namespace tt4b { | "second" | "minute" | "hour" + + type PopupMenu = 'percentage' | 'ranking' | 'limit' } namespace common { From 35bc107e585de636501baf225361ee73aef2203f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:25:58 +0000 Subject: [PATCH 26/95] i18n(download): download translations by bot --- src/i18n/message/app/analysis-resource.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 3b5841056..04f37e5a0 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -286,13 +286,13 @@ "common": { "focusTotal": "Общее время просмотра", "visitTotal": "Всего посещений", - "merged": "Соединено", + "merged": "Объединено", "virtual": "Виртуальное", "hostPlaceholder": "Поиск сайта для анализа", "emptyDesc": "Сайт не выбран" }, "summary": { - "title": "В целом", + "title": "Сводка", "day": "Всего активных дней", "firstDay": "Первое посещение {value}", "calendarTitle": "Активность за последние недели" From d42f2bf81d9466a305a43f26953a102cb9ec45af Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Jun 2026 18:26:07 +0800 Subject: [PATCH 27/95] i18n: add new directory to translate --- script/crowdin/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 090f1baa4..d83fa7cdf 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -4,7 +4,7 @@ import path from 'path' import { exitWith } from '../util/process' import { type CrowdinClient } from './client' -export const ALL_DIRS = ['app', 'common', 'popup', 'side', 'cs'] as const +export const ALL_DIRS = ['app', 'common', 'popup', 'side', 'cs', 'bg'] as const /** * The directory of messages From a2067eae975311e9b3abf933861353365dda496d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Jun 2026 18:26:18 +0800 Subject: [PATCH 28/95] fix: font order for Polish See the discussion on Crowdin: https://crowdin.com/project/timer-chrome-edge-firefox/discussions/2 --- src/pages/app/styles/index.ts | 6 +++++- src/pages/popup/style/common.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/app/styles/index.ts b/src/pages/app/styles/index.ts index 0c2075ad2..f5f77c6e4 100644 --- a/src/pages/app/styles/index.ts +++ b/src/pages/app/styles/index.ts @@ -20,7 +20,11 @@ export const injectAppCss = () => { height: 100vh; margin: 0; text-rendering: optimizeLegibility; - font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; + font-family: + "Helvetica Neue", Helvetica, Arial, + "Segoe UI", "Roboto", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + sans-serif; } ` } \ No newline at end of file diff --git a/src/pages/popup/style/common.ts b/src/pages/popup/style/common.ts index 04fe4f65d..cc6dc4dcb 100644 --- a/src/pages/popup/style/common.ts +++ b/src/pages/popup/style/common.ts @@ -4,7 +4,11 @@ export const injectCommonCss = () => injectGlobal` body { margin: 0; background-color: var(--el-bg-color); - font-family: Helvetica Neue,Helvetica,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Arial,sans-serif; + font-family: + "Helvetica Neue", Helvetica, Arial, + "Segoe UI", "Roboto", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + sans-serif; } html[data-theme='dark'] { From edd04dbebefc6cbf88a1abcf5fe03cf2148c173d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Jun 2026 18:57:42 +0800 Subject: [PATCH 29/95] chore: optimize issue template --- .../ISSUE_TEMPLATE/{bug-report---bug---.yaml => bug-report.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/ISSUE_TEMPLATE/{bug-report---bug---.yaml => bug-report.yaml} (100%) diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml similarity index 100% rename from .github/ISSUE_TEMPLATE/bug-report---bug---.yaml rename to .github/ISSUE_TEMPLATE/bug-report.yaml From 3beb3eb75adcd7a6f6e05719539823cd333fea8f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Jun 2026 23:26:33 +0800 Subject: [PATCH 30/95] feat: add bug report link on popup page --- .github/ISSUE_TEMPLATE/bug-report.yaml | 20 +--- .github/ISSUE_TEMPLATE/feature-request.yaml | 21 +--- src/background/action.ts | 14 +-- src/i18n/chrome/message.ts | 1 - .../common/context-menus-resource.json | 42 +++---- src/i18n/message/common/context-menus.ts | 1 - src/i18n/message/popup/header-resource.json | 8 +- src/i18n/message/popup/header.ts | 2 + .../app/components/About/Description.tsx | 27 ++--- .../Option/categories/Notification/index.tsx | 2 +- src/pages/app/index.ts | 2 +- src/pages/icons.tsx | 7 ++ .../popup/components/Header/MoreInfo.tsx | 106 ++++++++++-------- src/pages/popup/locale.ts | 2 +- src/util/constant/environment.ts | 23 +--- src/util/constant/url.ts | 35 +++--- 16 files changed, 130 insertions(+), 183 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 610e65a1d..eabb4bf2b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -15,34 +15,22 @@ body: label: Version description: Which version of the extension/addon are you using? placeholder: ex. v1.0.0 - - type: dropdown + - type: input id: os validations: required: true attributes: label: OS description: Which operating system are you using? (Mac/Linux/Win) - options: - - Win - - Mac - - Linux - - Android - - Other - default: 0 - - type: dropdown + placeholder: ex. Linux + - type: input id: browser validations: required: true attributes: label: Browser description: Which browser are you using? (Chrome/Firefox/Edge/Other) - options: - - Chrome - - Firefox - - Edge - - Brave - - Other - default: 0 + placeholder: ex. Chrome - type: input id: browser_version validations: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index ef5ec5286..566df9f59 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -7,35 +7,22 @@ body: attributes: value: | Tell us your wants~ - - type: dropdown + - type: input id: browser validations: required: true attributes: label: Browser - multiple: true description: Which browsers are you using? (Chrome/Firefox/Edge/Other) - options: - - Chrome - - Firefox - - Edge - - Brave - - Other - default: 0 - - type: dropdown + placeholder: ex. Chrome + - type: input id: os validations: required: false attributes: label: OS description: Which operating system are you using? (Mac/Linux/Win, Optional) - options: - - Win - - Mac - - Linux - - Android - - Other - default: 0 + placeholder: ex. Linux - type: textarea id: description validations: diff --git a/src/background/action.ts b/src/background/action.ts index b183480dc..fa20859a9 100644 --- a/src/background/action.ts +++ b/src/background/action.ts @@ -9,13 +9,9 @@ import { onIconClick } from "@api/chrome/action" import { createContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { createTab } from "@api/chrome/tab" -import { locale } from "@i18n" import { t2Chrome } from "@i18n/chrome/t" import { IS_ANDROID, IS_MV3, IS_SAFARI } from "@util/constant/environment" -import { - CHANGE_LOG_PAGE, GITHUB_ISSUE_ADD, SOURCE_CODE_PAGE, TU_CAO_PAGE, - getAppPageUrl, getGuidePageUrl, -} from "@util/constant/url" +import { CHANGE_LOG_PAGE, SOURCE_CODE_PAGE, getAppPageUrl, getGuidePageUrl } from "@util/constant/url" const APP_PAGE_URL = getAppPageUrl() @@ -57,13 +53,6 @@ const repoPageProps: ChromeContextMenuCreateProps = { ...baseProps } -const feedbackPageProps: ChromeContextMenuCreateProps = { - id: getRuntimeId() + '_timer_menu_item_feedback_link', - title: titleOf('😿', t2Chrome(msg => msg.contextMenus.feedbackPage)), - onclick: () => createTab(locale === 'zh_CN' ? TU_CAO_PAGE : GITHUB_ISSUE_ADD), - ...baseProps -} - const guidePageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_guide_link', title: titleOf('📖', t2Chrome(msg => msg.base.guidePage)), @@ -82,7 +71,6 @@ export function initBrowserAction() { createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) - createContextMenu(feedbackPageProps) createContextMenu(guidePageProps) createContextMenu(changeLogProps) diff --git a/src/i18n/chrome/message.ts b/src/i18n/chrome/message.ts index 5c2904f74..69d2d1d25 100644 --- a/src/i18n/chrome/message.ts +++ b/src/i18n/chrome/message.ts @@ -47,7 +47,6 @@ const placeholder: ChromeMessage = { contextMenus: { add2Whitelist: '', removeFromWhitelist: '', - feedbackPage: '', }, initial: { localFile: { diff --git a/src/i18n/message/common/context-menus-resource.json b/src/i18n/message/common/context-menus-resource.json index 3c1f9813b..afa02ac69 100644 --- a/src/i18n/message/common/context-menus-resource.json +++ b/src/i18n/message/common/context-menus-resource.json @@ -1,72 +1,58 @@ { "zh_CN": { "add2Whitelist": "将{host}加入白名单", - "removeFromWhitelist": "将{host}从白名单移出", - "feedbackPage": "吐槽一下" + "removeFromWhitelist": "将{host}从白名单移出" }, "zh_TW": { "add2Whitelist": "將 {host} 加入白名單", - "removeFromWhitelist": "將 {host} 從白名單移出", - "feedbackPage": "吐槽一下" + "removeFromWhitelist": "將 {host} 從白名單移出" }, "en": { "add2Whitelist": "Add {host} to the whitelist", - "removeFromWhitelist": "Remove {host} from the whitelist", - "feedbackPage": "Issues" + "removeFromWhitelist": "Remove {host} from the whitelist" }, "ja": { "add2Whitelist": "ホワイトリストに {host} を追加", - "removeFromWhitelist": "ホワイトリストから {host} を削除します", - "feedbackPage": "フィードバックの欠如" + "removeFromWhitelist": "ホワイトリストから {host} を削除します" }, "pt_PT": { "add2Whitelist": "Adicionar {host} à lista de permissões", - "removeFromWhitelist": "Remover {host} da lista de permissões", - "feedbackPage": "Problemas" + "removeFromWhitelist": "Remover {host} da lista de permissões" }, "uk": { "add2Whitelist": "Додати {host} в білий список", - "removeFromWhitelist": "Вилучити {host} з білого списку", - "feedbackPage": "Проблеми" + "removeFromWhitelist": "Вилучити {host} з білого списку" }, "es": { "add2Whitelist": "Agregar {host} a la lista blanca", - "removeFromWhitelist": "Eliminar {host} de la lista blanca", - "feedbackPage": "Problemas" + "removeFromWhitelist": "Eliminar {host} de la lista blanca" }, "de": { "add2Whitelist": "Fügen Sie {host} zur Whitelist hinzu", - "removeFromWhitelist": "Entfernen Sie {host} von der Whitelist", - "feedbackPage": "Issues" + "removeFromWhitelist": "Entfernen Sie {host} von der Whitelist" }, "fr": { "add2Whitelist": "Ajouter {host} à la liste blanche", - "removeFromWhitelist": "Supprimer {host} de la liste blanche", - "feedbackPage": "Problèmes" + "removeFromWhitelist": "Supprimer {host} de la liste blanche" }, "ru": { "add2Whitelist": "Добавить {host} в белый список", - "removeFromWhitelist": "Удалить {host} из белого списка", - "feedbackPage": "Проблемы" + "removeFromWhitelist": "Удалить {host} из белого списка" }, "ar": { "add2Whitelist": "أضف {host} إلى القائمة البيضاء", - "removeFromWhitelist": "إزالة {host} من القائمة البيضاء", - "feedbackPage": "مشكلات" + "removeFromWhitelist": "إزالة {host} من القائمة البيضاء" }, "tr": { "add2Whitelist": "{host} adresini beyaz listeye ekle", - "removeFromWhitelist": "{host} adresini beyaz listeden çıkart", - "feedbackPage": "Sorunlar" + "removeFromWhitelist": "{host} adresini beyaz listeden çıkart" }, "pl": { "add2Whitelist": "Dodaj {host} do Whitelisty", - "removeFromWhitelist": "Usuń {host} z Whitelisty", - "feedbackPage": "Zgłoszenia" + "removeFromWhitelist": "Usuń {host} z Whitelisty" }, "it": { "add2Whitelist": "Aggiungi {host} alla whitelist", - "removeFromWhitelist": "Rimuovi {host} dalla whitelist", - "feedbackPage": "Problemi" + "removeFromWhitelist": "Rimuovi {host} dalla whitelist" } } \ No newline at end of file diff --git a/src/i18n/message/common/context-menus.ts b/src/i18n/message/common/context-menus.ts index 4f5f00a47..e5a87653a 100644 --- a/src/i18n/message/common/context-menus.ts +++ b/src/i18n/message/common/context-menus.ts @@ -13,7 +13,6 @@ import resource from './context-menus-resource.json' export type ContextMenusMessage = { add2Whitelist: string removeFromWhitelist: string - feedbackPage: string } const _default: Messages = resource diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 5af352876..dd1d70c2f 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -4,14 +4,18 @@ "discord": "Join Discord", "showSiteName": "Display site name", "showTopN": "Display top {n}", - "donutChart": "Displayed as donut charts" + "donutChart": "Displayed as donut charts", + "bug": "Report Bug", + "feature": "Request Feature" }, "zh_CN": { "rating": "提交评价", "discord": "加入 Discord", "showSiteName": "显示网站名称", "showTopN": "显示前 {n} 名", - "donutChart": "以圆环图显示" + "donutChart": "以圆环图显示", + "bug": "提交 Bug", + "feature": "功能提案" }, "zh_TW": { "rating": "送出評分", diff --git a/src/i18n/message/popup/header.ts b/src/i18n/message/popup/header.ts index ba3017ebe..b514dc5fb 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -13,6 +13,8 @@ export type HeaderMessage = { donutChart: string showSiteName: string showTopN: string + bug: string + feature: string } const headerMessages = resource satisfies Messages diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index dc48ed8ef..335fd7091 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -7,11 +7,11 @@ import Flex from "@pages/components/Flex" import { Coffee, GitHub } from '@pages/icons' import { rateClicked } from '@pages/util/rate' import { - BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, CHROME_HOMEPAGE, EDGE_HOMEPAGE, FEEDBACK_QUESTIONNAIRE, FIREFOX_HOMEPAGE, - getHomepageWithLocale, GITHUB_ISSUE_ADD, HOMEPAGE, LICENSE_PAGE, PRIVACY_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE, + BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, CHROME_HOMEPAGE, EDGE_HOMEPAGE, FIREFOX_HOMEPAGE, getHomepageWithLocale, + GITHUB_ISSUE_FEATURE, HOMEPAGE, LICENSE_PAGE, PRIVACY_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE, } from "@util/constant/url" import { type ComponentSize, ElCard, ElDescriptions, ElDescriptionsItem, ElDivider, ElText, useNamespace } from "element-plus" -import { computed, defineComponent, reactive } from "vue" +import { computed, defineComponent } from "vue" import DescLink from "./DescLink" import { Chrome, Echarts, Edge, ElementPlus, Firefox, Vue } from './Icon' import InstallationLink from "./InstallationLink" @@ -75,18 +75,9 @@ const computeSize = (mediaSize: MediaSize): ComponentSize => { } const _default = defineComponent<{}>(() => { - const feedbackUrl = FEEDBACK_QUESTIONNAIRE[locale] || GITHUB_ISSUE_ADD const mediaSize = useMediaSize() const column = computed(() => mediaSize.value <= MediaSize.md ? 1 : 2) const size = computed(() => computeSize(mediaSize.value)) - const pages = reactive({ - homepage: HOMEPAGE, - privacy: PRIVACY_PAGE, - sourceCode: SOURCE_CODE_PAGE, - changeLog: CHANGE_LOG_PAGE, - email: AUTHOR_EMAIL, - }) - const [textContainerCls, descriptionsCls] = useStyle() return () => ( @@ -100,14 +91,14 @@ const _default = defineComponent<{}>(() => { msg.about.label.website)} labelAlign="right"> - {pages.homepage} + {HOMEPAGE} msg.about.label.privacy)} labelAlign="right"> - + msg.base.sourceCode)} labelAlign="right"> - + msg.about.label.license)} labelAlign="right"> @@ -115,10 +106,10 @@ const _default = defineComponent<{}>(() => { msg.base.changeLog)} labelAlign="right"> - + msg.about.label.support)} labelAlign="right"> - {pages.email} + {AUTHOR_EMAIL} msg.about.label.installation)} labelAlign="right"> @@ -164,7 +155,7 @@ const _default = defineComponent<{}>(() => {
    🙋  - + {t(msg => msg.about.text.feedback)} diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index cc649c48a..e6ca88862 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -99,7 +99,7 @@ const Notification = defineComponent((_, ctx) => { /> diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts index b53d83526..5dce9cf53 100644 --- a/src/pages/app/index.ts +++ b/src/pages/app/index.ts @@ -18,7 +18,7 @@ async function main() { injectAppCss() initDarkTheme() listenMediaSizeChange() - initLocale() + await initLocale() initEcharts() const app = await createElApp(Main) installRouter(app) diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx index cb31c1b86..648e30289 100644 --- a/src/pages/icons.tsx +++ b/src/pages/icons.tsx @@ -97,4 +97,11 @@ export const Website: Icon = () => ( +) + +export const Bug: Icon = () => ( + + + + ) \ No newline at end of file diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx index 7bacaa96d..11a065908 100644 --- a/src/pages/popup/components/Header/MoreInfo.tsx +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -1,71 +1,87 @@ import { createTab } from "@api/chrome/tab" -import { Collection, MoreFilled } from '@element-plus/icons-vue' +import { Collection, MagicStick, MoreFilled } from '@element-plus/icons-vue' import Flex from '@pages/components/Flex' -import { Discord, GitHub, Heart } from '@pages/icons' +import { Bug, Discord, GitHub, Heart } from '@pages/icons' import { rateClicked } from '@pages/util/rate' import { getColor, type ColorVariant } from '@pages/util/style' -import { t } from '@popup/locale' -import { CHANGE_LOG_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" +import { t, type I18nKey } from '@popup/locale' +import { + CHANGE_LOG_PAGE, GITHUB_ISSUE_BUG, GITHUB_ISSUE_FEATURE, REVIEW_PAGE, SOURCE_CODE_PAGE, +} from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { createStringUnionGuard } from 'typescript-guard' -import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" -import { type JSX } from 'vue/jsx-runtime' +import { h, type Component, type FunctionalComponent, type StyleValue } from "vue" -type Command = 'github' | 'changelog' | 'rate' | 'discord' +const ALL_CMDS = ['github', 'changelog', 'rate', 'discord', 'bug', 'feature'] as const -const isCommand = createStringUnionGuard('github', 'changelog', 'rate', 'discord') +type Command = typeof ALL_CMDS[number] + +const isCommand = createStringUnionGuard(...ALL_CMDS) type ItemLinkProps = { - icon: JSX.Element + icon: Component text: string iconColor?: ColorVariant } const ItemLink: FunctionalComponent = ({ icon, text, iconColor }) => ( - {icon} + {h(icon)} {text} ) -const HANDLERS: Record = { - github: () => createTab(SOURCE_CODE_PAGE), - changelog: () => createTab(CHANGE_LOG_PAGE), - rate: () => { - rateClicked() - createTab(REVIEW_PAGE) +type Config = ItemLinkProps & { + handler: NoArgCallback +} + +const createConfig = (text: I18nKey, icon: Component, url: string): Config => ({ + text: t(text), + icon, + handler: () => createTab(url) +}) + +const REGISTRY: Record = { + github: createConfig(msg => msg.base.sourceCode, GitHub, SOURCE_CODE_PAGE), + changelog: createConfig(msg => msg.base.changeLog, Collection, CHANGE_LOG_PAGE), + rate: { + text: t(msg => msg.header.rating), + icon: Heart, + iconColor: "danger", + handler: () => { + rateClicked() + createTab(REVIEW_PAGE) + } }, - discord: () => createTab('https://discord.gg/yXCngD8pKS'), + discord: createConfig(msg => msg.header.discord, Discord, 'https://discord.gg/yXCngD8pKS'), + bug: createConfig(msg => msg.header.bug, Bug, GITHUB_ISSUE_BUG), + feature: createConfig(msg => msg.header.feature, MagicStick, GITHUB_ISSUE_FEATURE), } -const MoreInfo = defineComponent<{}>(() => { +const ITEMS: Command[][] = [ + ['github', 'changelog', 'bug', 'feature'], + ['discord', 'rate'] +] - return () => ( - isCommand(val) && HANDLERS[val]()} - style={{ cursor: 'pointer' } satisfies StyleValue} - v-slots={{ - default: () => , - dropdown: () => ( - - - } text={t(msg => msg.base.sourceCode)} /> +const MoreInfo: FunctionalComponent<{}> = () => ( + isCommand(val) && REGISTRY[val].handler()} + style={{ cursor: 'pointer' } satisfies StyleValue} + v-slots={{ + default: () => , + dropdown: () => ( + + {ITEMS.flatMap((g, gi) => g.map((cmd, ci) => ( + + - - } text={t(msg => msg.base.changeLog)} /> - - - } text={t(msg => msg.header.discord)} /> - - - } text={t(msg => msg.header.rating)} iconColor="danger" /> - - - ) - }}> - - ) -}) + )))} + + ) + }}> + +) +MoreInfo.displayName = 'MoreInfo' export default MoreInfo \ No newline at end of file diff --git a/src/pages/popup/locale.ts b/src/pages/popup/locale.ts index bc161614e..f44286ccb 100644 --- a/src/pages/popup/locale.ts +++ b/src/pages/popup/locale.ts @@ -9,7 +9,7 @@ import { type I18nKey as _I18nKey, t as _t, tN as _tN } from "@i18n" import messages, { type PopupMessage } from "@i18n/message/popup" import type { VNode } from 'vue' -type I18nKey = _I18nKey +export type I18nKey = _I18nKey export const t = (key: I18nKey, param?: any) => _t(messages, { key, param }) diff --git a/src/util/constant/environment.ts b/src/util/constant/environment.ts index 2004a3536..a766d2c6a 100644 --- a/src/util/constant/environment.ts +++ b/src/util/constant/environment.ts @@ -50,7 +50,10 @@ export const IS_SAFARI = BROWSER_NAME === 'safari' /** * @since 3.3.0 */ -export const IS_ANDROID: boolean = !!userAgent?.toLowerCase()?.includes("android") +export const IS_ANDROID = userAgent?.toLowerCase()?.includes("android") ?? false +export const IS_WINDOWS = platform?.startsWith('Win') ?? false +export const IS_LINUX = (platform?.startsWith('Linux') ?? false) && !IS_ANDROID +export const IS_MAC = platform?.startsWith('Mac') ?? false let browserMajorVersion: number | undefined = undefined try { @@ -62,24 +65,6 @@ try { */ export const BROWSER_MAJOR_VERSION = browserMajorVersion -type NavigatorWithUAData = Navigator & { - userAgentData?: { - platform: string - } -} - -let isWindows = false -if (((navigator as unknown as NavigatorWithUAData)?.userAgentData)?.platform === 'Windows') { - isWindows = true -} else if (platform?.startsWith('Win')) { - isWindows = true -} - -/** - * @since 2.4.2 - */ -export const IS_WINDOWS = isWindows - /** * @since 1.4.4 */ diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index bd8083612..9b8c7f26f 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -7,7 +7,7 @@ import { getUrl, getVersion } from "@api/chrome/runtime" import { locale } from "@i18n" -import { BROWSER_MAJOR_VERSION, BROWSER_NAME } from "./environment" +import { BROWSER_MAJOR_VERSION, BROWSER_NAME, IS_ANDROID, IS_LINUX, IS_MAC, IS_WINDOWS } from "./environment" export const FIREFOX_HOMEPAGE = 'https://addons.mozilla.org/firefox/addon/besttimetracker' export const CHROME_HOMEPAGE = 'https://chromewebstore.google.com/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm' @@ -24,31 +24,26 @@ export const SOURCE_CODE_PAGE = 'https://github.com/sheepzh/time-tracker-4-brows */ export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/blob/main/CHANGELOG.md' -/** - * @since 0.0.6 - */ -export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/time-tracker-4-browser/issues/new/choose' +const issueTemplatePage = (type: 'bug' | 'feature') => { + const template = type === 'bug' ? 'bug-report.yaml' : 'feature-request.yaml' + const version = `v${getVersion()}` + const browserVersion = BROWSER_MAJOR_VERSION + const os = [ + IS_WINDOWS && 'Win', + IS_MAC && 'Mac', + IS_LINUX && 'Linux', + IS_ANDROID && 'Android', + ].filter(Boolean).join(',') + return `https://github.com/sheepzh/time-tracker-4-browser/issues/new?template=${template}&version=${version}&browser_version=${browserVersion}&os=${os}` +} -/** - * Feedback powered by support.qq.com - * - * @since 0.8.5 - */ -export const TU_CAO_PAGE = `https://support.qq.com/products/402895?os=${BROWSER_NAME}&osVersion=${BROWSER_MAJOR_VERSION}&clientVersion=${getVersion()}` +export const GITHUB_ISSUE_BUG = issueTemplatePage('bug') +export const GITHUB_ISSUE_FEATURE = issueTemplatePage('feature') export const PRIVACY_PAGE = 'https://www.wfhg.cc/en/privacy.html' export const LICENSE_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/blob/main/LICENSE' -/** - * @since 0.9.6 - */ -export const FEEDBACK_QUESTIONNAIRE: Record & Partial> = { - zh_CN: TU_CAO_PAGE, - zh_TW: 'https://docs.google.com/forms/d/e/1FAIpQLSdfvG6ExLj331YOLZIKO3x98k3kMxpkkLW1RgFuRGmUnZCGRQ/viewform?usp=sf_link', - en: 'https://docs.google.com/forms/d/e/1FAIpQLSdNq4gnSY7uxYkyqOPqyYF3Bqlc3ZnWCLDi5DI5xGjPeVCNiw/viewform?usp=sf_link', -} - const UNINSTALL_QUESTIONNAIRE_EN = 'https://docs.google.com/forms/d/e/1FAIpQLSflhZAFTw1rTUjAEwgxqCaBuhLBBthwEK9fIjvmwWfITLSK9A/viewform?usp=sf_link' /** * @since 0.9.6 From 1b871cea2432ea39343ea14a6579437dd1ee8764 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 14 Jun 2026 00:58:57 +0800 Subject: [PATCH 31/95] v4.3.6 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2cc95c3..d139fcc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.6] - 2026-06-14 + +- Fixed some bugs + ## [4.3.5] - 2026-06-05 - Fixed some bugs (#788, #790) diff --git a/package.json b/package.json index 1a86d99ca..2409052a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.5", + "version": "4.3.6", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From c7a4d26fb58ffdecb388589bd8bb9dd438e9a76f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 14 Jun 2026 01:20:47 +0800 Subject: [PATCH 32/95] fix: column header --- .../Limit/components/Table/index.tsx | 8 +-- .../app/components/common/ColumnHeader.tsx | 49 ++++++++++--------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index ca9f2cab3..98d5f9298 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -50,10 +50,10 @@ function sortByEffectiveDays(a: tt4b.limit.Item, b: tt4b.limit.Item) { } const _default = defineComponent((_, ctx) => { - const { data: weekStartName } = useRequest(async () => { + const { data: weeklyInfo } = useRequest(async () => { const offset = await getWeekStartDay() - const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] - return name || 'NaN' + const weekStart = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] ?? 'NaN' + return t(msg => msg.limit.item.weekStartInfo, { weekStart }) }) const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() @@ -147,7 +147,7 @@ const _default = defineComponent((_, ctx) => { header: () => ( msg.calendar.range.thisWeek)} - tooltipContent={t(msg => msg.limit.item.weekStartInfo, { weekStart: weekStartName.value })} + tooltipContent={weeklyInfo.value} /> ), default: ({ row: { diff --git a/src/pages/app/components/common/ColumnHeader.tsx b/src/pages/app/components/common/ColumnHeader.tsx index 286f0f0bb..95f135bf0 100644 --- a/src/pages/app/components/common/ColumnHeader.tsx +++ b/src/pages/app/components/common/ColumnHeader.tsx @@ -1,29 +1,32 @@ import { InfoFilled } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { ElIcon, ElTooltip } from "element-plus" -import { defineComponent, useSlots } from "vue" +import { FunctionalComponent } from 'vue' -const ColumnHeader = defineComponent<{ label: string, tooltipContent?: string }>(({ label, tooltipContent }) => { - const slots = useSlots() - return () => ( - - {label} - ( - - - - - - ), - }} - /> - - ) -}, { props: ['label', 'tooltipContent'] }) +type Props = { + label: string + tooltipContent?: string +} + +const ColumnHeader: FunctionalComponent = ({ label, tooltipContent }, { slots }) => ( + + {label} + ( + + + + + + ), + }} + /> + +) +ColumnHeader.displayName = 'ColumnHeader' export default ColumnHeader \ No newline at end of file From 93c0775fcdd89000238655c7c3cf663ba6e40acc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:21:41 +0000 Subject: [PATCH 33/95] i18n(download): download translations by bot --- src/i18n/message/app/limit-resource.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 6f630036a..1fec198c2 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -15,7 +15,8 @@ "effectiveDay": "生效日期", "allowDelay": "可延时", "or": "或者", - "notEffective": "未生效" + "notEffective": "未生效", + "unlimited": "无限制" }, "step": { "base": "基本信息", @@ -577,7 +578,7 @@ "reminder": "Zaman sınırı dolmasına {min} dakikadan az kaldı!" }, "pl": { - "onlyEffective": "Tylko efektywne", + "onlyEffective": "Tylko skuteczne", "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", "item": { @@ -592,7 +593,8 @@ "effectiveDay": "Aktywna w", "allowDelay": "Możliwe do przedłużenia", "or": "lub", - "notEffective": "Nieskuteczny" + "notEffective": "Nieskuteczny", + "unlimited": "Nielimitowany" }, "step": { "base": "Podstawowe informacje", From 967ff05b3f79584a30a5eb1f886fea604b6f1c57 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 16 Jun 2026 00:33:44 +0800 Subject: [PATCH 34/95] chore: contributor map --- .github/workflows/contributor.yml | 29 ++++ .gitignore | 1 + README-zh.md | 4 +- README.md | 6 +- package.json | 8 +- script/contributor.ts | 233 ++++++++++++++++++++++++++++++ script/crowdin/client.ts | 102 +++++++++++-- script/user-chart/add.ts | 3 +- script/user-chart/common.ts | 12 -- script/user-chart/render.ts | 3 +- script/util/gist.ts | 12 ++ 11 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/contributor.yml create mode 100644 script/contributor.ts create mode 100644 script/util/gist.ts diff --git a/.github/workflows/contributor.yml b/.github/workflows/contributor.yml new file mode 100644 index 000000000..ae8ba83cc --- /dev/null +++ b/.github/workflows/contributor.yml @@ -0,0 +1,29 @@ +name: Update Contributors + +on: + schedule: + - cron: "0 23 * * 2" + workflow_dispatch: + +jobs: + update-contributors: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Run Script + run: npx ts-node -P tsconfig.node.json ./script/contributor.ts + env: + TIMER_USER_COUNT_GIST_TOKEN: ${{ secrets.GIST_PAT }} + TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }} diff --git a/.gitignore b/.gitignore index 86537e1bd..071808f26 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ package-lock.json aaa user-chart.svg +contributors.svg test.log diff --git a/README-zh.md b/README-zh.md index 9b49e936d..da628f466 100644 --- a/README-zh.md +++ b/README-zh.md @@ -91,6 +91,6 @@ 至于最简单粗暴的贡献方式,当然是在 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/web%E6%99%82%E9%96%93%E7%B5%B1%E8%A8%88/) / [Chrome](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm) / [Edge](https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc) 好评三连啦 XXD -## 致谢 +## ❤️ 谢谢大家! -Timer - Count your browsing time and visits on every sites | Product Hunt +![Thanks To](https://gist.githubusercontent.com/sheepzh/cb33b8b1a1e21b533bf650483b125af5/raw/contributors.svg) diff --git a/README.md b/README.md index 7e740cdbc..afe7cac83 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,6 @@ Most of the software's localization relies on machine translation. You can also It's simple and much helpful! -## Thanks +## ❤️ Thanks To -Timer (relaunch) - Timer is one browser extension to stat site visits and time. | Product Hunt - - +![Thanks To](https://gist.githubusercontent.com/sheepzh/cb33b8b1a1e21b533bf650483b125af5/raw/contributors.svg) \ No newline at end of file diff --git a/package.json b/package.json index 2409052a1..a7b7a9a9c 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,13 @@ "license": "MIT", "devDependencies": { "@commitlint/types": "^21.0.1", - "@crowdin/crowdin-api-client": "^1.55.4", + "@crowdin/crowdin-api-client": "^1.56.0", "@emotion/babel-plugin": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.13", "@rspack/cli": "^2.0.8", "@rspack/core": "^2.0.8", - "@rstest/core": "^0.10.3", - "@rstest/coverage-istanbul": "^0.10.3", + "@rstest/core": "^0.10.5", + "@rstest/coverage-istanbul": "^0.10.5", "@types/chrome": "0.1.43", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", @@ -64,7 +64,7 @@ "@element-plus/icons-vue": "^2.3.2", "@emotion/css": "^11.13.5", "echarts": "^6.1.0", - "element-plus": "2.14.1", + "element-plus": "2.14.2", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", "typescript-guard": "0.2.6", diff --git a/script/contributor.ts b/script/contributor.ts new file mode 100644 index 000000000..1870e0b75 --- /dev/null +++ b/script/contributor.ts @@ -0,0 +1,233 @@ +import { createGist, findTarget, updateGist, type FileForm, type GistForm } from '@api/gist' +import { writeFileSync } from 'fs' +import { createArrayGuard, createObjectGuard, isInt, isString } from 'typescript-guard' +import { getClientFromEnv, TopMember } from './crowdin/client' +import { validateTokenFromEnv } from './util/gist' +import { exitWith } from './util/process' + +type GithubContributor = { + login: string + avatar_url: string + contributions: number + type: string +} + +const isGithubContributors = createArrayGuard( + createObjectGuard({ + login: isString, + avatar_url: isString, + contributions: isInt, + type: isString, + }) +) + +async function fetchGithubContributors(token: string): Promise { + const result: GithubContributor[] = [] + let page = 1 + while (true) { + const res = await fetch( + `https://api.github.com/repos/sheepzh/time-tracker-4-browser/contributors?per_page=100&page=${page}`, + { headers: { Accept: 'application/vnd.github+json', Authorization: `token ${token}` } } + ) + if (!res.ok) throw new Error(`GitHub contributors API error: ${res.status}`) + const data = await res.json() + if (!isGithubContributors(data)) exitWith(`Invalid data from GitHub API: ${JSON.stringify(data)}`) + if (!data.length) break + result.push(...data) + page++ + } + return result +} + +const AVATAR_R = 20 +const CARD_W = 64 +const CARD_H = 76 +const COLS = 12 +const H_PADDING = 20 +const V_PADDING = 16 +const SECTION_GAP = 28 +const SECTION_LABEL_H = 24 + +async function getBase64Avatar(url: string): Promise { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + const contentType = response.headers.get('content-type') ?? 'image/jpeg' + const base64 = buffer.toString('base64') + + return `data:${contentType};base64,${base64}` + } catch (error) { + // Fallback to original URL if error occurs + console.error(`[Avatar Fetch Failed] ${url}:`, error) + return url + } +} + +type Contributor = { + name: string + avatarUrl: string + sub: string +} + +function renderCard(x: number, y: number, contributor: Contributor): string { + const { avatarUrl, name, sub } = contributor + const cx = x + CARD_W / 2 + const ay = y + AVATAR_R + 4 + + const shortName = name.length > 8 ? name.slice(0, 7) + '…' : name + const shortSub = sub.length > 10 ? sub.slice(0, 9) + '…' : sub + const safeAvatarUrl = avatarUrl.replace(/&/g, '&') + + return ` + + + + + + ${shortName} + + + ${shortSub} + + ` +} + +function renderSection(label: string, items: Contributor[], yOffset: number): { svg: string; height: number } { + const rows = Math.ceil(items.length / COLS) + const height = SECTION_LABEL_H + rows * CARD_H + let svg = ` + + ${label} + + ` + + items.forEach((item, i) => { + const x = H_PADDING + (i % COLS) * CARD_W + const y = yOffset + SECTION_LABEL_H + Math.floor(i / COLS) * CARD_H + svg += renderCard(x, y, item) + }) + return { svg, height } +} + +async function renderSvg(coders: Contributor[], translators: Contributor[]): Promise { + const totalWidth = H_PADDING * 2 + COLS * CARD_W + let y = V_PADDING + const sections: string[] = [] + + // Code Contributors + for (const coder of coders) { + coder.avatarUrl = await getBase64Avatar(coder.avatarUrl) + } + const { height: coderHeight, svg: coderSvg } = renderSection('Code Contributors', coders, y) + sections.push(coderSvg) + y += coderHeight + SECTION_GAP + + // Translation Contributors + for (const translator of translators) { + translator.avatarUrl = await getBase64Avatar(translator.avatarUrl) + } + const cdSection = renderSection('Translation Contributors', translators, y) + sections.push(cdSection.svg) + y += cdSection.height + V_PADDING + + const totalHeight = y + + return ` + + + ${sections.join('\n')} + + ` +} + +const GIST_DESC = 'Timer contributor list, auto-generated' +const GIST_FILENAME = 'contributors.svg' + +async function uploadToGist(token: string, svg: string): Promise { + const files: Record = { + [GIST_FILENAME]: { filename: GIST_FILENAME, content: svg }, + } + const form: GistForm = { public: true, description: GIST_DESC, files } + const existing = await findTarget(token, g => g.description === GIST_DESC) + if (existing) { + await updateGist(token, existing.id, form) + console.log('Updated gist:', existing.id) + } else { + const created = await createGist(token, form) + console.log('Created gist:', created.id) + } +} + +async function fetchCoders(token: string): Promise { + console.log('Fetching GitHub contributors...') + const github = await fetchGithubContributors(token) + const users = github.filter(c => c.type === 'User') + console.log(`GitHub: ${users.length} users (${github.length} contributors)`) + + return users.map(g => ({ + name: g.login, + avatarUrl: g.avatar_url, + sub: `${g.contributions} commits` + })) +} + +const crowdinScore = ({ approved, translated }: TopMember): number => translated + approved * .4 + +const LANGUAGE_MAP: Record = { + "Chinese Simplified": "简体中文", + "Chinese Traditional": "正體中文", +} + +const fetchTranslators = async (): Promise => { + console.log('Fetching Crowdin contributors...') + const members = await getClientFromEnv().fetchTopMembers() + console.log(`Crowdin: ${members.length} contributors`) + + return members + .filter(c => c.username !== 'sheepzh') + .sort((a, b) => crowdinScore(b) - crowdinScore(a)) + .map(c => ({ + name: c.username, + avatarUrl: c.avatarUrl, + sub: c.languages.map(l => LANGUAGE_MAP[l] ?? l).slice(0, 1).join(', ') + })) +} + +async function main(): Promise { + const gistToken = validateTokenFromEnv() + + const coders = await fetchCoders(gistToken) + const translators = await fetchTranslators() + + console.log('Rendering SVG...') + const svg = await renderSvg(coders, translators) + writeFileSync('contributors.svg', svg, 'utf-8') + + console.log('Uploading to Gist...') + await uploadToGist(gistToken, svg) + console.log('Done!') +} + +main() + diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index 80fc7474f..274e4d2ae 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -8,12 +8,52 @@ import Crowdin, { type StringTranslationsModel, type UploadStorageModel, } from '@crowdin/crowdin-api-client' +import { createArrayGuard, createObjectGuard, isInt, isString } from 'typescript-guard' +import { exitWith } from '../util/process' import { ALL_CROWDIN_LANGUAGES, type CrowdinLanguage, type Dir, type ItemSet } from './common' const PROJECT_ID = 516822 +export type TopMember = { + username: string + avatarUrl: string + translated: number + approved: number + languages: string[] +} + const MAIN_BRANCH_NAME = 'main' +type TopMemberRow = { + user: { + username: string + avatarUrl: string + } + languages: { name: string }[] + translated: number + approved: number +} + +const isTopMemberRow = createObjectGuard({ + user: createObjectGuard({ + username: isString, + avatarUrl: isString, + }), + languages: createArrayGuard(createObjectGuard({ + name: isString, + })), + translated: isInt, + approved: isInt, +}) + +type TopMemberData = { + data: TopMemberRow[] +} + +const isTopMemberData = createObjectGuard({ + data: createArrayGuard(isTopMemberRow) +}) + /** * The iterator of response */ @@ -273,25 +313,67 @@ export class CrowdinClient { skipUntranslatedStrings: true, }) const buildId = buildRes.data.id - const maxRetries = 120 - let retryCount = 0 - while (true) { - if (retryCount >= maxRetries) { - throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`) - } + await this.#retryWith(async () => { const statusRes = await this.crowdin.translationsApi.checkBuildStatus(PROJECT_ID, buildId) const { status, progress } = statusRes.data console.log(`Build status: ${status}, progress: ${progress}%`) - if (status === 'finished') break + if (status === 'finished') return true if (status === 'canceled' || status === 'failed') { throw new Error(`Build ${status}: buildId=${buildId}`) } - retryCount++ - await new Promise(resolve => setTimeout(resolve, 1000)) - } + }) + const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) return res.data.url } + + async fetchTopMembers(): Promise { + const reportUrl = await this.#buildMemberReport() + const result = await fetch(reportUrl) + const json = await result.json() + if (!isTopMemberData(json)) { + exitWith(`Unexpected report data format: ${JSON.stringify(json)}`) + } + return json.data.map(r => ({ + username: r.user.username, + avatarUrl: r.user.avatarUrl, + translated: r.translated, + approved: r.approved, + languages: r.languages.map(l => l.name), + })) + } + + async #buildMemberReport(): Promise { + const { data: { identifier: reportId } } = await this.crowdin.reportsApi.generateReport(PROJECT_ID, { + name: 'top-members', + schema: { unit: 'words', format: 'json' }, + }) + + await this.#retryWith(async () => { + const status = await this.crowdin.reportsApi.checkReportStatus(PROJECT_ID, reportId) + const { status: s, progress } = status.data + console.log(`Crowdin report: ${s} ${progress}%`) + if (s === 'finished') return true + if (s === 'canceled' || s === 'failed') throw new Error(`Report ${s}`) + }) + + const report = await this.crowdin.reportsApi.downloadReport(PROJECT_ID, reportId) + return report.data.url + } + + async #retryWith(predicate: () => Promise) { + const maxRetries = 120 + let retryCount = 0 + while (true) { + if (retryCount >= maxRetries) { + throw new Error(`Build timed out after ${maxRetries} retries`) + } + const pass = await predicate() + if (pass) break + retryCount++ + await new Promise(r => setTimeout(r, 1000)) + } + } } /** diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index b7c632fc8..a276ccb06 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -8,8 +8,9 @@ import { } from "@api/gist" import { CHROME_ID } from "@util/constant/meta" import fs from "fs" +import { validateTokenFromEnv } from '../util/gist' import { exitWith } from "../util/process" -import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" +import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount } from "./common" type AutoMode = { mode: 'auto' diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index a3a30cfc5..62fc0587e 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,4 @@ import { findTarget, type Gist } from "@api/gist" -import { exitWith } from '../util/process' export type Browser = | 'chrome' @@ -8,17 +7,6 @@ export type Browser = export type UserCount = Record -/** - * Validate the token from environment variables - */ -export function validateTokenFromEnv(): string { - const token = process.env.TIMER_USER_COUNT_GIST_TOKEN - if (!token) { - exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") - } - return token! -} - /** * Calculate the gist description of target browser */ diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index e5a3fb8ce..9d51f1e26 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -9,7 +9,8 @@ import { } from "echarts" import { writeFileSync } from "fs" import { exit } from 'process' -import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type UserCount } from "./common" +import { validateTokenFromEnv } from '../util/gist' +import { filenameOf, getExistGist, type Browser, type UserCount } from "./common" type EcOption = ComposeOption< | LineSeriesOption diff --git a/script/util/gist.ts b/script/util/gist.ts new file mode 100644 index 000000000..114549d8b --- /dev/null +++ b/script/util/gist.ts @@ -0,0 +1,12 @@ +import { exitWith } from './process' + +/** + * Validate the token from environment variables + */ +export function validateTokenFromEnv(): string { + const token = process.env.TIMER_USER_COUNT_GIST_TOKEN + if (!token) { + exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") + } + return token +} \ No newline at end of file From e9303ed9a2098d9095f7922abf732a48eaab6327 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 16 Jun 2026 00:55:14 +0800 Subject: [PATCH 35/95] ci: remove cache of npm --- .github/workflows/contributor.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/contributor.yml b/.github/workflows/contributor.yml index ae8ba83cc..7946d7076 100644 --- a/.github/workflows/contributor.yml +++ b/.github/workflows/contributor.yml @@ -17,7 +17,6 @@ jobs: uses: actions/setup-node@v6 with: node-version: "24" - cache: "npm" - name: Install Dependencies run: npm install From 0c8a3b64103ac289a2dda689c44f5515504d3f08 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 16 Jun 2026 13:59:08 +0800 Subject: [PATCH 36/95] fix: icon collecting on FF(#802) --- src/background/content-script-handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 3ad13fe4c..29189aa5b 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -5,7 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" +import { getTab } from '@api/chrome/tab' +import { IS_ANDROID, IS_CHROME, IS_FIREFOX, IS_SAFARI } from "@util/constant/environment" import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" @@ -49,6 +50,8 @@ async function processTabInfo(tab: ChromeTab): Promise { */ const collectIconAndAlias = async (tab: ChromeTab) => { if (IS_SAFARI || IS_ANDROID) return + // Tab from sender does not contain favIconUrl for FF + if (IS_FIREFOX) tab = (tab.id ? await getTab(tab.id) : undefined) ?? tab processTabInfo(tab) } From e8ec699fde12e6d4d01c85a23882f93a28c38fbe Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 16 Jun 2026 23:14:18 +0800 Subject: [PATCH 37/95] refactor: useCopy --- script/contributor.ts | 5 ++- .../categories/Limit/use2faSetup/Form.tsx | 21 ++++--------- .../Option/categories/Limit/usePswEdit.tsx | 6 ++-- src/pages/hooks/index.ts | 1 + src/pages/hooks/useCopy.ts | 22 +++++++++++++ src/pages/hooks/useDebounce.ts | 31 ++++++------------- 6 files changed, 43 insertions(+), 43 deletions(-) create mode 100644 src/pages/hooks/useCopy.ts diff --git a/script/contributor.ts b/script/contributor.ts index 1870e0b75..0b2aaa395 100644 --- a/script/contributor.ts +++ b/script/contributor.ts @@ -70,11 +70,11 @@ async function getBase64Avatar(url: string): Promise { type Contributor = { name: string avatarUrl: string - sub: string + sub?: string } function renderCard(x: number, y: number, contributor: Contributor): string { - const { avatarUrl, name, sub } = contributor + const { avatarUrl, name, sub = '' } = contributor const cx = x + CARD_W / 2 const ay = y + AVATAR_R + 4 @@ -188,7 +188,6 @@ async function fetchCoders(token: string): Promise { return users.map(g => ({ name: g.login, avatarUrl: g.avatar_url, - sub: `${g.contributions} commits` })) } diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx index b12885a20..9edac27b1 100644 --- a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx @@ -1,10 +1,9 @@ import { t } from '@app/locale' -import { CopyDocument } from '@element-plus/icons-vue' -import { useRequest, useState } from '@hooks' +import { useCopy, useRequest, useState } from '@hooks' import Flex from '@pages/components/Flex' import Img from '@pages/components/Img' import { generateQrDataUrl } from '@pages/util/qrcode' -import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElText } from 'element-plus' +import { ElButton, ElForm, ElFormItem, ElInput, ElText } from 'element-plus' import { computed, defineComponent, toRef, watch } from 'vue' function extractSecret(otpauth: string): string { @@ -12,22 +11,13 @@ function extractSecret(otpauth: string): string { return raw ?? 'Secret is unknown' } -async function copy(text: string) { - try { - await navigator.clipboard.writeText(text) - ElMessage.success('Copied') - } catch (e) { - const errMsg = e instanceof Error ? e.message : e ?? 'Unknown error' - ElMessage.error(`Copy failed: ${errMsg}`) - } -} - export type FormInstance = { getVerifyCode: () => string } const _default = defineComponent<{ otpauth: string }>((props, ctx) => { const otpauth = toRef(props, 'otpauth') + const { copy: copyAuth, icon: authIcon } = useCopy(otpauth) const [code, setCode] = useState('') watch(otpauth, () => setCode(''), { immediate: true }) @@ -41,6 +31,7 @@ const _default = defineComponent<{ otpauth: string }>((props, ctx) => { } satisfies FormInstance) const secret = computed(() => extractSecret(otpauth.value)) + const { copy: copySecret, icon: secretIcon } = useCopy(secret) return () => ( @@ -57,11 +48,11 @@ const _default = defineComponent<{ otpauth: string }>((props, ctx) => { size='small' modelValue={secret.value} readonly v-slots={{ - append: () => copy(secret.value)} />, + append: () => , }} /> - copy(otpauth.value)}> + {t(msg => msg.option.limit.level.twoFaCopyLink)} diff --git a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx index 0a3769892..3c57b8b2f 100644 --- a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx +++ b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx @@ -7,10 +7,8 @@ type Options = { reset: () => string | undefined } -export const usePswEdit = (options: Options) => { - const { reset } = options || {} - - const [psw, setPsw] = useState(reset?.()) +export const usePswEdit = ({ reset }: Options) => { + const [psw, setPsw] = useState(reset()) const [confirmPsw, setConfirmPsw, resetConfirmPsw] = useState('') const messageBoxNs = useNamespace('message-box') diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index 058e62ccc..83b064379 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -1,4 +1,5 @@ export * from "./useCached" +export * from "./useCopy" export * from "./useCount" export * from "./useDebounce" export * from "./useDocumentVisibility" diff --git a/src/pages/hooks/useCopy.ts b/src/pages/hooks/useCopy.ts new file mode 100644 index 000000000..44c7dc3ca --- /dev/null +++ b/src/pages/hooks/useCopy.ts @@ -0,0 +1,22 @@ +import { Check, CopyDocument } from '@element-plus/icons-vue' +import { ElMessage } from 'element-plus' +import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue' + +export const useCopy = (text: MaybeRefOrGetter) => { + const copied = ref(false) + const icon = computed(() => copied.value ? Check : CopyDocument) + const copy = async () => { + if (copied.value) return + try { + const value = toValue(text) + await navigator.clipboard.writeText(value) + copied.value = true + setTimeout(() => copied.value = false, 1000) + } catch (e) { + const errMsg = e instanceof Error ? e.message : e ?? 'Unknown error' + ElMessage.error(`Copy failed: ${errMsg}`) + } + } + + return { copied, icon, copy } +} \ No newline at end of file diff --git a/src/pages/hooks/useDebounce.ts b/src/pages/hooks/useDebounce.ts index 85abecc7e..8da78ea0a 100644 --- a/src/pages/hooks/useDebounce.ts +++ b/src/pages/hooks/useDebounce.ts @@ -1,4 +1,4 @@ -import { shallowRef, watch, type MaybeRefOrGetter, type Ref } from 'vue' +import { onScopeDispose, shallowRef, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue' import { useState } from './useState' type FunctionArgs = (...args: any[]) => any @@ -8,29 +8,18 @@ const DEFAULT_TIMEOUT = 100 export function useDebounceFn( fn: T, ms?: MaybeRefOrGetter -): T { +): (...args: Parameters) => void { let timeoutId: ReturnType | undefined + const clear = () => timeoutId && clearTimeout(timeoutId) - const resolveDelay = (ms: MaybeRefOrGetter | undefined): number => { - if (typeof ms === 'function') { - return ms() - } else if (typeof ms === 'object' && 'value' in ms) { - return ms.value - } else if (typeof ms === 'number') { - return ms - } else { - return DEFAULT_TIMEOUT - } - } - - const debounced = ((...args: Parameters) => { - timeoutId && clearTimeout(timeoutId) - - timeoutId = setTimeout(() => { - fn(...args) - }, resolveDelay(ms)) - }) as T + const resolveDelay = (): number => toValue(ms ?? DEFAULT_TIMEOUT) + const debounced = ((...args: Parameters): void => { + clear() + timeoutId = setTimeout(() => fn(...args), resolveDelay()) + }) + + onScopeDispose(clear) return debounced } From ba423fd94bdb05d27c7590f140b223e14c273b3a Mon Sep 17 00:00:00 2001 From: Luka <104729643+lmilojevicc@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:26:12 +0200 Subject: [PATCH 38/95] fix: refresh URL limits on SPA route changes Detect in-app navigations via LocationWatcher (history API wrapping, popstate, hashchange, and polling fallback) and re-evaluate limits on route change. Harden URL matching, stabilize e2e tests, and fix backup client list refresh after gist clear. --- examples/host/home/index.html | 8 ++ src/content-script/index.ts | 7 +- src/content-script/limit/index.ts | 51 +++++++++-- .../limit/modal/components/Footer.tsx | 8 +- .../limit/modal/components/Reason.tsx | 6 +- src/content-script/limit/modal/context.ts | 9 +- src/content-script/limit/modal/instance.ts | 11 +++ src/content-script/limit/modal/types.ts | 1 + .../limit/processor/message-adaptor.ts | 18 ++-- .../limit/processor/period-processor.ts | 20 +++-- .../limit/processor/visit-processor.ts | 25 ++++-- src/content-script/limit/types.ts | 6 +- src/content-script/location-watcher.ts | 64 ++++++++++++++ .../Option/categories/Backup/ClientTable.tsx | 3 + src/util/limit.ts | 3 +- test-e2e/backup/common.ts | 71 ++++++++++++---- test-e2e/limit/common.ts | 36 +++++++- test-e2e/limit/daily-limit.test.ts | 41 ++++++++- test/content-script/location-watcher.test.ts | 84 +++++++++++++++++++ test/util/limit.test.ts | 11 +++ 20 files changed, 418 insertions(+), 65 deletions(-) create mode 100644 examples/host/home/index.html create mode 100644 src/content-script/location-watcher.ts create mode 100644 test/content-script/location-watcher.test.ts diff --git a/examples/host/home/index.html b/examples/host/home/index.html new file mode 100644 index 000000000..438757300 --- /dev/null +++ b/examples/host/home/index.html @@ -0,0 +1,8 @@ + + + + +
    Time Tracker test page — home
    + + + diff --git a/src/content-script/index.ts b/src/content-script/index.ts index c99134ca8..cd1d0ebea 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -54,15 +54,14 @@ async function main() { // Execute only one time for each dom if (getOrSetFlag()) return - if (!host) return + if (!host || !url) return + void initLocale() const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) + await processLimit(url, dispatcher) if (isWhitelist) return - void initLocale() void printInfo(host) - await processLimit(url, dispatcher) - processTimeline() // Increase visit count at the end diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index f3e28fd97..fc7eda215 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,16 +1,32 @@ +import { trySendMsg2Runtime } from '@api/sw/common' import { getOption } from '@api/sw/option' +import LocationWatcher from '../location-watcher' import Dispatcher from '../dispatcher' -import ModalInstance from "./modal/instance" +import ModalInstance from './modal/instance' import MessageAdaptor from './processor/message-adaptor' -import PeriodProcessor from "./processor/period-processor" -import VisitProcessor from "./processor/visit-processor" +import PeriodProcessor from './processor/period-processor' +import VisitProcessor from './processor/visit-processor' import Reminder from './reminder' import type { ModalContext, Processor } from './types' +const getHost = (url: string): string | undefined => { + try { + return new URL(url).host + } catch { + return undefined + } +} + +const isWhitelisted = async (url: string): Promise => { + const host = getHost(url) + if (!host) return true + return !!await trySendMsg2Runtime('whitelist.contain', { host, url }) +} + export default async function processLimit(url: string, dispatcher: Dispatcher) { const { limitDelayDuration: delayDuration } = await getOption() const modal = new ModalInstance(url) - const context: ModalContext = { modal, url } + const context: ModalContext = { modal, url: '' } const messageAdaptor = new MessageAdaptor(context, delayDuration) const visitProcessor = new VisitProcessor(context, delayDuration) @@ -22,11 +38,34 @@ export default async function processLimit(url: string, dispatcher: Dispatcher) ] await Promise.all(processors.map(p => p.init())) + let active = false + let refreshId = 0 + const refreshUrl = async (nextUrl: string): Promise => { + if (!nextUrl) return + const currentRefreshId = ++refreshId + const urlChanged = nextUrl !== context.url + context.url = nextUrl + modal.setUrl(nextUrl) + active = false + processors.forEach(p => p.clear(urlChanged)) + const whitelisted = await isWhitelisted(nextUrl) + if (currentRefreshId !== refreshId || whitelisted) return + active = true + await Promise.all(processors.map(p => p.onLimitChanged())) + if (currentRefreshId !== refreshId) return + } + + await refreshUrl(url) + new LocationWatcher(url, nextUrl => void refreshUrl(nextUrl)).init() + const reminder = new Reminder() dispatcher - .register('limitChanged', () => void processors.forEach(p => p.onLimitChanged())) - .register('limitTimeMeet', items => void messageAdaptor.onLimitTimeMeet(items)) + .register('limitChanged', () => void refreshUrl(context.url)) + .register('limitTimeMeet', items => { + if (active) void messageAdaptor.onLimitTimeMeet(items) + return undefined + }) .register('limitReminder', data => void reminder.show(data)) .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) .registerAudibleChange(visitProcessor.tracker) diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/Footer.tsx index a1c212e16..c8ea01f41 100644 --- a/src/content-script/limit/modal/components/Footer.tsx +++ b/src/content-script/limit/modal/components/Footer.tsx @@ -15,8 +15,8 @@ import { useApp, useRule } from '../context' const _default = defineComponent(() => { const { reason, visitTime: currVisitTime, bridge, url, delayDuration } = useApp() - const analysisUrl = getAppPageUrl(APP_ANALYSIS_ROUTE, { url } satisfies AppAnalysisQuery) - const ruleUrl = getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url) } satisfies AppLimitQuery) + const analysisUrl = computed(() => getAppPageUrl(APP_ANALYSIS_ROUTE, { url: url.value } satisfies AppAnalysisQuery)) + const ruleUrl = computed(() => getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url.value) } satisfies AppLimitQuery)) const rule = useRule() const showDelay = computed(() => { @@ -56,7 +56,7 @@ const _default = defineComponent(() => { return () => ( - + {t(msg => msg.menu.siteAnalysis)} @@ -68,7 +68,7 @@ const _default = defineComponent(() => { > {t(msg => msg.modal.delay, { n: delayDuration.value })}
    - + {t(msg => msg.modal.ruleDetail)} diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 4fc4a95dc..9ebdb16df 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -51,7 +51,7 @@ const TimeDescriptions = defineComponent(props => { return () => ( - {renderBaseItems(rule.value, url)} + {renderBaseItems(rule.value, url.value)} {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} @@ -113,7 +113,7 @@ const _default = defineComponent(() => { dataLabel={t(msg => msg.calendar.range.thisWeek)} /> - {renderBaseItems(rule.value, url)} + {renderBaseItems(rule.value, url.value)} msg.limit.item.visitTime)} labelAlign="right"> {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} @@ -127,7 +127,7 @@ const _default = defineComponent(() => { - {renderBaseItems(rule.value, url)} + {renderBaseItems(rule.value, url.value)} msg.shared.limit.period)} labelAlign="right"> {rule.value?.periods?.length ?
    diff --git a/src/content-script/limit/modal/context.ts b/src/content-script/limit/modal/context.ts index bf2fc47fa..cb45d4c26 100644 --- a/src/content-script/limit/modal/context.ts +++ b/src/content-script/limit/modal/context.ts @@ -12,17 +12,20 @@ type AppContext = { reason: ShallowRef visitTime: ShallowRef bridge: ModalBridge - url: string + url: Ref delayDuration: Ref } export const provideApp = (app: App, bridge: ModalBridge, url: string) => { const reason = ref() const visitTime = ref(0) + const currentUrl = ref(url) const delayDuration = ref(5) getOption().then(({ limitDelayDuration }) => delayDuration.value = limitDelayDuration).catch(() => { }) - bridge.register('reason', data => { reason.value = data }) + bridge + .register('reason', data => { reason.value = data }) + .register('url', data => { currentUrl.value = data }) const updateVisitTime = async () => { bridge.request('visitTime', undefined) @@ -40,7 +43,7 @@ export const provideApp = (app: App, bridge: ModalBridge, url: string) _unmount() } - app.provide(GLOBAL_KEY, { reason, visitTime, bridge, url, delayDuration }) + app.provide(GLOBAL_KEY, { reason, visitTime, bridge, url: currentUrl, delayDuration }) } export const useApp = () => inject(GLOBAL_KEY) as AppContext diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts index c97aef856..96ebe0c3a 100644 --- a/src/content-script/limit/modal/instance.ts +++ b/src/content-script/limit/modal/instance.ts @@ -84,6 +84,12 @@ class ModalInstance implements MaskModal { .register('delay', () => this.delayHandlers.forEach(handler => handler())) } + setUrl(url: string): void { + if (url === this.url) return + this.url = url + this.iframe?.contentWindow && this.bridge.request('url', url).catch(() => { }) + } + addReason(...reasons2Add: LimitReason[]): void { reasons2Add = reasons2Add.filter(r => !this.reasons.some(reason => isSameReason(r, reason))) if (!reasons2Add.length) return @@ -141,6 +147,9 @@ class ModalInstance implements MaskModal { const exist = this.rootElement ?? document.querySelector(TAG_NAME) as RootElement if (exist) { this.rootElement = exist + if (!document.body.contains(exist)) { + document.body.appendChild(exist) + } return exist.shadowRoot } this.rootElement = createRootElement() @@ -157,6 +166,8 @@ class ModalInstance implements MaskModal { private async show(reason: LimitReason) { if (!this.rootElement) { await this.init() + } else if (!document.body.contains(this.rootElement)) { + document.body.appendChild(this.rootElement) } await exitFullscreen() pauseAllVideo() diff --git a/src/content-script/limit/modal/types.ts b/src/content-script/limit/modal/types.ts index 05b2a85de..f37ab22b4 100644 --- a/src/content-script/limit/modal/types.ts +++ b/src/content-script/limit/modal/types.ts @@ -10,6 +10,7 @@ type BridgeRegistry = & MakeRegistry<'reason', LimitReasonData | undefined, void> & MakeRegistry<'visitTime', void, number> & MakeRegistry<'delay', void, void> + & MakeRegistry<'url', string, void> export type BridgeCode = keyof BridgeRegistry export type BridgeRequest = BridgeRegistry[C]['req'] diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index e64d33484..9406872a8 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -13,8 +13,8 @@ const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitR class MessageAdaptor implements Processor { constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } - onLimitChanged(): void { - this.initRules() + onLimitChanged(): Promise { + return this.initRules() } onLimitTimeMeet(items: tt4b.limit.Item[]): void { @@ -25,14 +25,18 @@ class MessageAdaptor implements Processor { } async init(): Promise { - this.initRules() - this.context.modal.addDelayHandler(() => this.initRules()) + this.context.modal.addDelayHandler(() => void this.initRules()) } - async initRules(): Promise { + clear(_urlChanged?: boolean): void { this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') - const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url: this.context.url }) - if (!limitedRules?.length) return + } + + async initRules(): Promise { + const url = this.context.url + this.clear() + const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url }) + if (url !== this.context.url || !limitedRules?.length) return const reasons = limitedRules.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) this.context.modal.addReason(...reasons) diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index fe0a84bab..787b217e9 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -10,7 +10,7 @@ function processRule(rule: tt4b.limit.Rule, nowSeconds: number, context: ModalCo const [s, e] = p const startSeconds = s * 60 const endSeconds = (e + 1) * 60 - const reason: LimitReason = { id, cond, type: "PERIOD" } + const reason: LimitReason = { id, cond, type: 'PERIOD' } const timers: ReturnType[] = [] if (nowSeconds < startSeconds) { timers.push(setTimeout(() => context.modal.addReason(reason), (startSeconds - nowSeconds) * MILL_PER_SECOND)) @@ -29,15 +29,23 @@ class PeriodProcessor implements Processor { constructor(private readonly context: ModalContext) { } async onLimitChanged(): Promise { - await this.init() + await this.initRules() } - async init(): Promise { - // Clear first + init(): void { + } + + clear(_urlChanged?: boolean): void { this.timers.forEach(clearTimeout) - this.context.modal.removeReasonsByType("PERIOD") + this.timers = [] + this.context.modal.removeReasonsByType('PERIOD') + } - const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: this.context.url }) ?? [] + private async initRules(): Promise { + const url = this.context.url + this.clear() + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url }) ?? [] + if (url !== this.context.url) return const nowSeconds = date2Idx(new Date()) this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index 80637ccd4..215238880 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -15,8 +15,8 @@ class VisitProcessor implements Processor { }) } - onLimitChanged(): void { - this.initRules() + onLimitChanged(): Promise { + return this.initRules() } private hasLimited(rule: tt4b.limit.Rule): boolean { @@ -35,7 +35,7 @@ class VisitProcessor implements Processor { this.context.modal.addReason({ id, cond, - type: "VISIT", + type: 'VISIT', allowDelay, delayCount: this.delayCount, getVisitTime: () => this.focusTime, @@ -44,19 +44,30 @@ class VisitProcessor implements Processor { } private async initRules() { - this.rules = await trySendMsg2Runtime("limit.list", { effective: true, url: this.context.url }) ?? [] - this.context.modal.removeReasonsByType("VISIT") + const url = this.context.url + this.clear() + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url }) ?? [] + if (url !== this.context.url) return + this.rules = rules } async init(): Promise { this.tracker.init() - this.initRules() this.context.modal.addDelayHandler(() => this.processDelay()) } + clear(urlChanged?: boolean): void { + this.rules = [] + if (urlChanged) { + this.focusTime = 0 + this.delayCount = 0 + } + this.context.modal.removeReasonsByType('VISIT') + } + private processDelay() { this.delayCount++ - this.context.modal.removeReasonsByType("VISIT") + this.context.modal.removeReasonsByType('VISIT') } } diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts index cddfcb183..ac44dca74 100644 --- a/src/content-script/limit/types.ts +++ b/src/content-script/limit/types.ts @@ -10,6 +10,7 @@ export type LimitReason = export interface MaskModal { readonly reasons: LimitReason[] + setUrl(url: string): void addReason(...reasons: LimitReason[]): void removeReason(...reasons: LimitReason[]): void removeReasonsByType(...types: tt4b.limit.ReasonType[]): void @@ -23,5 +24,6 @@ export type ModalContext = { export interface Processor { init(): Awaitable - onLimitChanged(): void -} \ No newline at end of file + onLimitChanged(): Awaitable + clear(urlChanged?: boolean): void +} diff --git a/src/content-script/location-watcher.ts b/src/content-script/location-watcher.ts new file mode 100644 index 000000000..3df99f38b --- /dev/null +++ b/src/content-script/location-watcher.ts @@ -0,0 +1,64 @@ +type UrlChangeHandler = (url: string, prevUrl: string) => void + +class LocationWatcher { + private url: string + private timer: number | undefined + private initialized = false + private originalPushState: History['pushState'] | undefined + private originalReplaceState: History['replaceState'] | undefined + private readonly handleChangeBound = this.handleChange.bind(this) + + constructor(url: string, private readonly handler: UrlChangeHandler, private readonly interval = 800) { + this.url = url + } + + init(): void { + if (this.initialized) return + this.initialized = true + + window.addEventListener('popstate', this.handleChangeBound) + window.addEventListener('hashchange', this.handleChangeBound) + this.timer = window.setInterval(this.handleChangeBound, this.interval) + + this.originalPushState = history.pushState + this.originalReplaceState = history.replaceState + + history.pushState = (...args: Parameters) => { + this.originalPushState!.apply(history, args) + this.handleChangeBound() + } + history.replaceState = (...args: Parameters) => { + this.originalReplaceState!.apply(history, args) + this.handleChangeBound() + } + } + + dispose(): void { + if (!this.initialized) return + this.initialized = false + + window.removeEventListener('popstate', this.handleChangeBound) + window.removeEventListener('hashchange', this.handleChangeBound) + this.timer && clearInterval(this.timer) + this.timer = undefined + + if (this.originalPushState) { + history.pushState = this.originalPushState + this.originalPushState = undefined + } + if (this.originalReplaceState) { + history.replaceState = this.originalReplaceState + this.originalReplaceState = undefined + } + } + + private handleChange(): void { + const url = window.location.href + if (!url || url === this.url) return + const prevUrl = this.url + this.url = url + this.handler(url, prevUrl) + } +} + +export default LocationWatcher diff --git a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx index 50f3a3b7d..d48ac22ff 100644 --- a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx +++ b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx @@ -6,6 +6,7 @@ */ import { allBackupClients } from "@api/sw/backup" +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from '@app/locale' import { cvt2LocaleTime } from '@app/util/time' import { Loading, RefreshRight } from "@element-plus/icons-vue" @@ -34,8 +35,10 @@ const formatTime = (value: tt4b.backup.Client): string => { } const _default = defineComponent<{ onSelect: ArgCallback }>(props => { + const { visible } = useDialogSop() const { data: list, loading, refresh } = useRequest(allBackupClients, { defaultValue: [], + deps: () => visible.value, onError: e => ElMessage.error(e instanceof Error ? e.message : String(e ?? 'Unknown error...')) }) diff --git a/src/util/limit.ts b/src/util/limit.ts index 25b0b702d..960ebf1d6 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -4,7 +4,8 @@ import { getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "./time" const GLOBSTAR_TOKEN = `__GLOBSTAR${Math.random().toString(36).slice(2, 6)}__` const matchUrl = (cond: string, url: string): boolean => { - const pattern = cond + const normalizedCond = cond.length > 1 ? cond.replace(/\/$/, '') : cond + const pattern = normalizedCond .replace(/\*\*/g, GLOBSTAR_TOKEN) .split('*') .join('.*') diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts index 5a64e932f..09e83f770 100644 --- a/test-e2e/backup/common.ts +++ b/test-e2e/backup/common.ts @@ -12,26 +12,60 @@ const typeNames: Record = { const OPTION_TAB_ID = 'pane-backup' -async function waitClientReady(page: Page): Promise | null> { +async function waitForNoVisibleOverlay(page: Page): Promise { await page.waitForFunction(() => { - const emptyBody = document.querySelector('.el-dialog .el-table__empty-block') - const rows = document.querySelectorAll('.el-dialog .el-table .el-table__row') - return !!emptyBody || !!rows.length - }, { timeout: 1000 }) - - const nextBtn = await page.waitForSelector( - '.el-overlay:not([style*="display: none"]) .el-dialog .el-button.el-button--primary:not([disabled])', - { timeout: 1000 }, - ) + return ![...document.querySelectorAll('.el-overlay')].some(o => { + return window.getComputedStyle(o).display !== 'none' + }) + }, { timeout: 5000 }) +} + +async function queryTopVisibleDialog(page: Page): Promise | null> { + const overlays = await page.$$('.el-overlay') + for (const overlay of overlays.reverse()) { + const visible = await overlay.evaluate(el => window.getComputedStyle(el).display !== 'none') + if (!visible) continue + const dialog = await overlay.$('.el-dialog') + if (dialog) return dialog + } + return null +} + +async function waitClientReady(page: Page): Promise> { + await page.waitForFunction(() => { + const overlays = [...document.querySelectorAll('.el-overlay')].filter(o => { + const style = window.getComputedStyle(o) + return style.display !== 'none' + }) + const dialog = overlays.at(-1)?.querySelector('.el-dialog') + if (!dialog) return false + const emptyBody = dialog.querySelector('.el-table__empty-block') + const rows = dialog.querySelectorAll('.el-table .el-table__row') + const tableReady = !!emptyBody || !!rows.length + const nextBtn = dialog.querySelector('.el-button.el-button--primary:not([disabled])') + return tableReady && !!nextBtn + }, { timeout: 5000 }) + + const dialog = await queryTopVisibleDialog(page) + const nextBtn = await dialog?.$('.el-button.el-button--primary:not([disabled])') + if (!nextBtn) throw new Error('Next button not found') return nextBtn } async function findCurrentClientRow(page: Page): Promise | null> { - const table = await page.waitForSelector( - '.el-overlay:not([style*="display: none"]) .el-dialog .el-table', - { timeout: 2000 }, - ) - const rows = await table!.$$('.el-table__row') + await page.waitForFunction(() => { + const overlays = [...document.querySelectorAll('.el-overlay')].filter(o => { + const style = window.getComputedStyle(o) + return style.display !== 'none' + }) + return !!overlays.at(-1)?.querySelector('.el-dialog .el-table') + }, { timeout: 5000 }) + + const dialog = await queryTopVisibleDialog(page) + const tableEl = await dialog?.$('.el-table') + if (!tableEl) return null + + const rows = await tableEl.$$('.el-table__row') for (const row of rows) { const currVisible = await row.evaluate(el => { @@ -96,8 +130,7 @@ export class BackupOptionWrapper { async assertTestInvalid() { const page = await this.clickButton('test') - // The same as mock server - await waitForMessage(page, 'Unauthorized') + await waitForMessage(page, '[401] Invalid token or no permission to access gist') } async assertTestValid() { @@ -175,7 +208,9 @@ export class BackupOptionWrapper { } async clearData() { - const page = await this.clickButton('clear') + const page = await this.page() + await waitForNoVisibleOverlay(page) + await this.clickButton('clear') const nextBtn = await waitClientReady(page) diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index dc1c97aff..b6526b36e 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -2,12 +2,39 @@ import type { ElementHandle, Frame, Page } from "puppeteer" import { fillCondEditor } from "../common/cond-editor" import { sleep } from "../common/util" +export async function waitForLimitModalHidden(page: Page, timeout = 15000): Promise { + await page.waitForFunction( + () => { + const overlay = document.querySelector('extension-time-tracker-overlay') + if (!overlay) return true + const iframe = overlay.shadowRoot?.firstElementChild + return !(iframe instanceof HTMLIFrameElement) + || iframe.style.visibility === 'hidden' + || iframe.style.display === 'none' + }, + { timeout }, + ) +} + +export async function waitForLimitModal(page: Page, timeout = 15000): Promise { + await page.waitForFunction( + () => { + const overlay = document.querySelector('extension-time-tracker-overlay') + if (!overlay) return false + const iframe = overlay.shadowRoot?.firstElementChild + return iframe instanceof HTMLIFrameElement + && iframe.style.visibility !== 'hidden' + && iframe.style.display !== 'none' + }, + { timeout }, + ) +} + export async function waitForLimitFrame(page: Page, timeout = 5000): Promise { return page.waitForFrame(f => f.url().includes('limit.html'), { timeout }) } -export async function isLimitModalVisible(page: Page): Promise { - await page.waitForSelector('extension-time-tracker-overlay', { timeout: 3000 }) +export async function queryLimitModalVisible(page: Page): Promise { return await page.evaluate(async () => { const overlay = document.querySelector('extension-time-tracker-overlay') if (!overlay) return false @@ -18,6 +45,11 @@ export async function isLimitModalVisible(page: Page): Promise { }) } +export async function isLimitModalVisible(page: Page): Promise { + await page.waitForSelector('extension-time-tracker-overlay', { timeout: 3000 }) + return await queryLimitModalVisible(page) +} + export async function createLimitRule(rule: tt4b.limit.Rule, page: Page) { const createButton = await page.$('.el-card:first-child .el-button:last-child') await createButton!.click() diff --git a/test-e2e/limit/daily-limit.test.ts b/test-e2e/limit/daily-limit.test.ts index b0bbe0da5..5495b5eb3 100644 --- a/test-e2e/limit/daily-limit.test.ts +++ b/test-e2e/limit/daily-limit.test.ts @@ -1,6 +1,6 @@ import { useLaunchContext } from '../common/base' import { MOCK_URL, sleep } from '../common/util' -import { createLimitRule, fillTimeLimit, isLimitModalVisible, waitForLimitFrame } from "./common" +import { createLimitRule, fillTimeLimit, isLimitModalVisible, queryLimitModalVisible, waitForLimitFrame, waitForLimitModal, waitForLimitModalHidden } from './common' describe('Daily limit', () => { const context = useLaunchContext() @@ -84,7 +84,44 @@ describe('Daily limit', () => { expect(modalExist).toBeFalsy() }, 60000) - test("Daily visit limit", async () => { + test('blocks expired path after spa navigation', async () => { + const limitTime = 1 + const blockedUrl = `${MOCK_URL}/home` + const limitPage = await context.openAppPage('/behavior/limit') + const demoRule: tt4b.limit.Rule = { + id: 1, name: 'TEST SPA DAILY LIMIT', + cond: [blockedUrl], + time: limitTime, + enabled: true, allowDelay: false, locked: false, + } + + await createLimitRule(demoRule, limitPage) + + // Exhaust limit via full navigation — background time tracking uses tab.url, not pushState + const blockedPage = await context.newPageAndWaitCsInjected(blockedUrl) + await blockedPage.bringToFront() + await waitForLimitModal(blockedPage) + await blockedPage.close() + + const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) + expect(await queryLimitModalVisible(testPage)).toBeFalsy() + + await testPage.bringToFront() + await testPage.evaluate(url => history.pushState({}, '', url), blockedUrl) + await waitForLimitModal(testPage) + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + expect(await queryLimitModalVisible(testPage)).toBeTruthy() + + await testPage.evaluate(url => history.pushState({}, '', url), MOCK_URL) + await waitForLimitModalHidden(testPage) + expect(await queryLimitModalVisible(testPage)).toBeFalsy() + }, 60000) + + test('Daily visit limit', async () => { const limitPage = await context.openAppPage('/behavior/limit') const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY VISIT LIMIT', diff --git a/test/content-script/location-watcher.test.ts b/test/content-script/location-watcher.test.ts new file mode 100644 index 000000000..6de09d9f1 --- /dev/null +++ b/test/content-script/location-watcher.test.ts @@ -0,0 +1,84 @@ +import LocationWatcher from '@cs/location-watcher' + +describe('LocationWatcher', () => { + beforeEach(() => { + history.replaceState({}, '', '/') + }) + + test('pushState triggers handler immediately', () => { + const initialUrl = window.location.href + const handler = rstest.fn() + const watcher = new LocationWatcher(initialUrl, handler) + watcher.init() + + history.pushState({}, '', '/page-a') + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(`${window.location.origin}/page-a`, initialUrl) + + watcher.dispose() + }) + + test('replaceState triggers handler immediately', () => { + const initialUrl = window.location.href + const handler = rstest.fn() + const watcher = new LocationWatcher(initialUrl, handler) + watcher.init() + + history.replaceState({}, '', '/page-b') + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(`${window.location.origin}/page-b`, initialUrl) + + watcher.dispose() + }) + + test('popstate still triggers handler', async () => { + history.replaceState({}, '', '/') + const rootUrl = window.location.href + history.pushState({}, '', '/page-a') + const pageAUrl = window.location.href + const handler = rstest.fn() + const watcher = new LocationWatcher(pageAUrl, handler) + watcher.init() + + const popstate = new Promise(resolve => { + window.addEventListener('popstate', () => resolve(), { once: true }) + }) + history.back() + await popstate + + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(rootUrl, pageAUrl) + + watcher.dispose() + }) + + test('dispose restores native history methods', () => { + const nativePushState = history.pushState + const nativeReplaceState = history.replaceState + const handler = rstest.fn() + const watcher = new LocationWatcher(window.location.href, handler) + watcher.init() + + watcher.dispose() + + expect(history.pushState).toBe(nativePushState) + expect(history.replaceState).toBe(nativeReplaceState) + history.pushState({}, '', '/page-after-dispose') + expect(handler).not.toHaveBeenCalled() + }) + + test('init is idempotent', () => { + const handler = rstest.fn() + const watcher = new LocationWatcher(window.location.href, handler) + watcher.init() + watcher.init() + + history.pushState({}, '', '/page-a') + + expect(handler).toHaveBeenCalledTimes(1) + + watcher.dispose() + }) +}) diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index 209ab07cd..fc6fcbd4f 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -24,6 +24,17 @@ describe('util/limit', () => { expect(matches(cond, 'http://www.bilibili.com/cheese/list')).toBe(false) expect(matches(cond, 'http://t.bilibili.com/')).toBe(true) expect(matches(cond, 'https://www.bilibili.com/video/BV3527/')).toBe(true) + + // Trailing slash checks + expect(matches(['example.com/'], 'https://example.com')).toBe(true) + expect(matches(['example.com'], 'https://example.com/')).toBe(true) + expect(matches(['example.com/path/'], 'https://example.com/path')).toBe(true) + expect(matches(['example.com/path'], 'https://example.com/path/')).toBe(true) + expect(matches(['example.com/path/'], 'https://example.com/path/?foo=bar')).toBe(true) + expect(matches(['example.com/path'], 'https://example.com/path?foo=bar')).toBe(true) + + // Do not collapse a lone root slash into an empty pattern + expect(matches(['/'], 'https://example.com/')).toBe(false) }) test('matchCond', () => { From ce8a121d3ae0f82f1855b2ddecb6a2ae7da4e715 Mon Sep 17 00:00:00 2001 From: Luka <104729643+lmilojevicc@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:17:08 +0200 Subject: [PATCH 39/95] refactor: address review feedback on SPA limit refresh Replace Processor clear/onLimitChanged with onUrlRefreshed context carrying prev/next URL and whitelist state. Rerender ClientTable from Download and Clear dialog invokers instead of watching DialogSop. --- src/content-script/limit/index.ts | 10 ++++---- .../limit/processor/message-adaptor.ts | 20 +++++++++------ .../limit/processor/period-processor.ts | 19 +++++--------- .../limit/processor/visit-processor.ts | 25 +++++++------------ src/content-script/limit/types.ts | 9 +++++-- .../Option/categories/Backup/Clear/index.tsx | 15 ++++++++--- .../Option/categories/Backup/ClientTable.tsx | 3 --- .../categories/Backup/Download/index.tsx | 13 +++++++--- 8 files changed, 59 insertions(+), 55 deletions(-) diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index fc7eda215..ed4c7bf1f 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -43,16 +43,16 @@ export default async function processLimit(url: string, dispatcher: Dispatcher) const refreshUrl = async (nextUrl: string): Promise => { if (!nextUrl) return const currentRefreshId = ++refreshId - const urlChanged = nextUrl !== context.url + const prevUrl = context.url context.url = nextUrl modal.setUrl(nextUrl) active = false - processors.forEach(p => p.clear(urlChanged)) const whitelisted = await isWhitelisted(nextUrl) - if (currentRefreshId !== refreshId || whitelisted) return - active = true - await Promise.all(processors.map(p => p.onLimitChanged())) if (currentRefreshId !== refreshId) return + + await Promise.all(processors.map(p => p.onUrlRefreshed({ prevUrl, nextUrl, whitelisted }))) + if (currentRefreshId !== refreshId) return + active = !whitelisted } await refreshUrl(url) diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 9406872a8..533aa2b80 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,6 +1,6 @@ import { trySendMsg2Runtime } from '@api/sw/common' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import type { LimitReason, ModalContext, Processor } from '../types' +import type { LimitReason, ModalContext, Processor, UrlRefreshContext } from '../types' const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item @@ -13,10 +13,6 @@ const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitR class MessageAdaptor implements Processor { constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } - onLimitChanged(): Promise { - return this.initRules() - } - onLimitTimeMeet(items: tt4b.limit.Item[]): void { if (!items.length) return items.filter(({ cond }) => matches(cond, this.context.url)) @@ -28,13 +24,21 @@ class MessageAdaptor implements Processor { this.context.modal.addDelayHandler(() => void this.initRules()) } - clear(_urlChanged?: boolean): void { + async onUrlRefreshed({ nextUrl, whitelisted }: UrlRefreshContext): Promise { this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') + if (whitelisted) return + await this.fetchLimitedRules(nextUrl) } async initRules(): Promise { - const url = this.context.url - this.clear() + await this.onUrlRefreshed({ + prevUrl: this.context.url, + nextUrl: this.context.url, + whitelisted: false, + }) + } + + private async fetchLimitedRules(url: string): Promise { const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url }) if (url !== this.context.url || !limitedRules?.length) return diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index 787b217e9..6afd64bd5 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -1,7 +1,7 @@ import { trySendMsg2Runtime } from '@api/sw/common' import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import type { LimitReason, ModalContext, Processor } from '../types' +import type { LimitReason, ModalContext, Processor, UrlRefreshContext } from '../types' function processRule(rule: tt4b.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType[] { const { cond, periods, id } = rule @@ -28,27 +28,20 @@ class PeriodProcessor implements Processor { constructor(private readonly context: ModalContext) { } - async onLimitChanged(): Promise { - await this.initRules() - } - init(): void { } - clear(_urlChanged?: boolean): void { + async onUrlRefreshed({ nextUrl, whitelisted }: UrlRefreshContext): Promise { this.timers.forEach(clearTimeout) this.timers = [] this.context.modal.removeReasonsByType('PERIOD') - } + if (whitelisted) return - private async initRules(): Promise { - const url = this.context.url - this.clear() - const rules = await trySendMsg2Runtime('limit.list', { effective: true, url }) ?? [] - if (url !== this.context.url) return + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: nextUrl }) ?? [] + if (nextUrl !== this.context.url) return const nowSeconds = date2Idx(new Date()) this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) } } -export default PeriodProcessor \ No newline at end of file +export default PeriodProcessor diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index 215238880..ff51d2cfc 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -1,7 +1,7 @@ import { trySendMsg2Runtime } from '@api/sw/common' import NormalTracker from "@cs/tracker/normal" import { MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import type { ModalContext, Processor } from '../types' +import type { ModalContext, Processor, UrlRefreshContext } from '../types' class VisitProcessor implements Processor { private focusTime: number = 0 @@ -15,10 +15,6 @@ class VisitProcessor implements Processor { }) } - onLimitChanged(): Promise { - return this.initRules() - } - private hasLimited(rule: tt4b.limit.Rule): boolean { const { visitTime } = rule if (!visitTime) return false @@ -43,26 +39,23 @@ class VisitProcessor implements Processor { }) } - private async initRules() { - const url = this.context.url - this.clear() - const rules = await trySendMsg2Runtime('limit.list', { effective: true, url }) ?? [] - if (url !== this.context.url) return - this.rules = rules - } - async init(): Promise { this.tracker.init() this.context.modal.addDelayHandler(() => this.processDelay()) } - clear(urlChanged?: boolean): void { + async onUrlRefreshed({ prevUrl, nextUrl, whitelisted }: UrlRefreshContext): Promise { this.rules = [] - if (urlChanged) { + if (prevUrl !== nextUrl) { this.focusTime = 0 this.delayCount = 0 } this.context.modal.removeReasonsByType('VISIT') + if (whitelisted) return + + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: nextUrl }) ?? [] + if (nextUrl !== this.context.url) return + this.rules = rules } private processDelay() { @@ -71,4 +64,4 @@ class VisitProcessor implements Processor { } } -export default VisitProcessor \ No newline at end of file +export default VisitProcessor diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts index ac44dca74..b29cd3525 100644 --- a/src/content-script/limit/types.ts +++ b/src/content-script/limit/types.ts @@ -22,8 +22,13 @@ export type ModalContext = { modal: MaskModal } +export type UrlRefreshContext = { + prevUrl: string + nextUrl: string + whitelisted: boolean +} + export interface Processor { init(): Awaitable - onLimitChanged(): Awaitable - clear(urlChanged?: boolean): void + onUrlRefreshed(ctx: UrlRefreshContext): Awaitable } diff --git a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx index 902db37ce..2e619bb74 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx @@ -12,7 +12,7 @@ import { t } from '@app/locale' import { Delete } from "@element-plus/icons-vue" import { BIRTHDAY, formatTimeYMD } from '@util/time' import { ElButton } from "element-plus" -import { defineComponent } from "vue" +import { defineComponent, ref } from "vue" import ClientTable from '../ClientTable' import Step2 from './Step2' import type { ClearForm, StatResult } from './types' @@ -38,7 +38,8 @@ async function fetchStatResult(client: tt4b.backup.Client): Promise } const _default = defineComponent(() => { - const { step, form, open } = initDialogSopContext({ + const tableKey = ref(0) + const { step, form, open: openSop } = initDialogSopContext({ stepCount: STEP_TITLES.length, init: () => ({}), onNext: async ({ form }) => { @@ -53,10 +54,14 @@ const _default = defineComponent(() => { if (errMsg) throw new Error(errMsg) }, }) + const open = () => { + tableKey.value++ + openSop() + } return () => <> - open()}> + {t(msg => msg.option.backup.clear.btn)} { finishButton={{ type: 'danger', text: t(msg => msg.option.backup.clear.btn) }} stepTitles={STEP_TITLES} > - {step.value === 0 ? form.client = c} /> : } + {step.value === 0 + ? form.client = c} /> + : } }) diff --git a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx index d48ac22ff..50f3a3b7d 100644 --- a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx +++ b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx @@ -6,7 +6,6 @@ */ import { allBackupClients } from "@api/sw/backup" -import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from '@app/locale' import { cvt2LocaleTime } from '@app/util/time' import { Loading, RefreshRight } from "@element-plus/icons-vue" @@ -35,10 +34,8 @@ const formatTime = (value: tt4b.backup.Client): string => { } const _default = defineComponent<{ onSelect: ArgCallback }>(props => { - const { visible } = useDialogSop() const { data: list, loading, refresh } = useRequest(allBackupClients, { defaultValue: [], - deps: () => visible.value, onError: e => ElMessage.error(e instanceof Error ? e.message : String(e ?? 'Unknown error...')) }) diff --git a/src/pages/app/components/Option/categories/Backup/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx index ac0d3544c..fed9df3f9 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/index.tsx @@ -12,7 +12,7 @@ import { t } from "@app/locale" import { Files } from "@element-plus/icons-vue" import { BIRTHDAY, formatTimeYMD } from '@util/time' import { ElButton } from "element-plus" -import { defineComponent, toRaw } from "vue" +import { defineComponent, ref, toRaw } from "vue" import ClientTable from '../ClientTable' import Step2 from './Step2' import type { DownloadForm } from './types' @@ -31,7 +31,8 @@ async function fetchData(client: tt4b.backup.Client): Promise { - const { open, step, form } = initDialogSopContext({ + const tableKey = ref(0) + const { open: openSop, step, form } = initDialogSopContext({ stepCount: STEP_TITLES.length, init: () => ({ data: { rows: [], focus: true, time: true }, resolution: undefined }), onNext: async ({ form, target }) => { @@ -49,9 +50,13 @@ const _default = defineComponent(() => { await importOther({ resolution, data: toRaw(data) }) }, }) + const open = () => { + tableKey.value++ + openSop() + } return () => <> - open()}> + {t(msg => msg.option.backup.download.btn)} { stepTitles={STEP_TITLES} finishButton={{ text: t(msg => msg.option.backup.download.btn) }} > - form.client = c} /> + form.client = c} /> From a4bd26a865d4043c9016bbe1c71d74cab0f546af Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 18 Jun 2026 21:30:48 +0800 Subject: [PATCH 40/95] feat: support to custom icon url of websites (#803) --- package.json | 8 +- src/api/sw/site.ts | 8 +- src/background/content-script-handler.ts | 28 ++-- .../version/local-file-initializer.ts | 29 ++-- src/background/message-dispatcher.ts | 7 +- src/background/service/site-service.ts | 31 ++-- src/content-script/index.ts | 30 ++-- src/i18n/chrome/message.ts | 11 -- src/i18n/message/bg/index.ts | 3 + src/i18n/message/bg/notification.ts | 6 +- .../app/components/Record/Table/index.tsx | 19 ++- .../SiteManage/Table/column/AliasColumn.tsx | 7 +- .../app/components/SiteManage/Table/index.tsx | 50 +++---- src/pages/components/EditableImg.tsx | 134 ++++++++++++++++++ src/pages/components/Img.tsx | 15 +- src/util/site.ts | 24 +--- test/util/site.test.ts | 1 - types/common.d.ts | 8 ++ types/tt4b.d.ts | 9 +- 19 files changed, 259 insertions(+), 169 deletions(-) create mode 100644 src/pages/components/EditableImg.tsx diff --git a/package.json b/package.json index a7b7a9a9c..9fdd1d601 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "@commitlint/types": "^21.0.1", "@crowdin/crowdin-api-client": "^1.56.0", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.13", + "@rsdoctor/rspack-plugin": "^1.5.15", "@rspack/cli": "^2.0.8", "@rspack/core": "^2.0.8", - "@rstest/core": "^0.10.5", - "@rstest/coverage-istanbul": "^0.10.5", + "@rstest/core": "^0.10.6", + "@rstest/coverage-istanbul": "^0.10.6", "@types/chrome": "0.1.43", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", @@ -50,7 +50,7 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.16.1", + "knip": "^6.17.1", "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts index 0109e51ae..3b1f6794f 100644 --- a/src/api/sw/site.ts +++ b/src/api/sw/site.ts @@ -12,13 +12,7 @@ export function changeSitesCate(cateId: number | undefined, ...keys: tt4b.site.S return sendMsg2Runtime('site.changeCate', { keys, cateId }) } -export const deleteSiteIcon = (key: tt4b.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) - -export async function changeSiteAlias(key: tt4b.site.SiteKey, alias: string | undefined): Promise { - const trimmed = alias?.trim() || undefined - await sendMsg2Runtime('site.changeAlias', { key, alias: trimmed }) - return trimmed -} +export const modifySite = (param: tt4b.site.ModifyParam) => sendMsg2Runtime('site.modify', param) export const fillInitialAlias = (keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 29189aa5b..5cecc3508 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -6,43 +6,31 @@ */ import { getTab } from '@api/chrome/tab' +import { saveSite } from '@service/site-service' import { IS_ANDROID, IS_CHROME, IS_FIREFOX, IS_SAFARI } from "@util/constant/environment" import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" import MessageDispatcher from "./message-dispatcher" -import { saveAlias, saveIconUrl } from "./service/site-service" import { incVisitCount } from './track-server/normal' -function isUrl(title: string) { - return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') -} - -async function collectAlias(key: tt4b.site.SiteKey, tabTitle: string) { - if (!tabTitle) return - if (isUrl(tabTitle)) return - const siteName = extractSiteName(tabTitle, key.host) - siteName && await saveAlias(key, siteName, true) -} - /** * Process the tab */ async function processTabInfo(tab: ChromeTab): Promise { - let { favIconUrl, url, title } = tab + // Not support to modify site info on Android, so skip it + if (IS_ANDROID) return + let { favIconUrl: iconUrl, url, title } = tab if (!url || !title) return if (isBrowserUrl(url)) return const hostInfo = extractHostname(url) const host = hostInfo.host if (!host) return // localhost hosts with Chrome use cache, so keep the favIcon url undefined - IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const siteKey: tt4b.site.SiteKey = { host, type: 'normal' } - favIconUrl && await saveIconUrl(siteKey, favIconUrl) - !IS_ANDROID - && !isBrowserUrl(url) - && isHomepage(url) - && await collectAlias(siteKey, title) + IS_CHROME && /^localhost(:.+)?/.test(host) && (iconUrl = undefined) + // Only collect site name for homepage + const alias = isHomepage(url) ? extractSiteName(title) : undefined + await saveSite({ host, type: 'normal', alias, iconUrl }, false) } /** diff --git a/src/background/install-handler/version/local-file-initializer.ts b/src/background/install-handler/version/local-file-initializer.ts index 4a19e57fa..356942a8e 100644 --- a/src/background/install-handler/version/local-file-initializer.ts +++ b/src/background/install-handler/version/local-file-initializer.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { t } from '@bg/i18n' import mergeRuleDatabase from "@db/merge-rule-database" -import { t2Chrome } from "@i18n/chrome/t" -import { saveAlias } from '@service/site-service' +import { saveSite } from '@service/site-service' import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" import { type Migrator } from "./types" @@ -27,21 +27,14 @@ export default class LocalFileInitializer implements Migrator { merged: MERGED_HOST, }).then(() => console.log('Local file merge rules initialized')) // Add site name - saveAlias( - { host: PDF_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.pdf), - ) - saveAlias( - { host: JSON_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.json), - ) - saveAlias( - { host: PIC_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.pic), - ) - saveAlias( - { host: TXT_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.txt), - ) + const hostAlias = { + [PDF_HOST]: t(msg => msg.initial.localFile.pdf), + [JSON_HOST]: t(msg => msg.initial.localFile.json), + [PIC_HOST]: t(msg => msg.initial.localFile.pic), + [TXT_HOST]: t(msg => msg.initial.localFile.txt), + } + for (const [host, alias] of Object.entries(hostAlias)) { + void saveSite({ host, type: 'normal', alias, iconUrl: undefined }, true) + } } } \ No newline at end of file diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 5e15c1d4c..c308d4de0 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -23,8 +23,8 @@ import { getInstallTime, getLastBackUp } from "./service/meta-service" import notificationProcessor from './service/notification/processor' import { selectPeriods } from "./service/period-service" import { - addSite, batchChangeCate, fillInitialAlias, getInitialAlias, getSite, removeIconUrl, removeSites, saveAlias, - saveSiteRunState, searchSites, selectSitePage, + addSite, batchChangeCate, fillInitialAlias, getInitialAlias, getSite, removeSites, saveSite, saveSiteRunState, searchSites, + selectSitePage, } from "./service/site-service" import { batchDelete, countGroup, countSite, selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, @@ -84,8 +84,7 @@ class MessageDispatcher { .register('site.add', addSite) .register('site.delete', removeSites) .register('site.changeCate', ({ cateId, keys }) => batchChangeCate(cateId, keys)) - .register('site.deleteIcon', removeIconUrl) - .register('site.changeAlias', ({ key, alias }) => saveAlias(key, alias)) + .register('site.modify', param => saveSite(param, true)) .register('site.fillAlias', fillInitialAlias) .register('site.initialAlias', getInitialAlias) .register('site.changeRun', ({ key, enabled }) => saveSiteRunState(key, enabled)) diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts index e5fb102a1..8dbe9d06a 100644 --- a/src/background/service/site-service.ts +++ b/src/background/service/site-service.ts @@ -18,22 +18,21 @@ import CustomizedHostMergeRuler from './components/host-merge-ruler' import { slicePageResult } from "./components/page-info" import virtualSiteHolder from './components/virtual-site-holder' -export async function saveAlias(key: tt4b.site.SiteKey, alias: string | undefined, noRewrite?: boolean) { - const exist = await siteDatabase.get(key) - if (exist && noRewrite) return - await siteDatabase.save({ ...exist, ...key, alias }) -} - -export async function removeIconUrl(key: tt4b.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.iconUrl - await siteDatabase.save(exist) -} +export async function saveSite(param: tt4b.site.ModifyParam, overwrite: boolean): Promise { + const exist = await siteDatabase.get(param) + const alias = overwrite ? param.alias : exist?.alias ?? param.alias + const iconUrl = param.type === 'normal' + ? (overwrite ? param.iconUrl : exist?.iconUrl ?? param.iconUrl) + : undefined + + // Avoid unnecessary chrome.storage writes + if (!exist) { + if (alias === undefined && iconUrl === undefined) return + } else if (exist.alias === alias && exist.iconUrl === iconUrl) { + return + } -export async function saveIconUrl(key: tt4b.site.SiteKey, iconUrl: string) { - const exist = await siteDatabase.get(key) - await siteDatabase.save({ ...exist, ...key, iconUrl }) + await siteDatabase.save({ ...exist, ...param, alias, iconUrl }) } export async function saveSiteRunState(key: tt4b.site.SiteKey, enabled: boolean) { @@ -203,4 +202,4 @@ async function batchSaveAlias(siteMap: SiteMap): Promise { toSave.push({ ...exist ?? k, alias }) }) await siteDatabase.save(...toSave) -} \ No newline at end of file +} diff --git a/src/content-script/index.ts b/src/content-script/index.ts index cd1d0ebea..64dd9f95b 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -20,23 +20,23 @@ const url = document?.location?.href const FLAG_ID = '__TIMER_INJECTION_FLAG__' + chrome.runtime.id function getOrSetFlag(): boolean { - const pre = document?.getElementById(FLAG_ID) - if (!pre) { - const flag = document.createElement('span') - flag.style && (flag.style.visibility = 'hidden') - flag && (flag.id = FLAG_ID) + const existed = document?.getElementById(FLAG_ID) + if (existed) return true - if (document.readyState === "complete") { - document?.body?.appendChild(flag) - } else { - const oldListener = document.onreadystatechange - document.onreadystatechange = function (ev) { - oldListener?.call(this, ev) - document.readyState === "complete" && document?.body?.appendChild(flag) - } + const flag = document.createElement('span') + flag.style && (flag.style.visibility = 'hidden') + flag && (flag.id = FLAG_ID) + + if (document.readyState === "complete") { + document?.body?.appendChild(flag) + } else { + const oldListener = document.onreadystatechange + document.onreadystatechange = function (ev) { + oldListener?.call(this, ev) + document.readyState === "complete" && document?.body?.appendChild(flag) } } - return !!pre + return false } async function main() { @@ -57,8 +57,8 @@ async function main() { if (!host || !url) return void initLocale() - const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) await processLimit(url, dispatcher) + const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) if (isWhitelist) return void printInfo(host) diff --git a/src/i18n/chrome/message.ts b/src/i18n/chrome/message.ts index 69d2d1d25..1c83b2198 100644 --- a/src/i18n/chrome/message.ts +++ b/src/i18n/chrome/message.ts @@ -7,7 +7,6 @@ import baseMessages, { type BaseMessage } from "../message/common/base" import contextMenusMessages, { type ContextMenusMessage } from "../message/common/context-menus" -import initialMessages, { type InitialMessage } from "../message/common/initial" import metaMessages, { type MetaMessage } from "../message/common/meta" import { merge, type MessageRoot } from "../message/merge" @@ -15,14 +14,12 @@ export type ChromeMessage = { meta: MetaMessage base: BaseMessage contextMenus: ContextMenusMessage - initial: InitialMessage } const MESSAGE_ROOT: MessageRoot = { meta: metaMessages, base: baseMessages, contextMenus: contextMenusMessages, - initial: initialMessages, } const messages = merge(MESSAGE_ROOT) @@ -48,14 +45,6 @@ const placeholder: ChromeMessage = { add2Whitelist: '', removeFromWhitelist: '', }, - initial: { - localFile: { - pdf: '', - json: '', - pic: '', - txt: '', - } - } } function routerPath(root: any, parentPath = '') { diff --git a/src/i18n/message/bg/index.ts b/src/i18n/message/bg/index.ts index acb729e5a..af83fbbb1 100644 --- a/src/i18n/message/bg/index.ts +++ b/src/i18n/message/bg/index.ts @@ -1,4 +1,5 @@ import calendarMessages, { type CalendarMessage } from '../common/calendar' +import initialMessages, { type InitialMessage } from '../common/initial' import metaMessages, { type MetaMessage } from '../common/meta' import { merge, MessageRoot } from '../merge' import notificationMessages, { type NotificationMessage } from './notification' @@ -7,12 +8,14 @@ export type BgMessage = { meta: MetaMessage calendar: CalendarMessage notification: NotificationMessage + initial: InitialMessage } const CHILD_MESSAGES: MessageRoot = { meta: metaMessages, calendar: calendarMessages, notification: notificationMessages, + initial: initialMessages, } export default merge(CHILD_MESSAGES) \ No newline at end of file diff --git a/src/i18n/message/bg/notification.ts b/src/i18n/message/bg/notification.ts index edb0e44a3..92c6df96e 100644 --- a/src/i18n/message/bg/notification.ts +++ b/src/i18n/message/bg/notification.ts @@ -1,7 +1,9 @@ -import resources from "./notification-resource.json" +import resource from "./notification-resource.json" export type NotificationMessage = { dailySummary: string } -export default resources satisfies Messages \ No newline at end of file +const _default: Messages = resource + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/Record/Table/index.tsx b/src/pages/app/components/Record/Table/index.tsx index d7c712c60..ad69e5b31 100644 --- a/src/pages/app/components/Record/Table/index.tsx +++ b/src/pages/app/components/Record/Table/index.tsx @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { changeSiteAlias } from '@api/sw/site' +import { modifySite } from '@api/sw/site' import { listCateStats, listGroupStats, listSiteStats } from "@api/sw/stat" import ContentCard from '@app/components/common/ContentCard' import Editable from '@app/components/common/Editable' @@ -16,8 +16,7 @@ import { useDocumentVisibility, useManualRequest, useRequest, useState } from '@ import Flex from "@pages/components/Flex" import { sum } from "@util/array" import { isRtl } from "@util/document" -import { siteEqual } from "@util/site" -import { getAlias, isSite } from "@util/stat" +import { getAlias } from "@util/stat" import { cvtDateRange2Str } from '@util/time' import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type RenderRowData, type TableInstance } from "element-plus" import { createObjectGuard, createStringUnionGuard, isAny } from 'typescript-guard' @@ -32,13 +31,6 @@ import OperationColumn from "./columns/OperationColumn" import TimeColumn from "./columns/TimeColumn" import VisitColumn from "./columns/VisitColumn" -async function handleAliasChange(key: tt4b.site.SiteKey, newAlias: string | undefined, data: tt4b.stat.Row[]) { - newAlias = await changeSiteAlias(key, newAlias) - data.filter(isSite) - .filter(item => siteEqual(item.siteKey, key)) - .forEach(item => item.alias = newAlias) -} - type ColumnVisible = Record<'index' | 'date' | 'site' | 'cate' | 'group', boolean> const computeVisible = (filter: RecordFilterOption): ColumnVisible => { @@ -112,6 +104,11 @@ const _default = defineComponent((_, ctx) => { () => filter.siteMerge, ], () => table.value?.doLayout?.()) + const { refresh: changeAlias } = useManualRequest((row: tt4b.stat.SiteRow, newAlias: string | undefined) => { + const { siteKey: { type, host }, iconUrl } = row + return modifySite({ type, host, alias: newAlias, iconUrl }) + }, { onSuccess: refresh }) + return () => ( @@ -146,7 +143,7 @@ const _default = defineComponent((_, ctx) => { v-slots={({ row }: { row: tt4b.stat.Row }) => ( 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value.list)} + onChange={newAlias => 'siteKey' in row && changeAlias(row, newAlias)} /> )} /> diff --git a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx index 2f2395ce1..c3789e1fb 100644 --- a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { changeSiteAlias, fillInitialAlias, getInitialAlias } from "@api/sw/site" +import { fillInitialAlias, getInitialAlias, modifySite } from "@api/sw/site" import Editable from "@app/components/common/Editable" import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' import { t } from '@app/locale' @@ -18,7 +18,10 @@ import { defineComponent, type StyleValue } from "vue" const AliasColumn = defineComponent<{}>(() => { const { pagination, refresh } = useSiteManageTable() - const { refresh: doChange } = useManualRequest(changeSiteAlias, { onSuccess: refresh }) + const { refresh: doChange } = useManualRequest((row: tt4b.site.SiteInfo, alias: string | undefined) => { + const { type, host, iconUrl } = row + return modifySite({ type, host, alias, iconUrl }) + }, { onSuccess: refresh }) const handleBatchGenerate = async () => { let { list } = pagination.value diff --git a/src/pages/app/components/SiteManage/Table/index.tsx b/src/pages/app/components/SiteManage/Table/index.tsx index c8a9933e5..583826449 100644 --- a/src/pages/app/components/SiteManage/Table/index.tsx +++ b/src/pages/app/components/SiteManage/Table/index.tsx @@ -4,12 +4,13 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { changeSiteRun, deleteSiteIcon } from '@api/sw/site' +import { changeSiteRun, modifySite } from '@api/sw/site' import Category from '@app/components/common/Category' import HostAlert from '@app/components/common/HostAlert' import { t } from '@app/locale' -import Flex from "@pages/components/Flex" -import Img from '@pages/components/Img' +import { useManualRequest } from '@hooks' +import EditableImg from '@pages/components/EditableImg' +import Flex from '@pages/components/Flex' import { ElSwitch, ElTable, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" import { useSiteManageTable } from '../useSiteManage' @@ -17,19 +18,17 @@ import AliasColumn from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" import TypeColumn from "./column/TypeColumn" +type RenderParam = RenderRowData + const _default = defineComponent<{}>(() => { const { setSelected, refresh, pagination } = useSiteManageTable() - const handleIconError = async (row: tt4b.site.SiteInfo) => { - await deleteSiteIcon(row) - row.iconUrl = undefined - } + const { refresh: changeIcon } = useManualRequest((row: tt4b.site.SiteInfo, iconUrl: string | undefined) => { + const { type, host, alias } = row + return modifySite({ type, host, alias, iconUrl }) + }, { onSuccess: refresh }) - const handleRunChange = async (val: boolean, row: tt4b.site.SiteInfo) => { - await changeSiteRun(row, val) - row.run = val - refresh() - } + const { refresh: changeRun } = useManualRequest(changeSiteRun, { onSuccess: refresh }) return () => ( (() => { label={t(msg => msg.item.host)} minWidth={220} align="center" - v-slots={({ row }: RenderRowData) => ( + v-slots={({ row }: RenderParam) => (
    @@ -54,22 +53,23 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.siteManage.column.icon)} minWidth={100} align="center" - v-slots={({ row }: RenderRowData) => { - const { iconUrl } = row || {} - if (!iconUrl) return '' - return ( - - handleIconError(row)} /> - - ) - }} + v-slots={({ row }: RenderParam) => row.type === 'normal' && ( + + changeIcon(row, undefined)} + onSave={url => changeIcon(row, url)} + /> + + )} /> msg.siteManage.column.cate)} minWidth={140} align="center" - v-slots={({ row }: RenderRowData) => ( + v-slots={({ row }: RenderParam) => ( row.cate = val} /> )} /> @@ -78,11 +78,11 @@ const _default = defineComponent<{}>(() => { width={100} align="center" > - {({ row }: RenderRowData) => row.type === 'normal' && ( + {({ row }: RenderParam) => row.type === 'normal' && ( handleRunChange(!!val, row)} + onChange={val => changeRun(row, !!val)} /> )} diff --git a/src/pages/components/EditableImg.tsx b/src/pages/components/EditableImg.tsx new file mode 100644 index 000000000..890d7b9e9 --- /dev/null +++ b/src/pages/components/EditableImg.tsx @@ -0,0 +1,134 @@ +import { t } from '@app/locale' +import { Check, Close, Edit, Picture } from '@element-plus/icons-vue' +import { useState } from '@hooks' +import { ElButton, ElIcon, ElInput, ElPopover, ElText } from 'element-plus' +import { computed, CSSProperties, defineComponent, ref, watch } from 'vue' +import Flex from './Flex' +import Img, { ALL_IMG_PROPS, type ImgProps } from './Img' + +type EditableImgProps = ImgProps & { + onSave: ArgCallback +} + +type ImgSetupProps = Pick & { + initial: string + onClose: NoArgCallback +} + +type SetupState = 'empty' | 'error' | 'ready' +const STATE_STYLES: Record = { + empty: { + borderColor: 'var(--el-border-color-lighter)', + backgroundColor: 'var(--el-fill-color-blank)' + }, + error: { + borderColor: 'var(--el-color-danger)', + backgroundColor: 'var(--el-color-danger-light-9)' + }, + ready: { + borderColor: 'var(--el-color-success)', + backgroundColor: 'var(--el-color-success-light-9)' + } +} + +const ImgSetup = defineComponent(props => { + const url = ref(props.initial) + const [error, setError, resetError] = useState() + const state = computed(() => { + if (!url.value) return 'empty' + if (error.value) return 'error' + return 'ready' + }) + watch(url, val => { + if (!val.trim()) return resetError() + try { + new URL(val) + resetError() + } catch { + setError('INVALID URL') + } + }) + + const handleSave = () => { + if (state.value === 'error') return + props.onSave(url.value.trim() || undefined) + } + + return () => ( + + + + + + + {error.value} + + setError("FAILED TO LOAD")} + size={80} + style={{ objectFit: 'contain' }} + /> + + url.value = val} + size="small" + clearable + placeholder="e.g. https://example.com/image.png" + type={error.value ? 'error' : (url.value ? 'success' : '')} + /> + + + {t(msg => msg.button.cancel)} + + + {t(msg => msg.button.save)} + + + + ) +}, { props: ['initial', 'onSave', 'onClose'] }) + +const EditableImg = defineComponent(props => { + const visible = ref(false) + const hide = () => visible.value = false + const handleSave = (url: string | undefined) => { + props.onSave(url) + hide() + } + + return () => ( + + + visible.value = val} + placement="top" + width={280} + trigger="click" + // Destroy when inactive to reset url + persistent={false} + v-slots={{ + reference: () => , + default: () => + }} + /> + + ) +}, { props: [...ALL_IMG_PROPS, 'onSave'] }) + +export default EditableImg \ No newline at end of file diff --git a/src/pages/components/Img.tsx b/src/pages/components/Img.tsx index 811509d5d..6c873980d 100644 --- a/src/pages/components/Img.tsx +++ b/src/pages/components/Img.tsx @@ -1,23 +1,24 @@ import { useState } from '@hooks' -import { type CSSProperties, defineComponent, toRef } from 'vue' +import { type CSSProperties, defineComponent, watch } from 'vue' -type Props = Partial> & { +export type ImgProps = Partial> & { style?: CSSProperties onError?: ArgCallback size?: number } +export const ALL_IMG_PROPS: (keyof ImgProps)[] = ['src', 'alt', 'title', 'style', 'onError', 'size'] -const Img = defineComponent(props => { - const src = toRef(props, 'src') +const Img = defineComponent(props => { const [imgErr, setImgErr] = useState(false) + watch(() => props.src, () => setImgErr(false)) const handleError = (event: Event) => { setImgErr(true) props?.onError?.(event) } - return () => !src.value || imgErr.value ? null : ( + return () => !props.src || imgErr.value ? null : ( {props.alt}(props => { style={props.style} /> ) -}, { props: ['src', 'alt', 'size', 'style', 'title', 'onError'] }) +}, { props: ALL_IMG_PROPS }) export default Img \ No newline at end of file diff --git a/src/util/site.ts b/src/util/site.ts index 28524c248..87abb3264 100644 --- a/src/util/site.ts +++ b/src/util/site.ts @@ -9,11 +9,6 @@ const SEPARATORS = /[-|–_::,]/ const INVALID_SITE_NAME = /(登录)|(我的)|(个人)|(主页)|(首页)|(Welcome)/ -const SPECIAL_MAP: Record = { - // 哔哩哔哩 (゜-゜)つロ 干杯~-bilibili - 'www.bilibili.com': 'bilibili' -} - /** * Extract the site name from the title of tab * @@ -21,14 +16,11 @@ const SPECIAL_MAP: Record = { * @returns siteName, undefined if disable to detect * @since 0.5.1 */ -export function extractSiteName(title: string, host?: string): string | undefined { +export function extractSiteName(title: string): string | undefined { title = title.trim() - if (!title) { - return undefined - } - if (host && SPECIAL_MAP[host]) { - return SPECIAL_MAP[host] - } + if (!title) return undefined + if (title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://')) return undefined + return title .split(SEPARATORS) .filter(s => !INVALID_SITE_NAME.test(s)) @@ -59,12 +51,6 @@ export function supportCategory(siteKey: tt4b.site.SiteKey | undefined): boolean return type === 'normal' } -export function siteEqual(a: tt4b.site.SiteKey | undefined, b: tt4b.site.SiteKey | undefined) { - if (!a && !b) return true - if (a === b) return true - return a?.host === b?.host && a?.type === b?.type -} - /** * Marked the category ID of sites those don't set up category */ @@ -154,4 +140,4 @@ export class SiteMap { if (!func) return Object.values(this.innerMap).forEach(([k, v], idx) => func(k, v, idx)) } -} \ No newline at end of file +} diff --git a/test/util/site.test.ts b/test/util/site.test.ts index a39cea4aa..69c095e54 100644 --- a/test/util/site.test.ts +++ b/test/util/site.test.ts @@ -15,7 +15,6 @@ test('extract site name', () => { expect(extractSiteName('Office 365 登录 | Microsoft Office')).toEqual('Microsoft Office') expect(extractSiteName('首页 - 知乎')).toEqual('知乎') - expect(extractSiteName('哔哩哔哩 (゜-゜)つロ 干杯~-bilibili', 'www.bilibili.com')).toEqual('bilibili') expect(extractSiteName('SurveyMonkey: The World’s Most Popular Free Online Survey Tool')).toEqual('SurveyMonkey') }) diff --git a/types/common.d.ts b/types/common.d.ts index 81c768d1b..fa3531c3f 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -22,6 +22,14 @@ type RequiredPick = Required> type Exactly = T & Record, never> +type RequiredKeys = { [K in keyof T]-?: {} extends Pick ? never : K }[keyof T] +type OptionalKeys = { [K in keyof T]-?: {} extends Pick ? K : never }[keyof T] +type MakeOptionalUndefined = { + [K in RequiredKeys]: T[K] +} & { + [K in OptionalKeys]-?: T[K] | undefined +} extends infer O ? { [K in keyof O]: O[K] } : never + /** * Tuple with length * diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts index 8cfe5c397..59e87cc8b 100644 --- a/types/tt4b.d.ts +++ b/types/tt4b.d.ts @@ -226,11 +226,7 @@ declare namespace tt4b { keys: SiteKey[] } - type ChangeAliasParam = { - key: SiteKey - // Undefined means delete alias - alias: string | undefined - } + type ModifyParam = MakeOptionalUndefined> } namespace merge { @@ -1119,10 +1115,9 @@ declare namespace tt4b { & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> & _MakeRegistry<'site.page', site.PageQuery | undefined, common.PageResult> & _MakeRegistry<'site.add', site.SiteInfo, string | undefined> + & _MakeRegistry<'site.modify', site.ModifyParam> & _MakeRegistry<'site.delete', site.SiteKey[]> & _MakeRegistry<'site.changeCate', site.ChangeCateParam> - & _MakeRegistry<'site.deleteIcon', site.SiteKey> - & _MakeRegistry<'site.changeAlias', site.ChangeAliasParam> & _MakeRegistry<'site.fillAlias', site.SiteKey[]> & _MakeRegistry<'site.initialAlias', string, string | undefined> & _MakeRegistry<'site.changeRun', { key: site.SiteKey; enabled: boolean }> From 55888b5c7061faa1464b9e8c3ce339e113cf025d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 20 Jun 2026 02:27:24 +0800 Subject: [PATCH 41/95] refactor: localRef & localReactive --- src/i18n/message/app/analysis-resource.json | 14 -- src/i18n/message/app/analysis.ts | 1 - src/pages/app/Layout/menu/Side.tsx | 7 +- .../components/AnalysisFilter/index.tsx | 4 +- .../Analysis/components/Trend/Filter.tsx | 6 +- .../Analysis/components/Trend/Total.tsx | 2 +- .../Analysis/components/Trend/context.ts | 26 ++-- src/pages/app/components/Analysis/context.ts | 9 +- .../Dashboard/components/Timeline/context.ts | 5 +- .../Dashboard/components/TopKVisit/context.ts | 8 +- .../app/components/Habit/HabitFilter.tsx | 7 +- .../app/components/Habit/Period/Filter.tsx | 7 +- .../app/components/Habit/Period/context.ts | 18 ++- .../Habit/Site/DailyTrend/Wrapper.ts | 5 +- .../Habit/Site/Distribution/Wrapper.ts | 2 +- .../app/components/Habit/Site/Summary.tsx | 2 +- src/pages/app/components/Habit/Site/common.ts | 5 +- .../app/components/Habit/Site/context.ts | 2 +- src/pages/app/components/Habit/context.ts | 5 +- .../components/Limit/components/Filter.tsx | 17 +-- .../Limit/components/Table/index.tsx | 21 ++- .../components/Record/Filter/BatchDelete.tsx | 19 ++- .../app/components/Record/Filter/index.tsx | 13 +- src/pages/app/components/Record/common.ts | 14 +- src/pages/app/components/Record/context.ts | 97 ++++++-------- src/pages/app/components/Record/types.d.ts | 5 +- .../index.tsx} | 75 +++++++---- .../components/SiteManage/Filter/useBatch.ts | 80 ++++++++++++ .../SiteManage/Table/column/AliasColumn.tsx | 2 +- .../Table/column/OperationColumn.tsx | 2 +- .../app/components/SiteManage/Table/index.tsx | 6 +- .../{useSiteManage.ts => context.ts} | 37 ++++-- src/pages/app/components/SiteManage/index.tsx | 121 ++---------------- ...{ButtonFilterItem.tsx => ButtonFilter.tsx} | 10 +- ...angeFilterItem.tsx => DateRangeFilter.tsx} | 28 ++-- .../{InputFilterItem.tsx => InputFilter.tsx} | 14 +- .../common/filter/MultiSelectFilter.tsx | 26 ++++ .../common/filter/MultiSelectFilterItem.tsx | 30 ----- .../components/common/filter/SelectFilter.tsx | 27 ++++ .../common/filter/SelectFilterItem.tsx | 30 ----- .../components/common/filter/SwitchFilter.tsx | 27 ++++ .../common/filter/SwitchFilterItem.tsx | 28 ---- .../common/filter/TimeFormatFilter.tsx | 28 ++++ .../common/filter/TimeFormatFilterItem.tsx | 30 ----- .../app/components/common/filter/common.ts | 21 +-- .../app/components/common/filter/index.ts | 8 ++ src/pages/hooks/index.ts | 1 - src/pages/hooks/useCached.ts | 39 ------ src/pages/hooks/useLocalStorage.ts | 53 ++++---- src/pages/popup/components/Footer/Menu.tsx | 4 +- src/pages/popup/components/Footer/index.tsx | 2 +- src/pages/popup/components/Header/Option.tsx | 2 +- src/pages/popup/context.ts | 79 +++++------- src/pages/popup/router.ts | 10 +- src/util/time.ts | 60 +++++---- test/pages/hooks/useLocalStorage.test.ts | 97 ++++++++++++++ test/util/time.test.ts | 35 ++++- 57 files changed, 679 insertions(+), 654 deletions(-) rename src/pages/app/components/SiteManage/{SiteManageFilter.tsx => Filter/index.tsx} (51%) create mode 100644 src/pages/app/components/SiteManage/Filter/useBatch.ts rename src/pages/app/components/SiteManage/{useSiteManage.ts => context.ts} (54%) rename src/pages/app/components/common/filter/{ButtonFilterItem.tsx => ButtonFilter.tsx} (84%) rename src/pages/app/components/common/filter/{DateRangeFilterItem.tsx => DateRangeFilter.tsx} (90%) rename src/pages/app/components/common/filter/{InputFilterItem.tsx => InputFilter.tsx} (79%) create mode 100644 src/pages/app/components/common/filter/MultiSelectFilter.tsx delete mode 100644 src/pages/app/components/common/filter/MultiSelectFilterItem.tsx create mode 100644 src/pages/app/components/common/filter/SelectFilter.tsx delete mode 100644 src/pages/app/components/common/filter/SelectFilterItem.tsx create mode 100644 src/pages/app/components/common/filter/SwitchFilter.tsx delete mode 100644 src/pages/app/components/common/filter/SwitchFilterItem.tsx create mode 100644 src/pages/app/components/common/filter/TimeFormatFilter.tsx delete mode 100644 src/pages/app/components/common/filter/TimeFormatFilterItem.tsx create mode 100644 src/pages/app/components/common/filter/index.ts delete mode 100644 src/pages/hooks/useCached.ts create mode 100644 test/pages/hooks/useLocalStorage.test.ts diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 04f37e5a0..8213d5fc7 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -21,7 +21,6 @@ "trend": { "title": "区间趋势", "activeDay": "活跃天数", - "totalDay": "区间总天数", "maxFocus": "单日最大浏览时长", "averageFocus": "单日平均浏览时长", "maxVisit": "单日最大访问次数", @@ -52,7 +51,6 @@ "trend": { "title": "時段趨勢", "activeDay": "活躍天數", - "totalDay": "時段總天數", "maxFocus": "單日最高瀏覽時數", "averageFocus": "單日均瀏覽時數", "maxVisit": "單日最高造訪次數", @@ -83,7 +81,6 @@ "trend": { "title": "Trends", "activeDay": "Active days", - "totalDay": "Period days", "maxFocus": "Daily maximum browsing time", "averageFocus": "Daily average browsing time", "maxVisit": "Daily maximum visits", @@ -114,7 +111,6 @@ "trend": { "title": "レンジトレンド", "activeDay": "アクティブな日", - "totalDay": "間隔の合計日数", "maxFocus": "1 日の最大閲覧時間", "averageFocus": "1 日あたりの平均閲覧時間", "maxVisit": "1 日あたりの最大訪問数", @@ -145,7 +141,6 @@ "trend": { "title": "Tendências", "activeDay": "Dias ativos", - "totalDay": "Total dias", "maxFocus": "Máx. tempo navegação", "averageFocus": "Méd. tempo navegação", "maxVisit": "Máx. visitas", @@ -176,7 +171,6 @@ "trend": { "title": "Тенденції", "activeDay": "Активні дні", - "totalDay": "Дні періоду", "maxFocus": "Максимальний час перегляду на день", "averageFocus": "Середній час перегляду на день", "maxVisit": "Максимальна кількість відвідувань на день", @@ -207,7 +201,6 @@ "trend": { "title": "Tendencias", "activeDay": "Días activos", - "totalDay": "Días del período", "maxFocus": "Tiempo máximo de navegación diario", "averageFocus": "Tiempo promedio de navegación diario", "maxVisit": "Visitas máximas diarias", @@ -238,7 +231,6 @@ "trend": { "title": "Trends", "activeDay": "Aktive Tage", - "totalDay": "Zeitraum Tage", "maxFocus": "Tägliche maximale Surfzeit", "averageFocus": "Tägliche durchschnittliche Surfzeit", "maxVisit": "Tägliche maximale Besuche", @@ -269,7 +261,6 @@ "trend": { "title": "Tendances", "activeDay": "Jours actifs", - "totalDay": "Période de temps", "maxFocus": "Temps de navigation maximum quotidien", "averageFocus": "Durée moyenne de navigation quotidienne", "maxVisit": "Visites maximales quotidiennes", @@ -300,7 +291,6 @@ "trend": { "title": "Тренды", "activeDay": "Активные дни", - "totalDay": "Дни периода", "maxFocus": "Ежедневное максимальное время просмотра", "averageFocus": "Ежедневное среднее время просмотра", "maxVisit": "Ежедневные максимальные посещения", @@ -331,7 +321,6 @@ "trend": { "title": "الاتجاهات", "activeDay": "أيام نشطة", - "totalDay": "إجمالي الأيام", "maxFocus": "الحد الأقصى لوقت التصفح اليومي", "averageFocus": "متوسط ​​وقت التصفح اليومي", "maxVisit": "الحد الأقصى للزيارات اليومية", @@ -362,7 +351,6 @@ "trend": { "title": "Eğilimler", "activeDay": "Aktif Günler", - "totalDay": "Periyot günleri", "maxFocus": "Günlük maksimum gezinme süresi", "averageFocus": "Günlük ortalama gezinme süresi", "maxVisit": "Günlük maksimum ziyaret sayısı", @@ -393,7 +381,6 @@ "trend": { "title": "Trendy", "activeDay": "Aktywnych dni", - "totalDay": "Okres dni", "maxFocus": "Maksymalny dzienny czas przeglądania", "averageFocus": "Średni dzienny czas przeglądania", "maxVisit": "Maksymalna dzienna ilość wizyt", @@ -424,7 +411,6 @@ "trend": { "title": "Tendenze", "activeDay": "Giorni attivi", - "totalDay": "Periodo: Giorni", "maxFocus": "Tempo di navigazione massimo giornaliero", "averageFocus": "Tempo medio di navigazione giornaliero", "maxVisit": "Visite massime giornaliere", diff --git a/src/i18n/message/app/analysis.ts b/src/i18n/message/app/analysis.ts index b1b87a9c4..dba28cb65 100644 --- a/src/i18n/message/app/analysis.ts +++ b/src/i18n/message/app/analysis.ts @@ -29,7 +29,6 @@ export type AnalysisMessage = { trend: { title: string activeDay: string - totalDay: string maxFocus: string averageFocus: string maxVisit: string diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index dfd082a99..9f1f899a6 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -9,10 +9,11 @@ import { getVersion } from '@api/chrome/runtime' import { t } from '@app/locale' import { Expand, Fold } from '@element-plus/icons-vue' import { css } from '@emotion/css' -import { useCached, useState } from '@hooks' +import { localRef, useState } from '@hooks' import Flex from '@pages/components/Flex' import { colorVariant } from '@pages/util/style' import { ElCollapseTransition, ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, ElScrollbar, ElText, ElTooltip, useNamespace } from "element-plus" +import { isBoolean } from 'typescript-guard' import { defineComponent, h, nextTick, onMounted, type Ref, ref, type StyleValue, watch } from "vue" import { type Router, useRouter } from "vue-router" import { indexOfItem, menuGroups, type MenuItem } from "./item" @@ -20,12 +21,12 @@ import { handleClick, initTitle } from "./route" import { colorMenu } from './style' const useCollapseState = () => { - const [collapsed, setCollapsed] = useCached('menu-collapsed', false) + const collapsed = localRef('menu-collapsed', isBoolean, false) const [tooltipVisible, setTooltipVisible] = useState(false) const toggle = () => { setTooltipVisible(false) - nextTick(() => setCollapsed(!collapsed.value)) + nextTick(() => collapsed.value = !collapsed.value) } return { diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx index 068360266..bd89f3bce 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx @@ -6,7 +6,7 @@ */ import { useAnalysisTimeFormat } from "@app/components/Analysis/context" -import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' +import { TimeFormatFilter } from '@app/components/common/filter' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import TargetSelect from "./TargetSelect" @@ -17,7 +17,7 @@ const AnalysisFilter = defineComponent(() => { return () => ( - timeFormat.value = val} /> diff --git a/src/pages/app/components/Analysis/components/Trend/Filter.tsx b/src/pages/app/components/Analysis/components/Trend/Filter.tsx index 82433d9c2..2ca44d06c 100644 --- a/src/pages/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Filter.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' +import { DateRangeFilter } from '@app/components/common/filter' import { t } from "@app/locale" import type { ElDatePickerShortcut } from "@pages/element-ui/types" import { daysAgo } from "@util/time" @@ -23,12 +23,12 @@ const _default = defineComponent(() => { const dateRange = useAnalysisTrendDateRange() return () => ( - date.getTime() > new Date().getTime()} shortcuts={SHORTCUTS} clearable={false} - onChange={newVal => newVal && (dateRange.value = newVal)} + onChange={([s, e]) => s && e && (dateRange.value = [s, e])} /> ) }) diff --git a/src/pages/app/components/Analysis/components/Trend/Total.tsx b/src/pages/app/components/Analysis/components/Trend/Total.tsx index 9b12f3489..6af00e260 100644 --- a/src/pages/app/components/Analysis/components/Trend/Total.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Total.tsx @@ -33,7 +33,7 @@ const _default = defineComponent(props => { return () => ( msg.analysis.trend.activeDay)}/${t(msg => msg.analysis.trend.totalDay)}`} + mainName={t(msg => msg.analysis.trend.activeDay)} mainValue={computeDayValue(props.activeDay, rangeLength.value)} subRing={props.activeDay} containerStyle={INDICATOR_CONTAINER_STYLE} diff --git a/src/pages/app/components/Analysis/components/Trend/context.ts b/src/pages/app/components/Analysis/components/Trend/context.ts index 33b79937b..2fa52c2f6 100644 --- a/src/pages/app/components/Analysis/components/Trend/context.ts +++ b/src/pages/app/components/Analysis/components/Trend/context.ts @@ -14,7 +14,7 @@ import { daysAgo, getAllDatesBetween, getDayLength, MILL_PER_DAY } from "@util/t import { computed, onMounted, ref, watch, type Ref } from "vue" type Context = { - dateRange: Ref<[Date?, Date?]> + dateRange: Ref<[number, number]> rangeLength: Ref } @@ -36,7 +36,7 @@ type IndicatorSet = Record] { - const [start, end] = dateRange || [] - const allDates = start && end ? getAllDatesBetween(start, end) : [] - if (!rows) { + const [start, end] = dateRange + const allDates = getAllDatesBetween(start, end) + if (!rows.length) { // No data return [undefined, toMap(allDates, date => date, _l => undefined)] } @@ -88,12 +88,11 @@ function computeIndicatorSet( return [indicators, fullPeriodRow] } -function lastRange(dateRange: [Date, Date]): [Date, Date] | undefined { - const [start, end] = dateRange || [] - if (!start || !end) return undefined +function lastRange(dateRange: [number, number]): [number, number] { + const [start, end] = dateRange const dayLength = getDayLength(start, end) - const newEnd = new Date(start.getTime() - MILL_PER_DAY) - const newStart = new Date(start.getTime() - MILL_PER_DAY * dayLength) + const newEnd = start - MILL_PER_DAY + const newStart = start - MILL_PER_DAY * dayLength return [newStart, newEnd] } @@ -132,8 +131,9 @@ function handleDataChange(source: SourceParam, effect: EffectParam) { const NAMESPACE = 'siteAnalysis_trend' export const initAnalysisTrend = () => { - const dateRange = ref(daysAgo(14, 0)) - const rangeLength = computed(() => getDayLength(dateRange.value?.[0], dateRange.value?.[1])) + const [defaultStart, defaultEnd] = daysAgo(14, 0) + const dateRange = ref<[number, number]>([defaultStart.getTime(), defaultEnd.getTime()]) + const rangeLength = computed(() => getDayLength(dateRange.value[0], dateRange.value[1])) const visitData = ref() const focusData = ref() diff --git a/src/pages/app/components/Analysis/context.ts b/src/pages/app/components/Analysis/context.ts index b7834ad77..f5f576d14 100644 --- a/src/pages/app/components/Analysis/context.ts +++ b/src/pages/app/components/Analysis/context.ts @@ -8,9 +8,9 @@ import { type AppAnalysisQuery } from '@/shared/route' import { listCateStats, listSiteStats } from "@api/sw/stat" import { isTimeFormat } from '@app/util/limit/types' -import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" +import { localRef, useProvide, useProvider, useRequest } from "@hooks" import { extractHostname } from '@util/pattern' -import { ref, watch, type Ref } from "vue" +import { ref, type Ref } from "vue" import { useRoute, useRouter } from "vue-router" import type { AnalysisTarget } from "./types" @@ -50,10 +50,7 @@ const NAMESPACE = 'siteAnalysis' export const initAnalysis = () => { const target = ref(parseQuery()) - - const [cached, setCached] = useLocalStorage('analysis_timeFormat', isTimeFormat, 'default') - const timeFormat = ref(cached) - watch(timeFormat, setCached) + const timeFormat = localRef('analysis_timeFormat', isTimeFormat, 'default') const { data: rows, loading } = useRequest(() => queryRows(target.value), { deps: target, defaultValue: [] }) useProvide(NAMESPACE, { target, timeFormat, rows }) diff --git a/src/pages/app/components/Dashboard/components/Timeline/context.ts b/src/pages/app/components/Dashboard/components/Timeline/context.ts index 66f6bd458..d5154be6f 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/context.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/context.ts @@ -19,8 +19,9 @@ type ContextValue = { } export const initTimelineContext = () => { - const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) - const dates = getAllDatesBetween(new Date(start), new Date(), formatYAxis) + const end = Date.now() + const start = getStartOfDay(end - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) + const dates = getAllDatesBetween(start, end, formatYAxis) const [merge, setMerge] = useState('none') const { data: activities } = useRequest( () => sendMsg2Runtime('timeline.list', { start, merge: merge.value }), diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts index 1b3659e39..42795f11b 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts @@ -1,8 +1,8 @@ import { getSiteStatPage } from "@api/sw/stat" -import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" +import { localReactive, useProvide, useProvider, useRequest } from "@hooks" import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" import { createObjectGuard, createStringUnionGuard, isInt } from 'typescript-guard' -import { reactive, type ShallowRef, toRaw, watch } from "vue" +import { type ShallowRef } from "vue" export type BizOption = { name: string @@ -34,11 +34,9 @@ type Context = { const NAMESPACE = 'dashboardTopKVisit' export const initProvider = () => { - const [cachedFilter, setFilterCache] = useLocalStorage( + const filter = localReactive( `${NAMESPACE}_filter`, isTopKFilterOption, { topK: 6, dayNum: 30, topKChartType: 'pie' } ) - const filter = reactive(cachedFilter) - watch(() => filter, () => setFilterCache(toRaw(filter)), { deep: true }) const { data: value } = useRequest(async () => { const now = new Date() const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * filter.dayNum) diff --git a/src/pages/app/components/Habit/HabitFilter.tsx b/src/pages/app/components/Habit/HabitFilter.tsx index f2d9c4e61..82e91a8dc 100644 --- a/src/pages/app/components/Habit/HabitFilter.tsx +++ b/src/pages/app/components/Habit/HabitFilter.tsx @@ -5,8 +5,7 @@ * https://opensource.org/licenses/MIT */ -import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' -import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' +import { DateRangeFilter, TimeFormatFilter } from '@app/components/common/filter' import { t } from '@app/locale' import Flex from "@pages/components/Flex" import { ElDatePickerShortcut } from '@pages/element-ui/types' @@ -33,14 +32,14 @@ const _default = defineComponent(() => { return () => ( - s && e && (filter.dateRange = [s, e])} disabledDate={d => d.getTime() < Date.now() - MILL_PER_DAY * 366} /> - filter.timeFormat = v} /> diff --git a/src/pages/app/components/Habit/Period/Filter.tsx b/src/pages/app/components/Habit/Period/Filter.tsx index 50cbdb97b..85bdd0c4e 100644 --- a/src/pages/app/components/Habit/Period/Filter.tsx +++ b/src/pages/app/components/Habit/Period/Filter.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import SelectFilterItem from '@app/components/common/filter/SelectFilterItem' +import { SelectFilter } from '@app/components/common/filter' import { t } from '@app/locale' import { type HabitMessage } from '@i18n/message/app/habit' import Flex from '@pages/components/Flex' @@ -41,9 +41,8 @@ const _default = defineComponent(() => { return () => ( - { if (!val) return diff --git a/src/pages/app/components/Habit/Period/context.ts b/src/pages/app/components/Habit/Period/context.ts index 08a95506f..0a59bdc6e 100644 --- a/src/pages/app/components/Habit/Period/context.ts +++ b/src/pages/app/components/Habit/Period/context.ts @@ -6,11 +6,11 @@ */ import { listPeriods } from '@api/sw/period' -import { useLocalStorage, useProvide, useProvider, useRequest } from '@hooks' +import { localReactive, useProvide, useProvider, useRequest } from '@hooks' import { keyOf, MAX_PERIOD_ORDER } from "@util/period" import { getDayLength, MILL_PER_DAY } from "@util/time" import { createObjectGuard, createStringUnionGuard, isInt } from 'typescript-guard' -import { computed, reactive, toRaw, watch, type Reactive, type Ref } from "vue" +import { computed, type Ref } from "vue" import { useHabitFilter } from "../context" export type ChartType = 'average' | 'trend' | 'stack' @@ -38,15 +38,15 @@ type PeriodRange = { type Context = { value: Ref - filter: Reactive + filter: FilterOption periodRange: Ref } -const computeRange = (filterDateRange: [Date, Date]): PeriodRange => { - const [startDate, endDate] = filterDateRange || [] +const computeRange = (dateRange: [number, number]): PeriodRange => { + const [startDate, endDate] = dateRange const dateLength = getDayLength(startDate, endDate) - const prevStartDate = new Date(startDate.getTime() - MILL_PER_DAY * dateLength) - const prevEndDate = new Date(startDate.getTime() - MILL_PER_DAY) + const prevStartDate = new Date(startDate - MILL_PER_DAY * dateLength) + const prevEndDate = new Date(startDate - MILL_PER_DAY) return { curr: [keyOf(startDate, 0), keyOf(endDate, MAX_PERIOD_ORDER)], prev: [keyOf(prevStartDate, 0), keyOf(prevEndDate, MAX_PERIOD_ORDER)], @@ -58,11 +58,9 @@ const NAMESPACE = 'habitPeriod' export const initProvider = () => { const globalFilter = useHabitFilter() const periodRange = computed(() => computeRange(globalFilter.dateRange)) - const [cachedFilter, setFilterCache] = useLocalStorage( + const filter = localReactive( 'habit_period_filter', isFilterOption, { periodSize: 1, chartType: 'average' } ) - const filter = reactive(cachedFilter) - watch(() => filter, () => setFilterCache(toRaw(filter)), { deep: true }) const { data: value } = useRequest(async () => { const { curr: currRange, prev: prevRange } = periodRange.value || {} diff --git a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index 224e99bf8..32305133d 100644 --- a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -35,7 +35,7 @@ const TITLE = t(msg => msg.habit.site.trend.title) export type BizOption = { rows: tt4b.stat.Row[] - dateRange: [Date, Date] + dateRange: [number, number] timeFormat: tt4b.ui.TimeFormat } @@ -92,8 +92,7 @@ const legendOptionOf = (color: LinearGradientObject, name: string): Exclude r.date, l => sum(l.map(e => e.focus))) const visitMap = groupBy(rows, r => r.date, l => sum(l.map(e => e.time))) const siteMap = groupBy(rows, r => r.date, l => new Set(l.map(e => getHost(e))).size) diff --git a/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts index 606893114..f1e1c0e24 100644 --- a/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts @@ -11,7 +11,7 @@ import { computeAverageLen, generateTitleOption } from "../common" export type BizOption = { rows: tt4b.stat.Row[] - dateRange: [Date, Date] + dateRange: [number, number] } type EcOption = ComposeOption< diff --git a/src/pages/app/components/Habit/Site/Summary.tsx b/src/pages/app/components/Habit/Site/Summary.tsx index b30564f62..7467bbcd4 100644 --- a/src/pages/app/components/Habit/Site/Summary.tsx +++ b/src/pages/app/components/Habit/Site/Summary.tsx @@ -25,7 +25,7 @@ type Result = { } const computeSummary = (rows: tt4b.stat.Row[] = [], filter: FilterOption): Result => { - const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter?.dateRange) + const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter.dateRange) const totalFocus = sum(rows.map(r => r.focus)) const totalFocus4Average = exclusiveDate ? sum(rows.filter(r => r.date !== exclusiveDate).map(r => r.focus)) : totalFocus const totalTime = sum(rows.map(r => r.time)) diff --git a/src/pages/app/components/Habit/Site/common.ts b/src/pages/app/components/Habit/Site/common.ts index 4a63023fc..43dcda7c4 100644 --- a/src/pages/app/components/Habit/Site/common.ts +++ b/src/pages/app/components/Habit/Site/common.ts @@ -32,9 +32,10 @@ export type SeriesDataItem = { * @param dateRange date range of filter * @returns [averageLen, exclusiveToday4Average, exclusiveDate] */ -export const computeAverageLen = (dateRange: [Date, Date] | undefined): [number, boolean, string | null] => { +export const computeAverageLen = (dateRange: [number, number] | undefined): [number, boolean, string | null] => { if (!dateRange) return [0, false, null] - const [start, end] = dateRange + const start = new Date(dateRange[0]) + const end = new Date(dateRange[1]) if (isSameDay(start, end)) return [1, false, null] const dateDiff = getDayLength(start, end) const endIsTody = isSameDay(end, new Date()) diff --git a/src/pages/app/components/Habit/Site/context.ts b/src/pages/app/components/Habit/Site/context.ts index b23117231..0a6c7f93c 100644 --- a/src/pages/app/components/Habit/Site/context.ts +++ b/src/pages/app/components/Habit/Site/context.ts @@ -26,7 +26,7 @@ export const initProvider = () => { defaultValue: [], }) - const dateRangeLength = computed(() => getDayLength(filter.dateRange?.[0], filter.dateRange?.[1])) + const dateRangeLength = computed(() => getDayLength(filter.dateRange[0], filter.dateRange[1])) const { data: dateMergedRows } = useRequest(() => listSiteStats({ date: cvtDateRange2Str(filter.dateRange), mergeDate: true }), { deps: [() => filter.dateRange], diff --git a/src/pages/app/components/Habit/context.ts b/src/pages/app/components/Habit/context.ts index 5443b9cf5..83d86fef5 100644 --- a/src/pages/app/components/Habit/context.ts +++ b/src/pages/app/components/Habit/context.ts @@ -11,7 +11,7 @@ import { reactive, Reactive } from "vue" export type FilterOption = { timeFormat: tt4b.ui.TimeFormat - dateRange: [Date, Date] + dateRange: [number, number] } type Context = { @@ -21,8 +21,9 @@ type Context = { const NAMESPACE = 'habit' export const initHabit = () => { + const [defaultStart, defaultEnd] = daysAgo(7, 0) const filter = reactive({ - dateRange: daysAgo(7, 0), + dateRange: [defaultStart.getTime(), defaultEnd.getTime()], timeFormat: "default", }) useProvide(NAMESPACE, { filter }) diff --git a/src/pages/app/components/Limit/components/Filter.tsx b/src/pages/app/components/Limit/components/Filter.tsx index 33fc1fdb8..5c6f65dcc 100644 --- a/src/pages/app/components/Limit/components/Filter.tsx +++ b/src/pages/app/components/Limit/components/Filter.tsx @@ -7,9 +7,7 @@ import { createTabAfterCurrent } from "@api/chrome/tab" import DropdownButton, { type DropdownButtonItem } from '@app/components/common/DropdownButton' -import ButtonFilterItem from "@app/components/common/filter/ButtonFilterItem" -import InputFilterItem from "@app/components/common/filter/InputFilterItem" -import SwitchFilterItem from '@app/components/common/filter/SwitchFilterItem' +import { ButtonFilter, InputFilter, SwitchFilter } from '@app/components/common/filter' import { t } from '@app/locale' import { OPTION_ROUTE } from '@app/router/constants' import { Delete, Open, Operation, Plus, SetUp, TurnOff, WarningFilled } from "@element-plus/icons-vue" @@ -72,27 +70,26 @@ const _default = defineComponent(() => { return () => ( - msg.limit.item.condition)} onSearch={val => filter.url = val} /> - msg.limit.onlyEffective)} - defaultValue={filter.effective} + modelValue={filter.effective} onChange={val => filter.effective = val} /> - msg.limit.button.test} icon={Operation} onClick={test} /> - msg.base.option} icon={SetUp} @@ -111,7 +108,7 @@ const _default = defineComponent(() => { {t(msg => msg.limit.emptyTips)} ), default: () => ( - msg.button.create} type="success" icon={Plus} diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index 98d5f9298..d98f52553 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -10,13 +10,14 @@ import { useDelayDuration, useLimitAction, useLimitData } from "@app/components/ import type { LimitInstance } from '@app/components/Limit/types' import { t } from '@app/locale' import { Delete, Edit } from '@element-plus/icons-vue' -import { useCached, useRequest } from '@hooks' +import { localRef, useRequest } from '@hooks' import { locale } from '@i18n' import { isEffective } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" import { ElButton, ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance, } from "element-plus" +import { createObjectGuard, createStringUnionGuard, isAny } from 'typescript-guard' import { defineComponent, ref } from "vue" import Rule from "./Rule" import Waste from "./Waste" @@ -39,9 +40,15 @@ const ACTION_WIDTH: { [locale in tt4b.Locale]: number } = { it: 220, } -const DEFAULT_SORT_COL = 'waste' +type SortCol = 'waste' | 'weeklyWaste' +const isSort = createObjectGuard({ + prop: createStringUnionGuard('waste', 'weeklyWaste'), + order: createStringUnionGuard('ascending', 'descending'), + init: isAny, + silent: isAny, +}) -function createSorter(key: 'waste' | 'weeklyWaste') { +function createSorter(key: SortCol) { return (a: tt4b.limit.Item, b: tt4b.limit.Item) => a[key] - b[key] } @@ -60,7 +67,7 @@ const _default = defineComponent((_, ctx) => { const { modify, remove } = useLimitAction() const delayDuration = useDelayDuration() - const [sort, setSort] = useCached('__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' }) + const sort = localRef('__limit_sort__', isSort, { prop: 'waste', order: 'descending' }) const table = ref() const { data: lockVisible } = useRequest(async () => { @@ -80,7 +87,7 @@ const _default = defineComponent((_, ctx) => { height="100%" data={list.value} defaultSort={sort.value} - onSort-change={val => val.prop && val.order && setSort({ prop: val?.prop, order: val?.order })} + onSort-change={val => isSort(val) && (sort.value = val)} > { {({ row: { weekdays } }: RenderRowData) => } msg.calendar.range.today)} @@ -138,7 +145,7 @@ const _default = defineComponent((_, ctx) => { )} { +async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: [number?, number?]): Promise { const hosts: string[] = [] const groupIds: number[] = [] selected.forEach(row => { @@ -31,8 +31,7 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole let count2Delete = selected.length ?? 0 if (mergeDate) { // All the items - const [start, end] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] - const date: [string?, string?] = [start && formatTimeYMD(start), end && formatTimeYMD(end)] + const date = cvtDateRange2Str(dateRange) ?? [] const siteCount = hosts.length ? await countSiteStatsByHosts(hosts, date) : 0 const groupCount = groupIds.length ? await countGroupStatsByIds(groupIds, date) : 0 count2Delete = siteCount + groupCount @@ -51,16 +50,14 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole } let key: I18nKey | undefined = undefined - let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] - if (!startDate && !endDate) { + let [startDate, endDate] = dateRange + if (startDate === undefined && endDate === undefined) { // Delete all key = msg => msg.record.batchDelete.confirmMsgAll } else { const dateFormat = t(msg => msg.calendar.dateFormat) - startDate = startDate ?? getBirthday() - endDate = endDate ?? new Date() - const start = formatTime(startDate, dateFormat) - const end = formatTime(endDate, dateFormat) + const start = formatTime(startDate ?? getBirthday(), dateFormat) + const end = formatTime(endDate ?? Date.now(), dateFormat) if (start === end) { // Single date key = msg => msg.record.batchDelete.confirmMsg @@ -104,7 +101,7 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt }) } -async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange) { +async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: [number?, number?]) { if (mergeDate) { // Delete according to the date range const date = cvtDateRange2Str(dateRange) diff --git a/src/pages/app/components/Record/Filter/index.tsx b/src/pages/app/components/Record/Filter/index.tsx index 62828e70e..61d33eddb 100644 --- a/src/pages/app/components/Record/Filter/index.tsx +++ b/src/pages/app/components/Record/Filter/index.tsx @@ -5,10 +5,7 @@ * https://opensource.org/licenses/MIT */ -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 { CategoryFilter, DateRangeFilter, InputFilter, TimeFormatFilter } from '@app/components/common/filter' import { t } from '@app/locale' import Flex from "@pages/components/Flex" import { ElDatePickerShortcut } from '@pages/element-ui/types' @@ -38,16 +35,16 @@ const _default = defineComponent<{}>(() => { return () => ( - filter.query = str} /> - msg.calendar.label.startDate)} endPlaceholder={t(msg => msg.calendar.label.endDate)} disabledDate={(date: Date | number) => new Date(date) > new Date()} shortcuts={dateShortcuts} - modelValue={(filter.dateRange instanceof Date ? [filter.dateRange] : (filter.dateRange ?? [])) as [Date?, Date?]} + modelValue={filter.dateRange} onChange={val => filter.dateRange = val} /> (() => { modelValue={filter.cateIds} onChange={val => filter.cateIds = val} /> - filter.timeFormat = val} /> diff --git a/src/pages/app/components/Record/common.ts b/src/pages/app/components/Record/common.ts index a4da62db3..9c219037e 100644 --- a/src/pages/app/components/Record/common.ts +++ b/src/pages/app/components/Record/common.ts @@ -4,7 +4,7 @@ import { } from "@api/sw/stat" import { t } from '@app/locale' import { getGroupName, isGroup, isSite } from "@util/stat" -import { cvtDateRange2Str, type DateRange, formatTime, getBirthday } from "@util/time" +import { cvtDateRange2Str, formatTime, getBirthday } from "@util/time" import type { RecordFilterOption, RecordSort } from "./types" /** @@ -17,17 +17,15 @@ function computeSingleConfirmText(url: string, date: string): string { return t(msg => msg.item.operation.deleteConfirmMsg, { url, date }) } -function computeRangeConfirmText(url: string, dateRange: DateRange): string { - let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] - if (!startDate && !endDate) { +function computeRangeConfirmText(url: string, dateRange: [number?, number?]): string { + let [startDate, endDate] = dateRange + if (startDate === undefined && endDate === undefined) { // Delete all return t(msg => msg.item.operation.deleteConfirmMsgAll, { url }) } const dateFormat = t(msg => msg.calendar.dateFormat) - startDate = startDate ?? getBirthday() - endDate = endDate ?? new Date() - const start = formatTime(startDate, dateFormat) - const end = formatTime(endDate, dateFormat) + const start = formatTime(startDate ?? getBirthday(), dateFormat) + const end = formatTime(endDate ?? new Date(), dateFormat) return start === end // Only one day ? computeSingleConfirmText(url, start) diff --git a/src/pages/app/components/Record/context.ts b/src/pages/app/components/Record/context.ts index 4002c9c30..aac9dd6eb 100644 --- a/src/pages/app/components/Record/context.ts +++ b/src/pages/app/components/Record/context.ts @@ -1,13 +1,12 @@ +import type { RecordQuery } from '@app/router/constants' import { isOptionalIntArray, isTimeFormat } from '@app/util/limit/types' -import { useLocalStorage, useProvide, useProvider } from '@hooks' +import { localReactive, useProvide, useProvider } from '@hooks' +import { getBirthday } from "@util/time" import { - createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, - isOptionalInt, - isOptionalString, - isString, + createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, isOptionalString, } from 'typescript-guard' -import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" -import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" +import { reactive, ref, type ShallowRef, toRefs } from "vue" +import { useRoute, useRouter } from "vue-router" import type { DisplayComponent, RecordFilterOption, RecordSort } from "./types" type Context = { @@ -18,8 +17,6 @@ type Context = { const NAMESPACE = 'record' -type QueryPartial = PartialPick - const isSortProp = createStringUnionGuard('date', 'host', 'focus', 'run', 'time') const isSiteMerge = createStringUnionGuard>( 'cate', 'domain', 'group', @@ -28,73 +25,53 @@ const isSiteMerge = createStringUnionGuard & { - dateStart?: number - dateEnd?: number + return isSortProp(sc) ? sc : undefined } +type CacheValue = Omit + const isCacheValue = createObjectGuard({ query: isOptionalString, mergeDate: isBoolean, siteMerge: createOptionalGuard(isSiteMerge), cateIds: isOptionalIntArray, timeFormat: isTimeFormat, - dateStart: isOptionalInt, - dateEnd: isOptionalInt, }) -const cvtStorage2Filter = (storage: CacheValue | undefined): RecordFilterOption => { - const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage ?? {} - const now = new Date() - return { - query, - dateRange: [dateStart ? new Date(dateStart) : now, dateEnd ? new Date(dateEnd) : now], - mergeDate: mergeDate ?? false, - siteMerge, - cateIds, - timeFormat: timeFormat ?? 'default', - readRemote: false, - } -} - -const cvtFilter2Storage = (filter: RecordFilterOption): CacheValue => { - const { query, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter - const [dateStart, dateEnd] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] - return { - query, - mergeDate, siteMerge, - dateStart: dateStart?.getTime?.(), - dateEnd: dateEnd?.getTime?.(), - cateIds, timeFormat, - } -} - export const initRecordContext = () => { - const route = useRoute() - const router = useRouter() - const [queryFilter, querySort] = parseQuery(route, router) - - const [cachedFilter, setCachedFilter] = useLocalStorage('record_filter', isCacheValue) - const filter = reactive({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) - watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) + const cached = localReactive('record_filter', isCacheValue, { + query: undefined, + mergeDate: false, + timeFormat: 'default', + }) + const filter: RecordFilterOption = reactive({ + ...toRefs(cached), + readRemote: false, + dateRange: [Date.now(), Date.now()], + }) + const querySort = initQuery(filter) const sort = ref({ order: 'descending', diff --git a/src/pages/app/components/Record/types.d.ts b/src/pages/app/components/Record/types.d.ts index 12ddb825a..c62beb1b8 100644 --- a/src/pages/app/components/Record/types.d.ts +++ b/src/pages/app/components/Record/types.d.ts @@ -1,4 +1,3 @@ -import { DateRange } from '@util/time' import type { Sort } from "element-plus" export type RecordSort = Omit & { @@ -6,8 +5,8 @@ export type RecordSort = Omit & { } export type RecordFilterOption = { - query: string | undefined - dateRange: DateRange + query?: string + dateRange: [number?, number?] mergeDate: boolean siteMerge?: Exclude cateIds?: number[] diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/Filter/index.tsx similarity index 51% rename from src/pages/app/components/SiteManage/SiteManageFilter.tsx rename to src/pages/app/components/SiteManage/Filter/index.tsx index bd81bfbee..63220e4b3 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/Filter/index.tsx @@ -4,29 +4,29 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import Category from '@app/components/common/Category' +import { ButtonFilter, CategoryFilter, InputFilter, MultiSelectFilter, } from '@app/components/common/filter' import { useCategory } from "@app/context" import { t } from '@app/locale' -import { Connection, Delete, Grid, Plus } from "@element-plus/icons-vue" +import { Check, Close, Connection, Delete, Grid, Plus } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" +import { ElButton, ElDialog, ElForm, ElFormItem } from 'element-plus' import { computed, defineComponent, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from '../common/DropdownButton' -import ButtonFilterItem from "../common/filter/ButtonFilterItem" -import CategoryFilter from '../common/filter/CategoryFilter' -import InputFilterItem from "../common/filter/InputFilterItem" -import MultiSelectFilterItem from '../common/filter/MultiSelectFilterItem' -import { ALL_TYPES } from "./common" -import { useSiteManageFilter } from './useSiteManage' +import DropdownButton, { type DropdownButtonItem } from '../../common/DropdownButton' +import { ALL_TYPES } from "../common" +import { useSiteManageFilter } from '../context' +import { useBatch } from './useBatch' type BatchOpt = 'change' | 'disassociate' | 'delete' -const _default = defineComponent<{ - onCreate: NoArgCallback - onBatchChangeCate: NoArgCallback - onBatchDisassociate: NoArgCallback - onBatchDelete: NoArgCallback -}>(props => { +const Filter = defineComponent<{}>(() => { const cate = useCategory() - const filter = useSiteManageFilter() + const { filter, modifyInst } = useSiteManageFilter() + const { + batchChange, batchDisassociate, batchDelete, + selectVisible, closeSelect, targetCate, + onCateChangeConfirm, + } = useBatch() const cateDisabled = computed(() => { const types = filter.types @@ -45,31 +45,31 @@ const _default = defineComponent<{ key: 'change', label: t(msg => msg.siteManage.cate.batchChange), icon: Grid, - onClick: props.onBatchChangeCate, + onClick: batchChange, }, { key: 'disassociate', label: t(msg => msg.siteManage.cate.batchDisassociate), icon: Connection, - onClick: props.onBatchDisassociate, + onClick: batchDisassociate, }, { key: 'delete', label: t(msg => msg.button.batchDelete), icon: Delete, - onClick: props.onBatchDelete, + onClick: batchDelete, }] return () => ( - msg.item.host)} / ${t(msg => msg.siteManage.column.alias)}`} onSearch={val => filter.query = val} width={200} /> - msg.siteManage.column.type)} options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} - defaultValue={filter.types ?? []} + modelValue={filter.types ?? []} onChange={val => filter.types = val as tt4b.site.Type[]} /> - msg.button.create} icon={Plus} type="success" - onClick={props.onCreate} + onClick={() => modifyInst.value?.add()} /> + msg.siteManage.cate.batchChange)} + width={300} + modelValue={selectVisible.value} + onClose={closeSelect} + v-slots={{ + default: () => <> + + msg.siteManage.cate.name)} required> + targetCate.value = value} /> + + + , + footer: () => <> + + + {t(msg => msg.button.cancel)} + + + {t(msg => msg.button.confirm)} + + + + }} + /> ) -}, { props: ['onBatchChangeCate', 'onBatchDelete', 'onBatchDisassociate', 'onCreate'] }) +}) -export default _default \ No newline at end of file +export default Filter \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/Filter/useBatch.ts b/src/pages/app/components/SiteManage/Filter/useBatch.ts new file mode 100644 index 000000000..6d126fcca --- /dev/null +++ b/src/pages/app/components/SiteManage/Filter/useBatch.ts @@ -0,0 +1,80 @@ +import { changeSitesCate, deleteSites } from '@api/sw/site' +import { t } from '@app/locale' +import { WarnTriangleFilled } from '@element-plus/icons-vue' +import { useSwitch } from '@hooks' +import { supportCategory } from '@util/site' +import { ElMessage, ElMessageBox } from 'element-plus' +import { computed, markRaw, ref } from 'vue' +import { useSiteManageTable } from '../context' + +export const useBatch = () => { + const { refresh, selected } = useSiteManageTable() + + const supported = computed(() => selected.value.filter(supportCategory)) + const [selectVisible, openSelect, closeSelect] = useSwitch(false) + const targetCate = ref() + + const batchChange = () => { + if (!selected.value.length) { + return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + } + if (!supported.value.length) { + return ElMessage.info(t(msg => msg.siteManage.msg.noSupported)) + } + targetCate.value = undefined + openSelect() + } + + const onCateChangeConfirm = async () => { + const cateId = targetCate.value + if (!cateId) return ElMessage.warning("Category not selected") + + await changeSitesCate(cateId, ...supported.value) + ElMessage.success(t(msg => msg.operation.successMsg)) + closeSelect() + refresh() + } + + const batchDisassociate = () => { + if (!selected.value.length) { + return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + } + ElMessageBox.confirm( + t(msg => msg.siteManage.msg.disassociatedMsg), + { + type: 'warning', + title: t(msg => msg.siteManage.cate.batchDisassociate), + closeOnClickModal: true, + } + ).then(async () => { + const need2Clear = supported.value.filter(s => s.cate) + need2Clear.length && await changeSitesCate(undefined, ...need2Clear) + ElMessage.success(t(msg => msg.operation.successMsg)) + refresh() + }).catch(() => { }) + } + const batchDelete = () => { + if (!selected.value.length) { + return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + } + ElMessageBox.confirm( + t(msg => msg.siteManage.msg.batchDeleteMsg), + { + type: 'error', + title: t(msg => msg.button.batchDelete), + closeOnClickModal: true, + icon: markRaw(WarnTriangleFilled), + } + ).then(async () => { + await deleteSites(...(selected.value ?? [])) + ElMessage.success(t(msg => msg.operation.successMsg)) + refresh() + }).catch(() => { }) + } + + return { + batchChange, batchDisassociate, batchDelete, + selectVisible, closeSelect, + targetCate, onCateChangeConfirm, + } +} diff --git a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx index c3789e1fb..124f5c4f9 100644 --- a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx @@ -7,7 +7,7 @@ import { fillInitialAlias, getInitialAlias, modifySite } from "@api/sw/site" import Editable from "@app/components/common/Editable" -import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { useSiteManageTable } from '@app/components/SiteManage/context' import { t } from '@app/locale' import { MagicStick } from "@element-plus/icons-vue" import { useManualRequest } from '@hooks' diff --git a/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx index 5be59cbcd..ee495e26e 100644 --- a/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx @@ -6,7 +6,7 @@ */ import { deleteSites } from '@api/sw/site' import PopupConfirmButton from '@app/components/common/PopupConfirmButton' -import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { useSiteManageTable } from '@app/components/SiteManage/context' import { t } from '@app/locale' import { Delete } from "@element-plus/icons-vue" import { useManualRequest } from '@hooks' diff --git a/src/pages/app/components/SiteManage/Table/index.tsx b/src/pages/app/components/SiteManage/Table/index.tsx index 583826449..73bd7e3fa 100644 --- a/src/pages/app/components/SiteManage/Table/index.tsx +++ b/src/pages/app/components/SiteManage/Table/index.tsx @@ -13,7 +13,7 @@ import EditableImg from '@pages/components/EditableImg' import Flex from '@pages/components/Flex' import { ElSwitch, ElTable, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { useSiteManageTable } from '../useSiteManage' +import { useSiteManageTable } from '../context' import AliasColumn from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" import TypeColumn from "./column/TypeColumn" @@ -21,7 +21,7 @@ import TypeColumn from "./column/TypeColumn" type RenderParam = RenderRowData const _default = defineComponent<{}>(() => { - const { setSelected, refresh, pagination } = useSiteManageTable() + const { selected, refresh, pagination } = useSiteManageTable() const { refresh: changeIcon } = useManualRequest((row: tt4b.site.SiteInfo, iconUrl: string | undefined) => { const { type, host, alias } = row @@ -35,7 +35,7 @@ const _default = defineComponent<{}>(() => { data={pagination.value.list} height="100%" highlightCurrentRow border fit - onSelection-change={setSelected} + onSelection-change={val => selected.value = val} > > filter: FilterOption selected: ShallowRef - setSelected: ArgCallback refresh: NoArgCallback + modifyInst: Ref } const NAMESPACE = 'site-manage' -export const initSiteManage = (loadingTarget: RequestOption['loadingTarget']) => { - const [cache, setCache] = useLocalStorage('site-manage-filter', isCacheValue) - - const filter = reactive({ cateIds: cache?.cateIds }) - watch(() => filter.cateIds, cateIds => setCache({ cateIds })) +const initData = () => { + const cached = localReactive('site-manage-filter', isCacheValue, { cateIds: undefined }) + const filter: FilterOption = reactive({ ...toRefs(cached) }) const page = reactive({ num: 1, size: 20 }) - const [selected, setSelected] = useState([]) + const loadingTarget = ref() const { data: pagination, refresh, loading } = useRequest(() => { const { query: fuzzyQuery, cateIds, types } = filter return getSitePage({ fuzzyQuery, cateIds, types }, page) @@ -45,15 +44,25 @@ export const initSiteManage = (loadingTarget: RequestOption[ loadingTarget, deps: [() => filter, () => page], }) + return { pagination, refresh, loading, loadingTarget, filter, page } +} + +export const initSiteManage = () => { + const { + pagination, refresh, loading, loadingTarget, filter, page + } = initData() + + const selected = ref([]) + const modifyInst = ref() - useProvide(NAMESPACE, { pagination, filter, selected, setSelected, refresh }) + useProvide(NAMESPACE, { pagination, filter, selected, refresh, modifyInst }) return { - pagination, refresh, loading, - selected, page, + pagination, refresh, loading, modifyInst, + page, loadingTarget, } } -export const useSiteManageFilter = () => useProvider(NAMESPACE, 'filter').filter +export const useSiteManageFilter = () => useProvider(NAMESPACE, 'filter', 'modifyInst') -export const useSiteManageTable = () => useProvider(NAMESPACE, 'pagination', 'refresh', 'setSelected') \ No newline at end of file +export const useSiteManageTable = () => useProvider(NAMESPACE, 'pagination', 'refresh', 'selected') \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/index.tsx b/src/pages/app/components/SiteManage/index.tsx index ec1513d15..e0c750c63 100644 --- a/src/pages/app/components/SiteManage/index.tsx +++ b/src/pages/app/components/SiteManage/index.tsx @@ -5,102 +5,22 @@ * https://opensource.org/licenses/MIT */ -import { changeSitesCate, deleteSites } from "@api/sw/site" -import { t } from '@app/locale' -import { Check, Close, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" -import { supportCategory } from "@util/site" -import { ElButton, ElDialog, ElForm, ElFormItem, ElMessage, ElMessageBox } from "element-plus" -import { computed, defineComponent, markRaw, ref, type VNode } from "vue" -import Category from '../common/Category' +import { defineComponent } from "vue" import ContentContainer from '../common/ContentContainer' import Pagination from '../common/Pagination' -import Modify, { type ModifyInstance } from './Modify' -import Filter from "./SiteManageFilter" +import Filter from "./Filter" +import Modify from './Modify' import Table from "./Table" -import { initSiteManage } from './useSiteManage' +import { initSiteManage } from './context' export default defineComponent(() => { - const loadingTarget = ref() const { - page, pagination, refresh, loading, - selected - } = initSiteManage(() => loadingTarget.value?.el as HTMLElement | undefined) - const modify = ref() - - const cateSupported = computed(() => selected.value.filter(supportCategory)) - const [showCateChange, openCateChange, closeCateChange] = useSwitch(false) - const [batchCate, setBatchCate] = useState() - - const handleChangeCate = () => { - if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) - } - if (!cateSupported.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSupported)) - } - setBatchCate() - openCateChange() - } - - const onCateChangeConfirm = async () => { - const cateId = batchCate.value - if (!cateId) return ElMessage.warning("Category not selected") - - await changeSitesCate(cateId, ...cateSupported.value) - ElMessage.success(t(msg => msg.operation.successMsg)) - closeCateChange() - refresh() - } - - const handleDisassociate = () => { - if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) - } - ElMessageBox.confirm( - t(msg => msg.siteManage.msg.disassociatedMsg), - { - type: 'warning', - title: t(msg => msg.siteManage.cate.batchDisassociate), - closeOnClickModal: true, - } - ).then(async () => { - const need2Clear = cateSupported.value.filter(s => s.cate) - need2Clear.length && await changeSitesCate(undefined, ...need2Clear) - ElMessage.success(t(msg => msg.operation.successMsg)) - refresh() - }).catch(() => { }) - } - - const handleBatchDelete = () => { - if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) - } - ElMessageBox.confirm( - t(msg => msg.siteManage.msg.batchDeleteMsg), - { - type: 'error', - title: t(msg => msg.button.batchDelete), - closeOnClickModal: true, - icon: markRaw(WarnTriangleFilled), - } - ).then(async () => { - await deleteSites(...(selected.value ?? [])) - ElMessage.success(t(msg => msg.operation.successMsg)) - refresh() - }).catch(() => { }) - } + page, pagination, refresh, loading, modifyInst, loadingTarget, + } = initSiteManage() return () => ( - modify.value?.add?.()} - onBatchChangeCate={handleChangeCate} - onBatchDelete={handleBatchDelete} - onBatchDisassociate={handleDisassociate} - /> - ), + filter: () => , content: () => <> @@ -115,32 +35,7 @@ export default defineComponent(() => { /> - - msg.siteManage.cate.batchChange)} - width={300} - modelValue={showCateChange.value} - onClose={closeCateChange} - v-slots={{ - default: () => <> - - msg.siteManage.cate.name)} required> - - - - , - footer: () => <> - - - {t(msg => msg.button.cancel)} - - - {t(msg => msg.button.confirm)} - - - - }} - /> + , }} /> }) diff --git a/src/pages/app/components/common/filter/ButtonFilterItem.tsx b/src/pages/app/components/common/filter/ButtonFilter.tsx similarity index 84% rename from src/pages/app/components/common/filter/ButtonFilterItem.tsx rename to src/pages/app/components/common/filter/ButtonFilter.tsx index 665c63894..9af5b5083 100644 --- a/src/pages/app/components/common/filter/ButtonFilterItem.tsx +++ b/src/pages/app/components/common/filter/ButtonFilter.tsx @@ -4,19 +4,17 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useXsState } from '@hooks' import { type I18nKey, t } from '@app/locale' +import { useXsState } from '@hooks' import { type ButtonProps, ElButton } from "element-plus" import { defineComponent } from "vue" -type Props = { - icon: ButtonProps['icon'] +type Props = Pick & { text: I18nKey - type?: ButtonProps['type'] onClick?: NoArgCallback } -const ButtonFilterItem = defineComponent(props => { +const ButtonFilter = defineComponent(props => { const isXs = useXsState() return () => isXs.value ? ( @@ -34,4 +32,4 @@ const ButtonFilterItem = defineComponent(props => { ) }, { props: ['icon', 'onClick', 'text', 'type'] }) -export default ButtonFilterItem \ No newline at end of file +export default ButtonFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilter.tsx similarity index 90% rename from src/pages/app/components/common/filter/DateRangeFilterItem.tsx rename to src/pages/app/components/common/filter/DateRangeFilter.tsx index ecea9924b..7984c786c 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilter.tsx @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { useXsState } from '@hooks' import { t } from "@app/locale" import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue' import { css } from '@emotion/css' +import { useXsState } from '@hooks' +import { dateFormat } from "@i18n/element" import Flex from '@pages/components/Flex' import { getDatePickerIconSlots } from '@pages/element-ui/rtl' import { ElDatePickerShortcut } from '@pages/element-ui/types' -import { dateFormat } from "@i18n/element" import { isRtl } from '@util/document' import { MILL_PER_DAY } from '@util/time' import { type DatePickerProps, ElButton, ElDatePicker, ElText, useNamespace } from "element-plus" @@ -23,7 +23,7 @@ const clearShortcut = (): ElDatePickerShortcut => ({ value: [new Date(0), new Date(0)], }) -type Props = ModelValue<[Date?, Date?]> & { +type Props = ModelValue<[number?, number?]> & { disabledDate?: (date: Date) => boolean startPlaceholder?: string endPlaceholder?: string @@ -58,7 +58,7 @@ const useRange = (props: Props) => { if (!start) return true const { disabledDate } = props if (!disabledDate) return false - const lastDay = new Date(start.getTime() - MILL_PER_DAY) + const lastDay = new Date(start - MILL_PER_DAY) return disabledDate(lastDay) }) @@ -67,7 +67,7 @@ const useRange = (props: Props) => { if (!end) return true const { disabledDate } = props if (!disabledDate) return false - const nextDate = new Date(end.getTime() + MILL_PER_DAY) + const nextDate = new Date(end + MILL_PER_DAY) return disabledDate(nextDate) }) @@ -76,8 +76,8 @@ const useRange = (props: Props) => { const [start, end] = modelValue ?? [] if (!start || !end) return const millDiff = MILL_PER_DAY * dayNum - const newStart = new Date(start.getTime() + millDiff) - const newEnd = new Date(end.getTime() + millDiff) + const newStart = start + millDiff + const newEnd = end + millDiff onChange?.([newStart, newEnd]) } @@ -110,9 +110,9 @@ const DefaultRange = defineComponent(props => { return start && end ? [start, end] : undefined }) - const handleUpdate = (innerVal: [Date, Date] | undefined) => { + const handleUpdate = (innerVal: [number, number] | undefined) => { let value = innerVal ?? [undefined, undefined] - if (innerVal?.[0].getTime() === 0 && innerVal[1].getTime() === 0) { + if (innerVal?.[0] === 0 && innerVal[1] === 0) { // clear shortcuts value = [undefined, undefined] } @@ -174,7 +174,7 @@ const DefaultRange = defineComponent(props => { }, { props: ALL_PROPS }) type XsDatePickerProps = - & ModelValue + & ModelValue & Partial> const XsDatePicker: FunctionalComponent = props => { @@ -200,9 +200,9 @@ const XsDatePicker: FunctionalComponent = props => { } const XsRange = defineComponent(props => { - const handleChange = (start: Date | undefined, end: Date | undefined) => { + const handleChange = (start: number | undefined, end: number | undefined) => { const needReverse = start && end && start >= end - const arr: [Date?, Date?] = needReverse ? [end, start] : [start, end] + const arr: [number?, number?] = needReverse ? [end, start] : [start, end] props.onChange?.(arr) } @@ -227,9 +227,9 @@ const XsRange = defineComponent(props => { ) }, { props: ALL_PROPS }) -const DateRangeFilterItem = defineComponent(props => { +const DateRangeFilter = defineComponent(props => { const isXs = useXsState() return () => isXs.value ? : }, { props: ALL_PROPS }) -export default DateRangeFilterItem \ No newline at end of file +export default DateRangeFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/InputFilterItem.tsx b/src/pages/app/components/common/filter/InputFilter.tsx similarity index 79% rename from src/pages/app/components/common/filter/InputFilterItem.tsx rename to src/pages/app/components/common/filter/InputFilter.tsx index 6ef8cb36e..fb4d81da3 100644 --- a/src/pages/app/components/common/filter/InputFilterItem.tsx +++ b/src/pages/app/components/common/filter/InputFilter.tsx @@ -7,9 +7,10 @@ import { Search } from "@element-plus/icons-vue" import { useState } from "@hooks" +import { cvtPxScale } from '@pages/components/common' import { Enter } from '@pages/icons' import { ElIcon, ElInput } from "element-plus" -import { computed, defineComponent, ref, type StyleValue } from "vue" +import { defineComponent, ref, type StyleValue } from "vue" type Props = { defaultValue?: string @@ -18,14 +19,9 @@ type Props = { onSearch?: ArgCallback } -const InputFilterItem = defineComponent(props => { +const InputFilter = defineComponent(props => { const modelValue = ref(props.defaultValue ?? '') - const width = computed(() => { - const w = props.width - return typeof w === 'number' ? `${w}px` : (w ?? '180px') - }) - const [focused, setFocused] = useState(false) const handleBlur = () => { @@ -47,7 +43,7 @@ const InputFilterItem = defineComponent(props => { onKeydown={handleKeydown} onBlur={handleBlur} onFocus={() => setFocused(true)} - style={{ width: width.value } satisfies StyleValue} + style={{ width: cvtPxScale(props.width) ?? '180px' } satisfies StyleValue} suffixIcon={( @@ -58,4 +54,4 @@ const InputFilterItem = defineComponent(props => { ) }, { props: ['defaultValue', 'placeholder', 'width', 'onSearch'] }) -export default InputFilterItem \ No newline at end of file +export default InputFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/MultiSelectFilter.tsx b/src/pages/app/components/common/filter/MultiSelectFilter.tsx new file mode 100644 index 000000000..a6b203db4 --- /dev/null +++ b/src/pages/app/components/common/filter/MultiSelectFilter.tsx @@ -0,0 +1,26 @@ +import { ElSelect, type SelectProps } from "element-plus" +import { type FunctionalComponent } from "vue" +import { SELECT_WRAPPER_STYLE } from "./common" + +type Data = string | number + +type Props = ModelValue & Pick & { + options?: { value: Data, label?: string }[] +} + +const MultiSelectFilter: FunctionalComponent = props => ( + props.onChange?.([])} + placeholder={props.placeholder} + style={SELECT_WRAPPER_STYLE} + options={props.options} + /> +) +MultiSelectFilter.displayName = 'MultiSelectFilter' + +export default MultiSelectFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx deleted file mode 100644 index 57028d6ee..000000000 --- a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ElSelect } from "element-plus" -import { defineComponent } from "vue" -import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" - -type Data = string | number - -type Props = BaseFilterProps & { - placeholder?: string - options?: { value: Data, label?: string }[] -} - -const MultiSelectFilterItem = defineComponent(props => { - const [data, setter] = useFilterState('multi_select', props) - - return () => ( - setter([])} - placeholder={props.placeholder} - style={SELECT_WRAPPER_STYLE} - options={props.options} - /> - ) -}, { props: [...ALL_BASE_FILTER_PROPS, 'placeholder', 'options'] }) - -export default MultiSelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SelectFilter.tsx b/src/pages/app/components/common/filter/SelectFilter.tsx new file mode 100644 index 000000000..0b4d60962 --- /dev/null +++ b/src/pages/app/components/common/filter/SelectFilter.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElSelect, type SelectProps } from "element-plus" +import type { FunctionalComponent } from 'vue' +import { SELECT_WRAPPER_STYLE } from "./common" + +type Props = ModelValue & Pick & { + options: Record +} + +const SelectFilter: FunctionalComponent = props => ( + ({ label, value }))} + /> +) +SelectFilter.displayName = 'SelectFilter' + +export default SelectFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SelectFilterItem.tsx b/src/pages/app/components/common/filter/SelectFilterItem.tsx deleted file mode 100644 index a1d9c1669..000000000 --- a/src/pages/app/components/common/filter/SelectFilterItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { ElSelect, SelectProps } from "element-plus" -import { defineComponent } from "vue" -import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" - -type Props = BaseFilterProps & Pick & { - options: Record -} - -const SelectFilterItem = defineComponent(props => { - const [data, setter] = useFilterState('select', props) - - return () => ( - ({ label, value }))} - /> - ) -}, { props: [...ALL_BASE_FILTER_PROPS, 'options', 'placeholder'] }) - -export default SelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SwitchFilter.tsx b/src/pages/app/components/common/filter/SwitchFilter.tsx new file mode 100644 index 000000000..92c2a6f70 --- /dev/null +++ b/src/pages/app/components/common/filter/SwitchFilter.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import Flex from "@pages/components/Flex" +import { ElSwitch, ElText } from "element-plus" +import type { FunctionalComponent } from "vue" + +type Props = ModelValue & { + label: string +} + +const SwitchFilter: FunctionalComponent = props => ( + + {props.label} + props.onChange?.(Boolean(val))} + /> + +) +SwitchFilter.displayName = 'SwitchFilter' + +export default SwitchFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SwitchFilterItem.tsx b/src/pages/app/components/common/filter/SwitchFilterItem.tsx deleted file mode 100644 index f1b46e7bc..000000000 --- a/src/pages/app/components/common/filter/SwitchFilterItem.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import Flex from "@pages/components/Flex" -import { ElSwitch, ElText } from "element-plus" -import { defineComponent } from "vue" -import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, useFilterState } from './common' - -type Props = BaseFilterProps & { - label: string -} - -const _default = defineComponent(props => { - const [data, setter] = useFilterState('switch', props) - - return () => ( - - {props.label} - setter(val as boolean)} /> - - ) -}, { props: [...ALL_BASE_FILTER_PROPS, 'label'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/common/filter/TimeFormatFilter.tsx b/src/pages/app/components/common/filter/TimeFormatFilter.tsx new file mode 100644 index 000000000..44b1499d8 --- /dev/null +++ b/src/pages/app/components/common/filter/TimeFormatFilter.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import type { FunctionalComponent } from "vue" +import SelectFilterItem from "./SelectFilter" + +const TIME_FORMAT_LABELS: { [key in tt4b.ui.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) +} + +const TimeFormatFilter: FunctionalComponent> = props => ( + val && props.onChange?.(val as tt4b.ui.TimeFormat)} + /> +) +TimeFormatFilter.displayName = 'TimeFormatFilter' + +export default TimeFormatFilter \ No newline at end of file diff --git a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx deleted file mode 100644 index 583ac07fc..000000000 --- a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { t } from "@app/locale" -import { defineComponent } from "vue" -import SelectFilterItem from "./SelectFilterItem" - -const TIME_FORMAT_LABELS: { [key in tt4b.ui.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) -} - -const _default = defineComponent>(props => { - return () => ( - val && props.onChange?.(val as tt4b.ui.TimeFormat)} - /> - ) -}, { props: ['modelValue', 'onChange'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/common/filter/common.ts b/src/pages/app/components/common/filter/common.ts index c8bcbf430..94b28637a 100644 --- a/src/pages/app/components/common/filter/common.ts +++ b/src/pages/app/components/common/filter/common.ts @@ -1,24 +1,5 @@ -import { useCached, useState } from '@hooks' -import { watch, type StyleValue } from "vue" -import { useRoute } from 'vue-router' +import { type StyleValue } from "vue" export const SELECT_WRAPPER_STYLE: StyleValue = { width: '200px', } - -export type BaseFilterProps = { - defaultValue: T - historyName?: string - onChange?: ArgCallback -} - -export const useFilterState = (item: string, props: BaseFilterProps) => { - const [data, setter] = props.historyName - ? useCached(`__filter_item_${item}_${useRoute().path}_${props.historyName}`, props.defaultValue) - : useState(props.defaultValue) - props.onChange && watch(data, props.onChange, { immediate: true }) - - return [data, setter] as const -} - -export const ALL_BASE_FILTER_PROPS: readonly (keyof BaseFilterProps)[] = ['defaultValue', 'historyName', 'onChange'] \ No newline at end of file diff --git a/src/pages/app/components/common/filter/index.ts b/src/pages/app/components/common/filter/index.ts new file mode 100644 index 000000000..e19896960 --- /dev/null +++ b/src/pages/app/components/common/filter/index.ts @@ -0,0 +1,8 @@ +export { default as ButtonFilter } from './ButtonFilter' +export { default as CategoryFilter } from './CategoryFilter' +export { default as DateRangeFilter } from './DateRangeFilter' +export { default as InputFilter } from './InputFilter' +export { default as MultiSelectFilter } from './MultiSelectFilter' +export { default as SelectFilter } from './SelectFilter' +export { default as SwitchFilter } from './SwitchFilter' +export { default as TimeFormatFilter } from './TimeFormatFilter' diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index 83b064379..0c15ca528 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -1,4 +1,3 @@ -export * from "./useCached" export * from "./useCopy" export * from "./useCount" export * from "./useDebounce" diff --git a/src/pages/hooks/useCached.ts b/src/pages/hooks/useCached.ts deleted file mode 100644 index 6c40d2fc7..000000000 --- a/src/pages/hooks/useCached.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { ref, type Ref, watch } from "vue" - -const getInitialValue = (key: string, defaultValue?: T): T | undefined => { - if (!key) return defaultValue - const exist = localStorage.getItem(key) - if (!exist) return defaultValue - try { - return JSON.parse(exist) as T - } catch (e) { - return undefined - } -} - -const saveCache = (key: string, val: T) => { - if (!key) return - if (val === null || val === undefined || val === '') { - localStorage.removeItem(key) - } else { - localStorage.setItem(key, JSON.stringify(val)) - } -} - -export function useCached(key: string, defaultValue: T): [data: Ref, setter: ArgCallback] -export function useCached(key: string, defaultValue?: T): [data: Ref, setter: ArgCallback] -export function useCached(key: string, defaultValue?: T) { - let cachedValue = getInitialValue(key, defaultValue) - let initial = cachedValue ?? defaultValue - const data = initial === undefined ? ref() : ref(initial) - const setter = (val: T | undefined) => data.value = val - watch(data, () => saveCache(key, data.value), { immediate: true }) - return [data, setter] -} diff --git a/src/pages/hooks/useLocalStorage.ts b/src/pages/hooks/useLocalStorage.ts index 2ddc0c87c..4257abf61 100644 --- a/src/pages/hooks/useLocalStorage.ts +++ b/src/pages/hooks/useLocalStorage.ts @@ -1,30 +1,13 @@ -type StoragePrimitive = string | boolean | number | undefined -type StorageArray = Array -type StorageObject = { [key: string]: StorageValue } +import type { TypeGuard } from 'typescript-guard' +import { reactive, type Reactive, shallowRef, type ShallowRef, toRaw, watch } from 'vue' + type StorageValue = - | StoragePrimitive - | StorageArray + | string | number | boolean | undefined + | StorageValue[] | StorageObject +type StorageObject = { [key: string]: StorageValue } -type Guard = (val: unknown) => val is T - -export function useLocalStorage(key: string, guard: Guard, defaultValue: T): [T, ArgCallback] -export function useLocalStorage(key: string, guard: Guard): [T | undefined, (val: T | undefined) => void] -export function useLocalStorage(key: string, guard: Guard, defaultVal?: T): [data: T | undefined, setter: ArgCallback] { - const value = deserialize(localStorage.getItem(key), guard) ?? defaultVal - - const setter = (val: T | undefined) => { - if (val === undefined) { - localStorage.removeItem(key) - } else { - localStorage.setItem(key, JSON.stringify(val)) - } - } - - return [value, setter] -} - -function deserialize(json: string | null, guard: Guard): T | undefined { +function deserialize(json: string | null, guard: TypeGuard): T | undefined { if (!json) return undefined try { @@ -33,4 +16,24 @@ function deserialize(json: string | null, guard: Guard): T | undefined { } catch { return undefined } -} \ No newline at end of file +} + +export function localRef(key: string, guard: TypeGuard, defaultValue: T): ShallowRef +export function localRef(key: string, guard: TypeGuard): ShallowRef +export function localRef(key: string, guard: TypeGuard, defaultValue?: T): ShallowRef { + const result = shallowRef(deserialize(localStorage.getItem(key), guard) ?? defaultValue) + watch(result, val => val === undefined + ? localStorage.removeItem(key) + : localStorage.setItem(key, JSON.stringify(val)) + ) + return result +} + +export function localReactive(key: string, guard: TypeGuard, defaultValue: T): Reactive { + const result = reactive(deserialize(localStorage.getItem(key), guard) ?? defaultValue) + watch(result, + val => localStorage.setItem(key, JSON.stringify(toRaw(val))), + { deep: true }, + ) + return result +} diff --git a/src/pages/popup/components/Footer/Menu.tsx b/src/pages/popup/components/Footer/Menu.tsx index b4916718d..2569660b8 100644 --- a/src/pages/popup/components/Footer/Menu.tsx +++ b/src/pages/popup/components/Footer/Menu.tsx @@ -28,10 +28,10 @@ const createItems = (): MenuItem[] => [ ] as const const Menu = defineComponent(() => { - const { menu, setMenu } = useMenu() + const menu = useMenu() return () => ( - isMenu(v) && setMenu(v)}> + isMenu(v) && (menu.value = v)}> {createItems().map(({ route, label, icon }) => ( diff --git a/src/pages/popup/components/Footer/index.tsx b/src/pages/popup/components/Footer/index.tsx index 36c643b81..fdc6b3425 100644 --- a/src/pages/popup/components/Footer/index.tsx +++ b/src/pages/popup/components/Footer/index.tsx @@ -6,7 +6,7 @@ import LimitToolbar from './LimitToolbar' import Menu from "./Menu" const Footer = defineComponent(() => { - const { menu } = useMenu() + const menu = useMenu() return () => ( diff --git a/src/pages/popup/components/Header/Option.tsx b/src/pages/popup/components/Header/Option.tsx index 53d97f53f..1a1dfaa71 100644 --- a/src/pages/popup/components/Header/Option.tsx +++ b/src/pages/popup/components/Header/Option.tsx @@ -18,7 +18,7 @@ const reference = () => ( const Option = defineComponent(() => { const option = useOption() - const { menu } = useMenu() + const menu = useMenu() const isPercentage = computed(() => menu.value === 'percentage') const toggleName = () => { diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index dbba43ecb..a7366ea2c 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -1,12 +1,14 @@ import { listAllCategories } from '@api/sw/cate' import { getLimitSummary } from '@api/sw/limit' import { getOption, setOption } from "@api/sw/option" -import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" +import { localReactive, localRef, useProvide, useProvider, useRequest } from "@hooks" import { isDarkMode, processDarkMode } from "@pages/util/dark-mode" import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" -import { createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, isNumber, isOptionalInt } from 'typescript-guard' -import { computed, reactive, Ref, ref, type ShallowRef, toRaw, watch } from "vue" +import { + createObjectGuard, createOptionalGuard, createStringUnionGuard, isBoolean, isNumber, isOptionalInt, +} from 'typescript-guard' +import { onBeforeMount, ref, watch, type ShallowRef } from "vue" import { useRoute, useRouter } from 'vue-router' import { t } from "./locale" import { isMenu } from './router' @@ -20,25 +22,30 @@ type PopupContextValue = { option: PopupOption cateNameMap: ShallowRef> menu: ShallowRef - setMenu: ArgCallback limitSummary: ShallowRef limitSummaryLoading: ShallowRef - selectedLimit: Ref + selectedLimit: ShallowRef } const initMenu = () => { - const [stored, setStored] = useLocalStorage('popup_menu', isMenu, 'percentage') + const menu = localRef('popup_menu', isMenu, 'percentage') const route = useRoute() const router = useRouter() - const myRoute = computed(() => { - const menuMaybe = route.path.substring(1) - return isMenu(menuMaybe) ? menuMaybe : stored + + onBeforeMount(async () => { + await router.isReady() + const initial = route.path.substring(1) + if (isMenu(initial)) { + menu.value = initial + } else { + // Replace with valid menu + router.replace(`/${menu.value}`) + } }) - const setMyRoute = (val: tt4b.ui.PopupMenu) => { - setStored(val) - router.push('/' + val) - } - return [myRoute, setMyRoute] as const + + watch(menu, val => router.push(`/${val}`)) + + return menu } const NAMESPACE = '_' @@ -64,10 +71,18 @@ export const initPopupContext = (): ShallowRef => { return result }, { defaultValue: {} }) - const query = initQuery() - const option = initOption() + const query = localReactive('popup-query', isQuery, { + dimension: 'focus', + duration: 'today', + mergeMethod: undefined, + }) + const option = localReactive('popup-option', isOption, { + showName: true, + topN: 10, + donutChart: false, + }) - const [menu, setMenu] = initMenu() + const menu = initMenu() const { data: limitSummary, loading: limitSummaryLoading } = useRequest( () => menu.value === 'limit' ? getLimitSummary() : Promise.resolve(undefined), @@ -86,7 +101,7 @@ export const initPopupContext = (): ShallowRef => { useProvide(NAMESPACE, { reload, darkMode, setDarkMode, query, option, cateNameMap, - menu, setMenu, + menu, limitSummary, limitSummaryLoading, selectedLimit, }) @@ -102,38 +117,12 @@ const isQuery = createObjectGuard({ mergeMethod: createOptionalGuard(isMergeMethod), }) -const initQuery = () => { - const [queryCache, setQueryCache] = useLocalStorage('popup-query', isQuery, { - dimension: 'focus', - duration: 'today', - mergeMethod: undefined, - }) - - const query = reactive(queryCache) - watch(query, () => setQueryCache(toRaw(query)), { deep: true }) - - return query -} - const isOption = createObjectGuard({ showName: isBoolean, topN: isNumber, donutChart: isBoolean, }) -const initOption = () => { - const [optionCache, setOptionCache] = useLocalStorage('popup-option', isOption, { - showName: true, - topN: 10, - donutChart: false, - }) - - const option = reactive(optionCache) - watch(option, () => setOptionCache(toRaw(option)), { deep: true }) - - return option -} - export const usePopupContext = () => useProvider( NAMESPACE, 'reload', 'darkMode', 'setDarkMode', 'cateNameMap' ) @@ -144,7 +133,7 @@ export const useOption = () => useProvider(NAMESPAC export const useCateNameMap = () => useProvider(NAMESPACE, 'cateNameMap')?.cateNameMap -export const useMenu = () => useProvider(NAMESPACE, 'menu', 'setMenu') +export const useMenu = () => useProvider(NAMESPACE, 'menu').menu export const useLimitSummary = () => { const { limitSummary: summary, limitSummaryLoading: loading, selectedLimit: selected } = useProvider( diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index 84f51a1d4..d457292f3 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -1,4 +1,3 @@ -import { useLocalStorage } from '@hooks' import { createStringUnionGuard } from 'typescript-guard' import type { App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" @@ -9,11 +8,9 @@ type MyRoute = | (Omit & { redirect: Path }) -const createRoutes = (stored: tt4b.ui.PopupMenu | undefined): MyRoute[] => [ +// Not to set redirect path for '/', which is managed by menu context +const createRoutes = (): MyRoute[] => [ { - path: '/', - redirect: stored ? `/${stored}` : '/percentage', - }, { path: '/percentage', component: () => import('./components/Percentage'), }, { @@ -28,8 +25,7 @@ const createRoutes = (stored: tt4b.ui.PopupMenu | undefined): MyRoute[] => [ export const isMenu = createStringUnionGuard('limit', 'percentage', 'ranking') export default (app: App) => { - const [stored] = useLocalStorage('popup_menu', isMenu) - const routes = createRoutes(stored) + const routes = createRoutes() const history = createWebHashHistory() const router = createRouter({ routes, history }) app.use(router) diff --git a/src/util/time.ts b/src/util/time.ts index 13656e862..7ddf3f0bb 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -147,7 +147,9 @@ export const daysAgo = (start: number, end: number): [Date, Date] => { return [new Date(current - start * MILL_PER_DAY), new Date(current - end * MILL_PER_DAY)] } -export function isSameDay(a: Date, b: Date): boolean { +export function isSameDay(a: Date | number, b: Date | number): boolean { + a = typeof a === 'number' ? new Date(a) : a + b = typeof b === 'number' ? new Date(b) : b return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() @@ -197,7 +199,7 @@ export function getStartOfDay(target: Date | number): number { */ export function getBirthday(): Date { const date = new Date() - // 2022-03-03 + // 2021-03-03 date.setFullYear(2021) date.setMonth(2) date.setDate(3) @@ -205,7 +207,7 @@ export function getBirthday(): Date { return date } -export const BIRTHDAY = '20220303' +export const BIRTHDAY = '20210303' /** * Calc the day length @@ -214,15 +216,17 @@ export const BIRTHDAY = '20220303' * 0 if 2022-06-10 00:00:00 to 2022-06-09 00:00:01 * 2 if 2022-11-10 08:00:00 to 2022-11-11 00:00:01 */ -export function getDayLength(dateStart: Date, dateEnd: Date): number { - let cursor = new Date(dateStart) - let dateDiff = 0 - do { - dateDiff += 1 - cursor = new Date(cursor.getTime() + MILL_PER_DAY) - } while (cursor.getTime() < dateEnd.getTime()) - isSameDay(cursor, dateEnd) && dateDiff++ - return dateDiff +export function getDayLength(start: Date | number, end: Date | number): number { + const d1 = new Date(start) + const d2 = new Date(end) + + if (d1 > d2) return 0 + + d1.setHours(0, 0, 0, 0) + d2.setHours(0, 0, 0, 0) + + // Use round to correct the error caused by daylight saving time + return Math.round((d2.getTime() - d1.getTime()) / MILL_PER_DAY) + 1 } /** @@ -233,15 +237,23 @@ export function getDayLength(dateStart: Date, dateEnd: Date): number { * [] 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, formatter?: Converter): string[] { - let cursor = new Date(dateStart) - let dates: string[] = [] - formatter = formatter ?? formatTimeYMD - do { - dates.push(formatter(cursor)) - cursor = new Date(cursor.getTime() + MILL_PER_DAY) - } while (cursor.getTime() < dateEnd.getTime()) - isSameDay(cursor, dateEnd) && dates.push(formatter(dateEnd)) +export function getAllDatesBetween(start: Date | number, end: Date | number, formatter?: Converter): string[] { + const current = new Date(start) + const target = new Date(end) + + if (current > target) return [] + + current.setHours(0, 0, 0, 0) + target.setHours(0, 0, 0, 0) + + const dates: string[] = [] + const fmt = formatter ?? formatTimeYMD + + while (current <= target) { + dates.push(fmt(current)) + current.setDate(current.getDate() + 1) + } + return dates } @@ -262,7 +274,7 @@ export function parseTime(dateStr: string | undefined): Date | undefined { export type DateRange = Date | [Date?, Date?] | undefined -export const cvtDateRange2Str = (range: DateRange): [string?, string?] | undefined => { +export const cvtDateRange2Str = (range: DateRange | [number?, number?]): [string?, string?] | undefined => { if (range === undefined) return undefined if (range instanceof Date) { // The same day @@ -270,5 +282,7 @@ export const cvtDateRange2Str = (range: DateRange): [string?, string?] | undefin return [date, date] } const [start, end] = range - return [start && formatTimeYMD(start), end && formatTimeYMD(end)] + const startStr = start === undefined ? undefined : formatTimeYMD(start) + const endStr = end === undefined ? undefined : formatTimeYMD(end) + return [startStr, endStr] } \ No newline at end of file diff --git a/test/pages/hooks/useLocalStorage.test.ts b/test/pages/hooks/useLocalStorage.test.ts new file mode 100644 index 000000000..7bd972f14 --- /dev/null +++ b/test/pages/hooks/useLocalStorage.test.ts @@ -0,0 +1,97 @@ +import { localReactive, localRef } from '@pages/hooks/useLocalStorage' +import { createArrayGuard, createObjectGuard, createOptionalGuard, isInt, isString } from 'typescript-guard' +import { nextTick } from 'vue' + +describe('Storage Utils Unit Tests (Rspack/Test)', () => { + beforeEach(() => localStorage.clear()) + + describe('localRef', () => { + it('should use default value when localStorage is empty', () => { + const numRef = localRef('test-num', isInt, 10) + expect(numRef.value).toBe(10) + }) + + it('should prioritize and deserialize cached value when localStorage has valid data', () => { + localStorage.setItem('test-num', JSON.stringify(100)) + const numRef = localRef('test-num', isInt, 10) + expect(numRef.value).toBe(100) + }) + + it('should fallback to default value when localStorage data fails TypeGuard validation', () => { + localStorage.setItem('test-num', JSON.stringify('I am a string, not a number')) + const numRef = localRef('test-num', isInt, 10) + expect(numRef.value).toBe(10) + }) + + it('should sync to localStorage automatically when ref value changes', async () => { + const numRef = localRef('test-num', isInt, 10) + numRef.value = 42 + + //must wait for the next tick, since Vue watch is asynchronous + await nextTick() + expect(localStorage.getItem('test-num')).toBe(JSON.stringify(42)) + }) + + it('should remove the key from localStorage when ref value is set to undefined', async () => { + localStorage.setItem('test-num', JSON.stringify(100)) + const numRef = localRef('test-num', isInt) + expect(numRef.value).toBe(100) + + numRef.value = undefined + await nextTick() + expect(localStorage.getItem('test-num')).toBeNull() + }) + }) + + describe('localReactive', () => { + type User = { + name: string + age: number + tags?: string[] + } + + const isTestUser = createObjectGuard({ + name: isString, + age: isInt, + tags: createOptionalGuard(createArrayGuard(isString)), + }) + + const defaultUser: User = { name: 'John Doe', age: 18, tags: ['frontend'] } + + it('should initialize with deep default value when localStorage is empty', () => { + const user = localReactive('test-user', isTestUser, defaultUser) + expect(user.name).toBe('John Doe') + expect(user.age).toBe(18) + }) + + it('should restore the reactive structure when localStorage has valid data', () => { + const cachedUser = { name: 'Jane Doe', age: 30 } + localStorage.setItem('test-user', JSON.stringify(cachedUser)) + + const user = localReactive('test-user', isTestUser, defaultUser) + expect(user.name).toBe('Jane Doe') + expect(user.age).toBe(30) + }) + + it('should trigger deep watch and sync to localStorage when mutating first-level properties', async () => { + const user = localReactive('test-user', isTestUser, defaultUser) + user.name = 'Alex' + + await nextTick() + const stored = JSON.parse(localStorage.getItem('test-user') || '{}') + expect(stored.name).toBe('Alex') + }) + + it('should trigger deep watch and write raw objects when mutating nested properties', async () => { + const user = localReactive('test-user', isTestUser, defaultUser) + + // Mutating a nested array + user.tags?.push('vue3') + + await nextTick() + const stored = JSON.parse(localStorage.getItem('test-user') || '{}') + expect(stored.tags).toContain('vue3') + expect(stored.tags).toHaveLength(2) + }) + }) +}) \ No newline at end of file diff --git a/test/util/time.test.ts b/test/util/time.test.ts index 696a985b9..20454460a 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -5,7 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { daysAgo, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, isSameDay } from "../../src/util/time" +import { + daysAgo, formatPeriodCommon, formatTime, getAllDatesBetween, getDayLength, getMonthTime, getStartOfDay, isSameDay, +} from "@util/time" test('time', () => { const dateStr = '2020/05/01 00:00:01' @@ -68,4 +70,35 @@ test("get start of day", () => { now.setHours(11, 30, 29, 999) const start = getStartOfDay(now) expect(start).toEqual(new Date(2022, 4, 2).getTime()) +}) + +describe("getDayLength", () => { + test("base usage", () => { + expect(getDayLength(new Date(2022, 4, 1, 3), new Date(2022, 4, 3, 2))).toEqual(3) + expect(getDayLength(new Date(2022, 7, 30, 23), new Date(2022, 8, 1, 2))).toEqual(3) + }) + + test("the same day", () => { + const start = new Date(2022, 4, 1, 3).getTime() + const end = new Date(2022, 4, 1, 2).getTime() + expect(getDayLength(start, end)).toEqual(0) + expect(getDayLength(end, start)).toEqual(1) + }) +}) + +describe("getAllDatesBetween", () => { + const start = new Date(2022, 4, 1, 3).getTime() + const end = new Date(2022, 4, 3, 2).getTime() + + test("base usage", () => { + expect(getAllDatesBetween(start, end)).toEqual(['20220501', '20220502', '20220503']) + expect(getAllDatesBetween(start, end, d => d.getFullYear().toString())).toEqual(['2022', '2022', '2022']) + }) + + test("the same day", () => { + const start = new Date(2022, 4, 1, 3).getTime() + const end = new Date(2022, 4, 1, 2).getTime() + expect(getAllDatesBetween(start, end)).toEqual([]) + expect(getAllDatesBetween(end, start)).toEqual(['20220501']) + }) }) \ No newline at end of file From 21c1ad141053fda4258193dd3403b13d5b18ec1c Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 20 Jun 2026 14:38:59 +0800 Subject: [PATCH 42/95] refactor: hooks --- .../Dashboard/components/Timeline/Summary.tsx | 36 +++++++-------- .../Record/Filter/MergeFilterItem.tsx | 45 ++++++++++--------- src/pages/hooks/index.ts | 1 - src/pages/hooks/useDocumentVisibility.ts | 4 +- src/pages/hooks/useManualRequest.ts | 17 ------- src/pages/hooks/useRequest.ts | 15 +++++++ src/pages/hooks/useSiteMerge.ts | 22 ++++----- .../popup/components/Footer/DataToolbar.tsx | 7 +-- 8 files changed, 68 insertions(+), 79 deletions(-) delete mode 100644 src/pages/hooks/useManualRequest.ts diff --git a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx index 2831e44bc..5ccd18dc8 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx @@ -1,11 +1,10 @@ import { t } from '@app/locale' import { InfoFilled } from '@element-plus/icons-vue' -import { useShadow } from '@hooks' import Flex from '@pages/components/Flex' import { groupBy } from '@util/array' import { MILL_PER_HOUR, MILL_PER_MINUTE } from '@util/time' import { ElIcon, ElRate, ElText, ElTooltip } from 'element-plus' -import { computed, defineComponent } from 'vue' +import { computed, defineComponent, type FunctionalComponent } from 'vue' import { useTimelineContext } from './context' const MAX_SCORE = 5 @@ -72,24 +71,21 @@ const analyze = (activities: tt4b.timeline.Activity[]): { busy: number, focus: n return { busy, focus } } -const Score = defineComponent<{ score: number, label: string, desc: string }>(props => { - const [score] = useShadow(() => props.score) - return () => ( - - - {`${props.label} `} - - - - - - - - - - - ) -}, { props: ['desc', 'label', 'score'] }) +const Score: FunctionalComponent<{ score: number, label: string, desc: string }> = props => ( + + + {`${props.label} `} + + + + + + + + + + +) const Summary = defineComponent<{}>(() => { const { activities } = useTimelineContext() diff --git a/src/pages/app/components/Record/Filter/MergeFilterItem.tsx b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx index 809cceaaa..40b3e40ef 100644 --- a/src/pages/app/components/Record/Filter/MergeFilterItem.tsx +++ b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx @@ -4,28 +4,32 @@ import { Calendar, Collection, Link, Menu } from "@element-plus/icons-vue" import { useSiteMerge } from '@hooks' import Flex from "@pages/components/Flex" import { ElCheckboxButton, ElCheckboxGroup, ElIcon, ElText, ElTooltip } from "element-plus" -import { computed, defineComponent, StyleValue } from "vue" -import { type JSX } from "vue/jsx-runtime" +import { createArrayGuard, createStringUnionGuard } from 'typescript-guard' +import { type Component, computed, defineComponent, type StyleValue } from "vue" import { useRecordFilter } from "../context" -const METHOD_ICONS: Record = { - cate: , - date: , - domain: , - group:
    , +const METHOD_ICONS: Record = { + cate: Collection, + date: Calendar, + domain: Link, + group: Menu, } +const isMergeMethods = createArrayGuard( + createStringUnionGuard('cate', 'date', 'domain', 'group') +) + +const ICON_STYLE: StyleValue = { margin: '-6px' } + const MergeFilterItem = defineComponent<{}>(() => { const filter = useRecordFilter() const cate = useCategory() - const { mergeItems: siteMergeItems } = useSiteMerge({ - onGroupDisabled: () => mergeMethod.value.filter(v => v !== 'group') - }) - const mergeItems = computed(() => { - const res = ['date', ...siteMergeItems.value] satisfies tt4b.stat.MergeMethod[] + const { methods: siteMergeMethods } = useSiteMerge() + const items = computed(() => { + const res = ['date', ...siteMergeMethods.value] satisfies tt4b.stat.MergeMethod[] return cate.enabled ? res : res.filter(m => m !== 'cate') }) - const mergeMethod = computed({ + const selected = computed({ get: () => { const { mergeDate, siteMerge } = filter const res: tt4b.stat.MergeMethod[] = [] @@ -36,7 +40,7 @@ const MergeFilterItem = defineComponent<{}>(() => { set: val => { filter.mergeDate = val.includes('date') const oldSiteMerge = filter.siteMerge - const newSiteMerge = siteMergeItems.value + const newSiteMerge = siteMergeMethods.value .filter(t => val.includes(t)) .sort(t => oldSiteMerge?.includes(t) ? 1 : -1)[0] filter.siteMerge = newSiteMerge @@ -44,19 +48,18 @@ const MergeFilterItem = defineComponent<{}>(() => { } }) + const handleChange = (val: unknown) => isMergeMethods(val) && (selected.value = val) + return () => ( {t(msg => msg.shared.merge.mergeBy)} - mergeMethod.value = val as tt4b.stat.MergeMethod[]} - > - {mergeItems.value.map(method => ( + + {items.value.map(method => ( msg.shared.merge.mergeMethod[method])} offset={20} placement="top"> - + {METHOD_ICONS[method]} @@ -65,6 +68,6 @@ const MergeFilterItem = defineComponent<{}>(() => { ) -}, { props: ['hideCate'] }) +}) export default MergeFilterItem \ No newline at end of file diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index 0c15ca528..df5894ef6 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -5,7 +5,6 @@ export * from "./useDocumentVisibility" export * from "./useEcharts" export * from "./useElementSize" export * from "./useLocalStorage" -export * from "./useManualRequest" export * from "./useMediaSize" export * from "./useProvider" export * from "./useRequest" diff --git a/src/pages/hooks/useDocumentVisibility.ts b/src/pages/hooks/useDocumentVisibility.ts index b30cb0568..1acc07b85 100644 --- a/src/pages/hooks/useDocumentVisibility.ts +++ b/src/pages/hooks/useDocumentVisibility.ts @@ -1,6 +1,6 @@ -import { onMounted, shallowRef } from 'vue' +import { onMounted, type ShallowRef, shallowRef } from 'vue' -export function useDocumentVisibility() { +export function useDocumentVisibility(): Readonly> { if (typeof document === 'undefined') return shallowRef('visible') const visibility = shallowRef(document.visibilityState) diff --git a/src/pages/hooks/useManualRequest.ts b/src/pages/hooks/useManualRequest.ts deleted file mode 100644 index 45dc09606..000000000 --- a/src/pages/hooks/useManualRequest.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type RequestOption, type RequestResult, useRequest } from './useRequest' - -export function useManualRequest

    ( - getter: (...p: P) => Awaitable, - option: MakeRequired, 'manual'>, 'defaultValue'>, -): RequestResult -export function useManualRequest

    ( - getter: (...p: P) => Awaitable, - option?: Omit, 'manual'>, -): RequestResult - -export function useManualRequest

    ( - getter: (...p: P) => Promise | T, - option?: Omit, 'manual'>, -): RequestResult { - return useRequest(getter, { ...option || {}, manual: true }) -} \ No newline at end of file diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index c36c81ecd..5a5816801 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -106,4 +106,19 @@ const useLoading = (option?: RequestOption) => { } } return null +} + +export function useManualRequest

    ( + getter: (...p: P) => Awaitable, + option: MakeRequired, 'manual'>, 'defaultValue'>, +): RequestResult +export function useManualRequest

    ( + getter: (...p: P) => Awaitable, + option?: Omit, 'manual'>, +): RequestResult +export function useManualRequest

    ( + getter: (...p: P) => Promise | T, + option?: Omit, 'manual'>, +): RequestResult { + return useRequest(getter, { ...option || {}, manual: true }) } \ No newline at end of file diff --git a/src/pages/hooks/useSiteMerge.ts b/src/pages/hooks/useSiteMerge.ts index d3b799e0c..4b6543901 100644 --- a/src/pages/hooks/useSiteMerge.ts +++ b/src/pages/hooks/useSiteMerge.ts @@ -2,21 +2,17 @@ import { getOption } from '@api/sw/option' import { computed } from 'vue' import { useRequest } from './useRequest' -type Options = { - onGroupDisabled?: NoArgCallback -} +export const useSiteMerge = () => { + const { data: tabGroupEnabled } = useRequest(async () => { + const option = await getOption() + return option?.countTabGroup ?? false + }, { defaultValue: false }) -export const useSiteMerge = ({ onGroupDisabled }: Options) => { - const { data: countTabGroup } = useRequest(() => getOption().then(o => o?.countTabGroup ?? false), { - defaultValue: false, - onSuccess: v => !v && onGroupDisabled?.() - }) - - const mergeItems = computed(() => { - const res: (Exclude)[] = ['cate', 'domain'] - countTabGroup.value && res.push('group') + const methods = computed(() => { + const res: Exclude[] = ['cate', 'domain'] + tabGroupEnabled.value && res.push('group') return res }) - return { mergeItems, countTabGroup } + return { methods, tabGroupEnabled } } \ No newline at end of file diff --git a/src/pages/popup/components/Footer/DataToolbar.tsx b/src/pages/popup/components/Footer/DataToolbar.tsx index 6492a7a74..9350a5a38 100644 --- a/src/pages/popup/components/Footer/DataToolbar.tsx +++ b/src/pages/popup/components/Footer/DataToolbar.tsx @@ -9,10 +9,7 @@ import DurationSelect from './DurationSelect' const DataToolbar = defineComponent(() => { const query = useQuery() - - const { mergeItems } = useSiteMerge({ - onGroupDisabled: () => query.mergeMethod === 'group' && (query.mergeMethod = undefined) - }) + const { methods } = useSiteMerge() return () => ( @@ -26,7 +23,7 @@ const DataToolbar = defineComponent(() => { style={{ width: '90px' }} options={[ { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, - ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), + ...methods.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), ]} /> From bf9db21241031e23488360b8bdce49b7234bdcea Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 20 Jun 2026 21:26:58 +0800 Subject: [PATCH 43/95] refactor: remove translations of error message --- src/i18n/message/app/record-resource.json | 14 -------- src/i18n/message/app/record.ts | 1 - .../message/app/site-manage-resource.json | 27 ---------------- src/i18n/message/app/site-manage.ts | 2 -- .../Option/categories/Notification/Footer.tsx | 2 +- .../components/Record/Filter/BatchDelete.tsx | 32 +++++++++---------- .../components/SiteManage/Filter/useBatch.ts | 8 ++--- .../popup/components/Percentage/chart.ts | 4 +-- 8 files changed, 22 insertions(+), 68 deletions(-) diff --git a/src/i18n/message/app/record-resource.json b/src/i18n/message/app/record-resource.json index 9b96fb627..57eaa658b 100644 --- a/src/i18n/message/app/record-resource.json +++ b/src/i18n/message/app/record-resource.json @@ -3,7 +3,6 @@ "exportFileName": "我的上网时间", "total": "合计访问 {visit} 次, 共 {focus}", "batchDelete": { - "noSelectedMsg": "请先在表格中勾选需要删除的行", "confirmMsg": "{example} 等在 {date} 的 {count} 条记录将会被删除!", "confirmMsgAll": "{example} 等的 {count} 条记录将会被删除!", "confirmMsgRange": "{example} 等在 {start} 至 {end} 之间的 {count} 条记录将会被删除!" @@ -24,7 +23,6 @@ "exportFileName": "我的上網時間紀錄", "total": "總造訪次數:{visit} 次,總使用時長:{focus}", "batchDelete": { - "noSelectedMsg": "請先勾選要刪除的資料列", "confirmMsg": "將刪除 {example} 等於 {date} 的 {count} 筆紀錄!", "confirmMsgAll": "將刪除 {example} 等的 {count} 筆紀錄!", "confirmMsgRange": "將刪除 {example} 等從 {start} 至 {end} 的 {count} 筆紀錄!" @@ -45,7 +43,6 @@ "exportFileName": "My_Browsing_Time", "total": "Total visits: {visit} times, total duration: {focus}", "batchDelete": { - "noSelectedMsg": "Please select the row you want to delete in the table first", "confirmMsg": "{count} records such as [{example}] of {date} will be deleted!", "confirmMsgAll": "{count} records such as [{example}] will be deleted!", "confirmMsgRange": "{count} records such as [{example}] between {start} and {end} will be deleted!" @@ -66,7 +63,6 @@ "exportFileName": "私のウェブ時間データ", "total": "合計訪問回数: {visit} 回、合計時間: {focus}", "batchDelete": { - "noSelectedMsg": "最初にテーブルで削除する行にチェックマークを付けてください", "confirmMsg": "{date} の {example} のようなサイトの {count} レコードは削除されます!", "confirmMsgAll": "{example} のようなサイトの {count} レコードは削除されます!", "confirmMsgRange": "{start} と {end} の間の {example} のようなサイトの {count} レコードが削除されます!" @@ -87,7 +83,6 @@ "exportFileName": "Meu_Tempo_de_Navegação", "total": "Visitas Totais: {visit} vezes, total duração: {focus}", "batchDelete": { - "noSelectedMsg": "Por favor, selecione a linha que deseja excluir na tabela primeiro", "confirmMsg": "{count} registros de sites como {example} em {date} serão excluídos!", "confirmMsgAll": "{count} registros para sites como {example} serão excluídos!", "confirmMsgRange": "{count} registros para sites como {example} entre {start} e {end} serão excluídos!" @@ -107,7 +102,6 @@ "uk": { "total": "Всього відвідувань: {visit} разів, загальна тривалість: {focus}", "batchDelete": { - "noSelectedMsg": "Спершу виберіть рядок, який ви хочете видалити", "confirmMsg": "{count} записів для сайтів, як-от {example}, за {date} будуть видалені!", "confirmMsgAll": "{count} записів для сайтів, як-от {example}, будуть видалені!", "confirmMsgRange": "{count} записів для сайтів, як-от {example}, між {start} та {end} будуть видалені!" @@ -128,7 +122,6 @@ "exportFileName": "Mi_Tiempo_De_Navegación", "total": "Visitas totales: {visit} veces, duración total: {focus}", "batchDelete": { - "noSelectedMsg": "Por favor, selecciona en la tabla primero la fila que deseas eliminar", "confirmMsg": "¡Se eliminarán {count} registros de sitios como {example} del {date}!", "confirmMsgAll": "¡Se eliminarán {count} registros de sitios como {example}!", "confirmMsgRange": "¡Se eliminarán {count} registros de sitios como {example} entre {start} y {end}!" @@ -149,7 +142,6 @@ "exportFileName": "Timer_Daten", "total": "Besuche Gesamt: {visit}, Gesamtdauer: {focus}", "batchDelete": { - "noSelectedMsg": "Bitte wählen Sie die Zeile aus, die Sie zuerst in der Tabelle löschen möchten", "confirmMsg": "{count} Einträge für Sites wie {example} auf {date} werden gelöscht!", "confirmMsgAll": "{count} Einträge für Sites wie {example} werden gelöscht!", "confirmMsgRange": "{count} Einträge für Sites wie {example} zwischen {start} und {end} werden gelöscht!" @@ -170,7 +162,6 @@ "exportFileName": "Mon_heure_de_navigation", "total": "Visites totales : {visit} fois, durée totale : {focus}", "batchDelete": { - "noSelectedMsg": "Veuillez d'abord sélectionner la ligne que vous souhaitez supprimer dans le tableau", "confirmMsg": "{count} enregistrements pour des sites comme {example} le {date} seront supprimés !", "confirmMsgAll": "{count} enregistrements pour des sites comme {example} seront supprimés !", "confirmMsgRange": "{count} enregistrements pour des sites comme {example} entre {start} et {end} seront supprimés !" @@ -190,7 +181,6 @@ "ru": { "exportFileName": "Мое_время_просмотра", "batchDelete": { - "noSelectedMsg": "Пожалуйста, выберите строку, которую вы хотите удалить в таблице", "confirmMsg": "{count} записи для таких сайтов, как {example} на {date} будут удалены!", "confirmMsgAll": "{count} записи для таких сайтов, как {example}, будут удалены!", "confirmMsgRange": "{count} записи для таких сайтов, как {example} между {start} и {end} будут удалены!" @@ -211,7 +201,6 @@ "exportFileName": "وقت_التصفح_الخاص_بي", "total": "إجمالي الزيارات: {visit} مرة، المدة الإجمالية: {focus}", "batchDelete": { - "noSelectedMsg": "الرجاء تحديد الصف الذي تريد حذفه من الجدول أولاً", "confirmMsg": "سيتم حذف {count} سجلًا لمواقع مثل {example} في {date}!", "confirmMsgAll": "سيتم حذف {count} سجلًا للمواقع مثل {example}!", "confirmMsgRange": "سيتم حذف {count} سجلًا لمواقع مثل {example} بين {start} و{end}!" @@ -232,7 +221,6 @@ "exportFileName": "Gezinti_Zamanım", "total": "Toplam ziyaret sayısı: {visit} kez, toplam süre: {focus}", "batchDelete": { - "noSelectedMsg": "Lütfen önce tabloda silmek istediğiniz satırı seçin", "confirmMsg": "{date} tarihine ait [{example}] gibi {count} kayıt silinecektir!", "confirmMsgAll": "[{example}] gibi {count} kayıt silinecek!", "confirmMsgRange": "{start} ile {end} arasında [{example}] gibi {count} kayıt silinecek!" @@ -252,7 +240,6 @@ "pl": { "total": "Całkowita liczba wizyt: {visit}, całkowity czas trwania: {focus}", "batchDelete": { - "noSelectedMsg": "Proszę najpierw wybrać wiersz, który chcesz usunąć z tabeli", "confirmMsg": "{count} rekord/y/ów takich jak [{example}] z {date} zostaną usunięte!", "confirmMsgAll": "{count} rekord/y/ów takich jak [{example}] zostaną usunięte!", "confirmMsgRange": "{count} rekord/y/ów takich jak [{example}] pomiędzy {start} i {end} zostaną usunięte!" @@ -272,7 +259,6 @@ "it": { "total": "Visite totali: {visit} volte, durata totale: {focus}", "batchDelete": { - "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", "confirmMsg": "{count} record come [{example}] di {date} saranno eliminati!", "confirmMsgAll": "{count} record come [{example}], saranno eliminati!", "confirmMsgRange": "{count} record come [{example}] tra {start} ed {end} saranno eliminati!" diff --git a/src/i18n/message/app/record.ts b/src/i18n/message/app/record.ts index c4c17e57e..874d80dd1 100644 --- a/src/i18n/message/app/record.ts +++ b/src/i18n/message/app/record.ts @@ -11,7 +11,6 @@ export type RecordMessage = { exportFileName: string total: string batchDelete: { - noSelectedMsg: string confirmMsg: string confirmMsgAll: string confirmMsgRange: string diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 745564ff2..f7e61a105 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -36,8 +36,6 @@ "msg": { "hostExistWarn": "{host} 已经存在", "existedTag": "已存在", - "noSelected": "未选择站点", - "noSupported": "所选站点均不能设置类目", "disassociatedMsg": "是否需要清除所有已选中站点的类目?", "batchDeleteMsg": "是否需要删除所有已选中站点?" } @@ -79,8 +77,6 @@ "msg": { "hostExistWarn": "「{host}」已存在", "existedTag": "已存在", - "noSelected": "未選取網站", - "noSupported": "選取的網站不支援設定分類", "disassociatedMsg": "確定要清除所有選取網站的分類嗎?", "batchDeleteMsg": "確定要刪除所有選取的網站嗎?" } @@ -122,8 +118,6 @@ "msg": { "hostExistWarn": "{host} exists", "existedTag": "EXISTED", - "noSelected": "No site selected", - "noSupported": "The selected sites cannot set categories", "disassociatedMsg": "Do you want to clear the categories of all selected sites?", "batchDeleteMsg": "Do you want to delete all selected sites?" } @@ -165,8 +159,6 @@ "msg": { "hostExistWarn": "{host} が存在します", "existedTag": "既存", - "noSelected": "サイトが選択されていません", - "noSupported": "選択されたサイトはカテゴリーを設定できない", "disassociatedMsg": "選択したすべてのサイトのカテゴリーを消去しますか?", "batchDeleteMsg": "選択したサイトをすべて削除しますか?" } @@ -208,8 +200,6 @@ "msg": { "hostExistWarn": "{host} já existe", "existedTag": "EXISTENTE", - "noSelected": "Nenhum site selecionado", - "noSupported": "Sites selecionados não suportam categorias", "disassociatedMsg": "Limpar categorias de todos os sites selecionados?", "batchDeleteMsg": "Eliminar todos os sites selecionados?" } @@ -251,8 +241,6 @@ "msg": { "hostExistWarn": "{host} вже існує", "existedTag": "ВЖЕ ІСНУЄ", - "noSelected": "Сайт не вибрано", - "noSupported": "Для вибраних сайтів не можна встановити категорії", "disassociatedMsg": "Хочете очистити категорії всіх вибраних сайтів?", "batchDeleteMsg": "Хочете видалити всі вибрані сайти?" } @@ -294,8 +282,6 @@ "msg": { "hostExistWarn": "{host} existe", "existedTag": "EXISTIÓ", - "noSelected": "Ningún sitio seleccionado", - "noSupported": "Los sitios seleccionados no pueden establecer categorías", "disassociatedMsg": "¿Quieres borrar las categorías de todos los sitios seleccionados?", "batchDeleteMsg": "¿Quieres eliminar todos los sitios seleccionados?" } @@ -337,8 +323,6 @@ "msg": { "hostExistWarn": "{host} existiert", "existedTag": "EXISTIERT", - "noSelected": "Keine Website ausgewählt", - "noSupported": "Die ausgewählten Sites können keine Kategorien festlegen", "disassociatedMsg": "Möchten Sie die Kategorien aller ausgewählten Sites löschen?", "batchDeleteMsg": "Möchten Sie alle ausgewählten Sites löschen?" } @@ -380,8 +364,6 @@ "msg": { "hostExistWarn": "{host} existe", "existedTag": "EXISÉ", - "noSelected": "Aucun site sélectionné", - "noSupported": "Les sites sélectionnés ne peuvent pas définir de catégories", "disassociatedMsg": "Voulez-vous effacer les catégories de tous les sites sélectionnés ?", "batchDeleteMsg": "Voulez-vous supprimer tous les sites sélectionnés ?" } @@ -420,7 +402,6 @@ "msg": { "hostExistWarn": "{host} существует", "existedTag": "ВЫПОЛНЕНО", - "noSelected": "Сайт не выбран", "batchDeleteMsg": "Вы уверенны, что хотите удалить выбранные сайты?" } }, @@ -461,8 +442,6 @@ "msg": { "hostExistWarn": "{host} موجود فعلًا", "existedTag": "موجودة", - "noSelected": "لم يتم تحديد الموقع", - "noSupported": "لا يمكن تعيين فئات للمواقع المحددة", "disassociatedMsg": "هل تريد إخلاء فئات جميع المواقع المحددة؟", "batchDeleteMsg": "هل تريد حذف جميع المواقع المحددة؟" } @@ -504,8 +483,6 @@ "msg": { "hostExistWarn": "{host} zaten mevcut", "existedTag": "MEVCUT", - "noSelected": "Seçilmiş site yok", - "noSupported": "Seçilen siteler için kategori ayarlanamıyor", "disassociatedMsg": "Seçilen tüm sitelerin kategorilerini temizlemek ister misiniz?", "batchDeleteMsg": "Seçili tüm siteleri silmek istiyor musunuz?" } @@ -547,8 +524,6 @@ "msg": { "hostExistWarn": "{host} istnieje", "existedTag": "WYŁĄCZONE", - "noSelected": "Nie wybrano żadnej witryny", - "noSupported": "Wybrane witryny nie mogą ustawić kategorii", "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } @@ -590,8 +565,6 @@ "msg": { "hostExistWarn": "{host} esiste", "existedTag": "ESISTEVA", - "noSelected": "Nessun sito selezionato", - "noSupported": "I siti selezionati non possono impostare categorie", "disassociatedMsg": "Vuoi cancellare le categorie di tutti i siti selezionati?", "batchDeleteMsg": "Vuoi eliminare tutti i siti selezionati?" } diff --git a/src/i18n/message/app/site-manage.ts b/src/i18n/message/app/site-manage.ts index 7f274756d..7ae108045 100644 --- a/src/i18n/message/app/site-manage.ts +++ b/src/i18n/message/app/site-manage.ts @@ -31,8 +31,6 @@ export type SiteManageMessage = { msg: { hostExistWarn: string existedTag: string - noSelected: string - noSupported: string disassociatedMsg: string batchDeleteMsg: string } diff --git a/src/pages/app/components/Option/categories/Notification/Footer.tsx b/src/pages/app/components/Option/categories/Notification/Footer.tsx index 3c3746a4f..25883faf0 100644 --- a/src/pages/app/components/Option/categories/Notification/Footer.tsx +++ b/src/pages/app/components/Option/categories/Notification/Footer.tsx @@ -12,7 +12,7 @@ const Footer: FunctionalComponent<{}> = () => { if (errMsg) throw new Error(errMsg) ElMessage.success('Valid!') } catch (e) { - const msg = e instanceof Error ? e.message : String(e) ?? 'Unknown error' + const msg = e instanceof Error ? e.message : e?.toString() ?? 'Unknown error' ElMessage.error(msg) } } diff --git a/src/pages/app/components/Record/Filter/BatchDelete.tsx b/src/pages/app/components/Record/Filter/BatchDelete.tsx index 5dda17ee7..f13942335 100644 --- a/src/pages/app/components/Record/Filter/BatchDelete.tsx +++ b/src/pages/app/components/Record/Filter/BatchDelete.tsx @@ -11,6 +11,16 @@ import { computed, defineComponent } from "vue" import { useRecordComponent, useRecordFilter } from "../context" import type { DisplayComponent, RecordFilterOption } from "../types" +async function extractExample(hostExample: string | undefined, groupIdExample: number | undefined): Promise { + if (hostExample) return hostExample + if (groupIdExample) { + const group = await getGroup(groupIdExample) + return group?.title ?? `ID:${groupIdExample}` + } + // Never happen + return 'NaN' +} + async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: [number?, number?]): Promise { const hosts: string[] = [] const groupIds: number[] = [] @@ -18,17 +28,8 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole isSite(row) && hosts.push(row.siteKey.host) isGroup(row) && groupIds.push(row.groupKey) }) - let example: string | undefined = hosts[0] - if (!example) { - const groupId = groupIds[0] - const group = groupId ? await getGroup(groupId) : undefined - example = group?.title ?? `ID:${groupId}` - } - if (!example) { - // Never happen - return t(msg => msg.record.batchDelete.noSelectedMsg) - } - let count2Delete = selected.length ?? 0 + const example = await extractExample(hosts[0], groupIds[0]) + let count2Delete = selected.length if (mergeDate) { // All the items const date = cvtDateRange2Str(dateRange) ?? [] @@ -75,11 +76,8 @@ async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boole async function handleBatchDelete(displayComp: DisplayComponent | undefined, filter: RecordFilterOption) { if (!displayComp) return - const selected = displayComp?.getSelected?.() ?? [] - if (!selected?.length) { - ElMessage.info(t(msg => msg.record.batchDelete.noSelectedMsg)) - return - } + const selected = displayComp?.getSelected?.() + if (!selected?.length) return ElMessage.info("No item selected") const { dateRange, mergeDate } = filter ElMessageBox({ message: await computeBatchDeleteMsg(selected, mergeDate, dateRange), @@ -115,7 +113,7 @@ async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRa } } -const BatchDelete = defineComponent(() => { +const BatchDelete = defineComponent<{}>(() => { const filter = useRecordFilter() const disabled = computed(() => { const { siteMerge } = filter diff --git a/src/pages/app/components/SiteManage/Filter/useBatch.ts b/src/pages/app/components/SiteManage/Filter/useBatch.ts index 6d126fcca..a5160b332 100644 --- a/src/pages/app/components/SiteManage/Filter/useBatch.ts +++ b/src/pages/app/components/SiteManage/Filter/useBatch.ts @@ -16,10 +16,10 @@ export const useBatch = () => { const batchChange = () => { if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + return ElMessage.info("No site selected") } if (!supported.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSupported)) + return ElMessage.info("Selected sites don't support category") } targetCate.value = undefined openSelect() @@ -37,7 +37,7 @@ export const useBatch = () => { const batchDisassociate = () => { if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + return ElMessage.info("No site selected") } ElMessageBox.confirm( t(msg => msg.siteManage.msg.disassociatedMsg), @@ -55,7 +55,7 @@ export const useBatch = () => { } const batchDelete = () => { if (!selected.value.length) { - return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) + return ElMessage.info("No site selected") } ElMessageBox.confirm( t(msg => msg.siteManage.msg.batchDeleteMsg), diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index d845451d5..c0a52d2cd 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -254,8 +254,8 @@ export function generateToolbox(getInstance: () => ECharts | undefined): Toolbox onclick: () => { const inst = getInstance() if (!inst) return - inst && void saveWithWatermark(inst).catch(err => { - console.info(err) + saveWithWatermark(inst).catch(err => { + console.info("Save image error:", err) ElMessage.error('Could not save the image.') }) }, From 356cbb607bd2ab322928fce890c50808d95a4e95 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 21 Jun 2026 21:37:09 +0800 Subject: [PATCH 44/95] refactor(cs): location --- src/content-script/index.ts | 18 ++-- src/content-script/limit/index.ts | 58 ++-------- src/content-script/limit/modal/instance.ts | 18 ++-- .../limit/processor/message-adaptor.ts | 45 ++++---- .../limit/processor/period-processor.ts | 68 ++++++------ .../limit/processor/visit-processor.ts | 44 ++++---- src/content-script/limit/types.ts | 15 +-- src/content-script/location-watcher.ts | 100 ++++++++++-------- src/content-script/tracker/run-time.ts | 50 +++++---- src/pages/popup/router.ts | 7 +- src/util/guard.ts | 2 +- src/util/lang.ts | 17 +-- test-e2e/common/overlay.ts | 30 ++++++ test-e2e/limit/common.ts | 37 +------ test-e2e/limit/daily-limit.test.ts | 18 ++-- test-e2e/limit/delay-duration.test.ts | 9 +- test-e2e/limit/visit-limit.test.ts | 7 +- test/content-script/location-watcher.test.ts | 72 +++++++------ test/util/lang.test.ts | 16 +++ 19 files changed, 317 insertions(+), 314 deletions(-) create mode 100644 test-e2e/common/overlay.ts create mode 100644 test/util/lang.test.ts diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 64dd9f95b..ae40cd1cb 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -9,14 +9,12 @@ import { trySendMsg2Runtime } from '@api/sw/common' import { initLocale } from "@i18n" import Dispatcher from './dispatcher' import processLimit from "./limit" +import LocationWatcher from './location-watcher' import printInfo from "./printer" import processTimeline from './timeline' import NormalTracker from "./tracker/normal" import RunTimeTracker from "./tracker/run-time" -const host = document?.location?.host -const url = document?.location?.href - const FLAG_ID = '__TIMER_INJECTION_FLAG__' + chrome.runtime.id function getOrSetFlag(): boolean { @@ -50,18 +48,20 @@ async function main() { }) normalTracker.init() dispatcher.registerAudibleChange(normalTracker) - new RunTimeTracker(url).init(dispatcher) + + const location = new LocationWatcher() + await location.init() + + new RunTimeTracker(location).init(dispatcher) // Execute only one time for each dom if (getOrSetFlag()) return - if (!host || !url) return void initLocale() - await processLimit(url, dispatcher) - const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) - if (isWhitelist) return + await processLimit(location, dispatcher) + if (location.whitelisted) return - void printInfo(host) + void printInfo(location.host) processTimeline() // Increase visit count at the end diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index ed4c7bf1f..9001db37a 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,71 +1,33 @@ -import { trySendMsg2Runtime } from '@api/sw/common' import { getOption } from '@api/sw/option' -import LocationWatcher from '../location-watcher' import Dispatcher from '../dispatcher' +import LocationWatcher from '../location-watcher' import ModalInstance from './modal/instance' import MessageAdaptor from './processor/message-adaptor' import PeriodProcessor from './processor/period-processor' import VisitProcessor from './processor/visit-processor' import Reminder from './reminder' -import type { ModalContext, Processor } from './types' +import type { Processor } from './types' -const getHost = (url: string): string | undefined => { - try { - return new URL(url).host - } catch { - return undefined - } -} - -const isWhitelisted = async (url: string): Promise => { - const host = getHost(url) - if (!host) return true - return !!await trySendMsg2Runtime('whitelist.contain', { host, url }) -} - -export default async function processLimit(url: string, dispatcher: Dispatcher) { +export default async function processLimit(location: LocationWatcher, dispatcher: Dispatcher) { const { limitDelayDuration: delayDuration } = await getOption() - const modal = new ModalInstance(url) - const context: ModalContext = { modal, url: '' } + const modal = new ModalInstance(location) - const messageAdaptor = new MessageAdaptor(context, delayDuration) - const visitProcessor = new VisitProcessor(context, delayDuration) + const messageAdaptor = new MessageAdaptor(modal, location, delayDuration) + const visitProcessor = new VisitProcessor(modal, location, delayDuration) const processors: Processor[] = [ messageAdaptor, visitProcessor, - new PeriodProcessor(context), + new PeriodProcessor(modal, location), ] await Promise.all(processors.map(p => p.init())) - - let active = false - let refreshId = 0 - const refreshUrl = async (nextUrl: string): Promise => { - if (!nextUrl) return - const currentRefreshId = ++refreshId - const prevUrl = context.url - context.url = nextUrl - modal.setUrl(nextUrl) - active = false - const whitelisted = await isWhitelisted(nextUrl) - if (currentRefreshId !== refreshId) return - - await Promise.all(processors.map(p => p.onUrlRefreshed({ prevUrl, nextUrl, whitelisted }))) - if (currentRefreshId !== refreshId) return - active = !whitelisted - } - - await refreshUrl(url) - new LocationWatcher(url, nextUrl => void refreshUrl(nextUrl)).init() + location.onChange(() => void processors.forEach(p => void p.reset())) const reminder = new Reminder() dispatcher - .register('limitChanged', () => void refreshUrl(context.url)) - .register('limitTimeMeet', items => { - if (active) void messageAdaptor.onLimitTimeMeet(items) - return undefined - }) + .register('limitChanged', () => void processors.forEach(p => void p.reset())) + .register('limitTimeMeet', items => void messageAdaptor.onLimitTimeMeet(items)) .register('limitReminder', data => void reminder.show(data)) .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) .registerAudibleChange(visitProcessor.tracker) diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts index 96ebe0c3a..1be09677e 100644 --- a/src/content-script/limit/modal/instance.ts +++ b/src/content-script/limit/modal/instance.ts @@ -1,5 +1,6 @@ import { getRuntimeId, getUrl } from '@api/chrome/runtime' import { trySendMsg2Runtime } from '@api/sw/common' +import LocationWatcher from '@cs/location-watcher' import { exitFullscreen, isSameReason } from '../common' import type { LimitReason, MaskModal } from '../types' import { ModalBridge } from './bridge' @@ -65,29 +66,24 @@ class ScreenLocker { } class ModalInstance implements MaskModal { - url: string rootElement: RootElement | undefined iframe: HTMLIFrameElement | undefined delayHandlers: NoArgCallback[] = [ - () => trySendMsg2Runtime('limit.delay', this.url), + () => trySendMsg2Runtime('limit.delay', this.location.url), ] reasons: LimitReason[] = [] reason: LimitReason | undefined screenLocker = new ScreenLocker() private bridge: ModalBridge - constructor(url: string) { + constructor(private location: LocationWatcher) { (window as any)['__modal__'] = this - this.url = url this.bridge = new ModalBridge(MSG_ORIGIN, () => this.iframe?.contentWindow ?? undefined) .register('visitTime', () => this.reason?.getVisitTime?.() ?? 0) .register('delay', () => this.delayHandlers.forEach(handler => handler())) - } - - setUrl(url: string): void { - if (url === this.url) return - this.url = url - this.iframe?.contentWindow && this.bridge.request('url', url).catch(() => { }) + location.onChange(({ nextUrl }) => { + this.iframe?.contentWindow && this.bridge.request('url', nextUrl).catch(() => { }) + }) } addReason(...reasons2Add: LimitReason[]): void { @@ -131,7 +127,7 @@ class ModalInstance implements MaskModal { const root = await this.prepareRoot() if (!root) return const iframe = document.createElement('iframe') - iframe.src = `${MODAL_URL}?url=${encodeURIComponent(this.url)}` + iframe.src = `${MODAL_URL}?url=${encodeURIComponent(this.location.url)}` iframe.style.width = '100vw' iframe.style.height = '100vh' iframe.style.border = 'none' diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 533aa2b80..09591c0ee 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,6 +1,8 @@ import { trySendMsg2Runtime } from '@api/sw/common' +import LocationWatcher from '@cs/location-watcher' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import type { LimitReason, ModalContext, Processor, UrlRefreshContext } from '../types' +import ModalInstance from '../modal/instance' +import type { LimitReason, Processor } from '../types' const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item @@ -11,39 +13,38 @@ const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitR } class MessageAdaptor implements Processor { - constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } + constructor( + private readonly modal: ModalInstance, + private readonly location: LocationWatcher, + private readonly delayDuration: number, + ) { } - onLimitTimeMeet(items: tt4b.limit.Item[]): void { + async onLimitTimeMeet(items: tt4b.limit.Item[]): Promise { if (!items.length) return - items.filter(({ cond }) => matches(cond, this.context.url)) + if (this.location.whitelisted) return + + items.filter(({ cond }) => matches(cond, this.location.url)) .flatMap(item => cvtItem2AddReason(item, this.delayDuration)) - .forEach(reason => this.context.modal.addReason(reason)) + .forEach(reason => this.modal.addReason(reason)) } async init(): Promise { - this.context.modal.addDelayHandler(() => void this.initRules()) + this.modal.addDelayHandler(() => void this.reset()) + await this.reset() } - async onUrlRefreshed({ nextUrl, whitelisted }: UrlRefreshContext): Promise { - this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') - if (whitelisted) return - await this.fetchLimitedRules(nextUrl) - } + async reset(): Promise { + this.modal.removeReasonsByType('DAILY', 'WEEKLY') + if (this.location.whitelisted) return - async initRules(): Promise { - await this.onUrlRefreshed({ - prevUrl: this.context.url, - nextUrl: this.context.url, - whitelisted: false, + const limitedRules = await trySendMsg2Runtime('limit.list', { + limited: true, effective: true, + url: this.location.url, }) - } - - private async fetchLimitedRules(url: string): Promise { - const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url }) - if (url !== this.context.url || !limitedRules?.length) return + if (!limitedRules?.length) return const reasons = limitedRules.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) - this.context.modal.addReason(...reasons) + this.modal.addReason(...reasons) } } diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index 6afd64bd5..b1af01b3e 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -1,46 +1,52 @@ import { trySendMsg2Runtime } from '@api/sw/common' +import LocationWatcher from '@cs/location-watcher' import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import type { LimitReason, ModalContext, Processor, UrlRefreshContext } from '../types' - -function processRule(rule: tt4b.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType[] { - const { cond, periods, id } = rule - if (!periods?.length) return [] - return periods.flatMap(p => { - const [s, e] = p - const startSeconds = s * 60 - const endSeconds = (e + 1) * 60 - const reason: LimitReason = { id, cond, type: 'PERIOD' } - const timers: ReturnType[] = [] - if (nowSeconds < startSeconds) { - timers.push(setTimeout(() => context.modal.addReason(reason), (startSeconds - nowSeconds) * MILL_PER_SECOND)) - timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) - } else if (nowSeconds >= startSeconds && nowSeconds <= endSeconds) { - context.modal.addReason(reason) - timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) - } - return timers - }) -} +import ModalInstance from '../modal/instance' +import type { LimitReason, Processor } from '../types' class PeriodProcessor implements Processor { - private timers: ReturnType[] = [] + #timers: ReturnType[] = [] - constructor(private readonly context: ModalContext) { } + constructor( + private readonly modal: ModalInstance, + private readonly location: LocationWatcher, + ) { + } init(): void { + void this.reset() } - async onUrlRefreshed({ nextUrl, whitelisted }: UrlRefreshContext): Promise { - this.timers.forEach(clearTimeout) - this.timers = [] - this.context.modal.removeReasonsByType('PERIOD') - if (whitelisted) return + async reset(): Promise { + this.#timers.forEach(clearTimeout) + this.#timers = [] + this.modal.removeReasonsByType('PERIOD') + if (this.location.whitelisted) return - const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: nextUrl }) ?? [] - if (nextUrl !== this.context.url) return + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: this.location.url }) ?? [] const nowSeconds = date2Idx(new Date()) - this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) + this.#timers = rules.flatMap(r => this.#processRule(r, nowSeconds)) + } + + #processRule(rule: tt4b.limit.Rule, nowSeconds: number,): ReturnType[] { + const { cond, periods, id } = rule + if (!periods?.length) return [] + return periods.flatMap(p => { + const [s, e] = p + const startSeconds = s * 60 + const endSeconds = (e + 1) * 60 + const reason: LimitReason = { id, cond, type: 'PERIOD' } + const timers: ReturnType[] = [] + if (nowSeconds < startSeconds) { + timers.push(setTimeout(() => this.modal.addReason(reason), (startSeconds - nowSeconds) * MILL_PER_SECOND)) + timers.push(setTimeout(() => this.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) + } else if (nowSeconds >= startSeconds && nowSeconds <= endSeconds) { + this.modal.addReason(reason) + timers.push(setTimeout(() => this.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) + } + return timers + }) } } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index ff51d2cfc..b3a28fbb7 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -1,7 +1,9 @@ import { trySendMsg2Runtime } from '@api/sw/common' +import LocationWatcher from '@cs/location-watcher' import NormalTracker from "@cs/tracker/normal" import { MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import type { ModalContext, Processor, UrlRefreshContext } from '../types' +import ModalInstance from '../modal/instance' +import type { Processor } from '../types' class VisitProcessor implements Processor { private focusTime: number = 0 @@ -9,10 +11,20 @@ class VisitProcessor implements Processor { tracker: NormalTracker private delayCount: number = 0 - constructor(private readonly context: ModalContext, private readonly delayDuration: number) { + constructor( + private readonly modal: ModalInstance, + private readonly location: LocationWatcher, + private readonly delayDuration: number, + ) { this.tracker = new NormalTracker({ onReport: data => this.handleTracker(data), }) + location.onChange(async ({ prevUrl, nextUrl }) => { + if (prevUrl === nextUrl) return + // reset focus time and delay count when url changed + this.focusTime = 0 + this.delayCount = 0 + }) } private hasLimited(rule: tt4b.limit.Rule): boolean { @@ -28,7 +40,7 @@ class VisitProcessor implements Processor { this.rules.forEach(rule => { if (!this.hasLimited(rule)) return const { id, cond, allowDelay } = rule - this.context.modal.addReason({ + this.modal.addReason({ id, cond, type: 'VISIT', @@ -41,26 +53,20 @@ class VisitProcessor implements Processor { async init(): Promise { this.tracker.init() - this.context.modal.addDelayHandler(() => this.processDelay()) + this.modal.addDelayHandler(() => { + this.delayCount++ + this.modal.removeReasonsByType('VISIT') + }) + + void this.reset() } - async onUrlRefreshed({ prevUrl, nextUrl, whitelisted }: UrlRefreshContext): Promise { + async reset() { this.rules = [] - if (prevUrl !== nextUrl) { - this.focusTime = 0 - this.delayCount = 0 - } - this.context.modal.removeReasonsByType('VISIT') - if (whitelisted) return - - const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: nextUrl }) ?? [] - if (nextUrl !== this.context.url) return - this.rules = rules - } + this.modal.removeReasonsByType('VISIT') + if (this.location.whitelisted) return - private processDelay() { - this.delayCount++ - this.context.modal.removeReasonsByType('VISIT') + this.rules = await trySendMsg2Runtime('limit.list', { effective: true, url: this.location.url }) ?? [] } } diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts index b29cd3525..e654301ca 100644 --- a/src/content-script/limit/types.ts +++ b/src/content-script/limit/types.ts @@ -10,25 +10,14 @@ export type LimitReason = export interface MaskModal { readonly reasons: LimitReason[] - setUrl(url: string): void addReason(...reasons: LimitReason[]): void removeReason(...reasons: LimitReason[]): void removeReasonsByType(...types: tt4b.limit.ReasonType[]): void addDelayHandler(handler: NoArgCallback): void } -export type ModalContext = { - url: string - modal: MaskModal -} - -export type UrlRefreshContext = { - prevUrl: string - nextUrl: string - whitelisted: boolean -} - export interface Processor { init(): Awaitable - onUrlRefreshed(ctx: UrlRefreshContext): Awaitable + // Reset rules and reasons + reset(): Promise } diff --git a/src/content-script/location-watcher.ts b/src/content-script/location-watcher.ts index 3df99f38b..6c180fb81 100644 --- a/src/content-script/location-watcher.ts +++ b/src/content-script/location-watcher.ts @@ -1,63 +1,79 @@ -type UrlChangeHandler = (url: string, prevUrl: string) => void +import { trySendMsg2Runtime } from '@api/sw/common' +import { extractHostname } from '@util/pattern' + +function getHost(): string { + // For file protocol, window.location.host is empty + return window.location.host || extractHostname(window.location.href).host +} + +type ChangeEvent = { + prevUrl: string + prevHost: string + nextUrl: string + nextHost: string +} class LocationWatcher { - private url: string - private timer: number | undefined - private initialized = false - private originalPushState: History['pushState'] | undefined - private originalReplaceState: History['replaceState'] | undefined - private readonly handleChangeBound = this.handleChange.bind(this) + url: string + host: string + whitelisted: boolean + #handlers: ArgCallback[] = [] + #timer: ReturnType - constructor(url: string, private readonly handler: UrlChangeHandler, private readonly interval = 800) { - this.url = url - } + private readonly handleChangeBound = this.handleChange.bind(this) - init(): void { - if (this.initialized) return - this.initialized = true + constructor() { + this.url = window.location.href + this.host = getHost() + this.whitelisted = false + // Initialize immediately to catch the initial fields window.addEventListener('popstate', this.handleChangeBound) window.addEventListener('hashchange', this.handleChangeBound) - this.timer = window.setInterval(this.handleChangeBound, this.interval) - this.originalPushState = history.pushState - this.originalReplaceState = history.replaceState + // Because content scripts run in a sandboxed environment, overriding history methods is unnecessary + // So check URL changed via setTimeout loop instead + this.#timer = setInterval(this.handleChangeBound, 500) + } - history.pushState = (...args: Parameters) => { - this.originalPushState!.apply(history, args) - this.handleChangeBound() - } - history.replaceState = (...args: Parameters) => { - this.originalReplaceState!.apply(history, args) - this.handleChangeBound() - } + async init() { + await this.#syncWhitelisted() } - dispose(): void { - if (!this.initialized) return - this.initialized = false + async #syncWhitelisted() { + const value = await trySendMsg2Runtime('whitelist.contain', { host: this.host, url: this.url }) + this.whitelisted = !!value + } + dispose(): void { window.removeEventListener('popstate', this.handleChangeBound) window.removeEventListener('hashchange', this.handleChangeBound) - this.timer && clearInterval(this.timer) - this.timer = undefined + clearInterval(this.#timer) + } - if (this.originalPushState) { - history.pushState = this.originalPushState - this.originalPushState = undefined - } - if (this.originalReplaceState) { - history.replaceState = this.originalReplaceState - this.originalReplaceState = undefined + private async handleChange(): Promise { + const nextUrl = window.location.href + if (!nextUrl || nextUrl === this.url) return + const nextHost = getHost() + + const prevUrl = this.url + const prevHost = this.host + + this.url = nextUrl + this.host = nextHost + await this.#syncWhitelisted() + + const ev: ChangeEvent = { + prevUrl: prevUrl, + nextUrl: nextUrl, + prevHost: prevHost, + nextHost: nextHost, } + this.#handlers.forEach(h => h(ev)) } - private handleChange(): void { - const url = window.location.href - if (!url || url === this.url) return - const prevUrl = this.url - this.url = url - this.handler(url, prevUrl) + onChange(handler: ArgCallback): void { + this.#handlers.push(handler) } } diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index fbbe42ec0..328ee9fbb 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -1,45 +1,43 @@ import { trySendMsg2Runtime } from '@api/sw/common' -import { extractHostname } from '@util/pattern' +import LocationWatcher from '@cs/location-watcher' import Dispatcher from '../dispatcher' class RunTimeTracker { - private start: number = Date.now() - // Real host, including builtin hosts - private host: string | undefined + #start: number = Date.now() + #enabled = false - constructor(private readonly url: string) { + constructor(private readonly location: LocationWatcher) { + location.onChange(() => { + this.collect() + this.fetchEnabled() + }) } init(dispatcher: Dispatcher): void { - this.fetchSite() - dispatcher.register('siteRunChange', () => void this.fetchSite()) + void this.fetchEnabled() + dispatcher.register('siteRunChange', () => void this.fetchEnabled()) setInterval(() => this.collect(), 1000) } - private async fetchSite() { - const { host } = extractHostname(this.url) - if (!host) return - const enabled = await trySendMsg2Runtime('site.runEnabled', host) - this.host = enabled ? host : undefined + private async fetchEnabled() { + this.#enabled = !this.location.whitelisted + && !!(await trySendMsg2Runtime('site.runEnabled', this.location.host)) } private async collect() { const now = Date.now() - const lastTime = this.start - - try { - if (this.host) { - const event: tt4b.core.Event = { - start: lastTime, - end: now, - ignoreTabCheck: false, - host: this.host, - } - await trySendMsg2Runtime('track.runTime', event) - } - this.start = now - } catch { + const lastTime = this.#start + this.#start = now + + if (!this.#enabled) return + + const event: tt4b.core.Event = { + start: lastTime, + end: now, + ignoreTabCheck: false, + host: this.location.host, } + await trySendMsg2Runtime('track.runTime', event) } } diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index d457292f3..36b485150 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -5,6 +5,7 @@ import { createRouter, createWebHashHistory, type RouteRecordRedirect, type Rout type Path = `/${tt4b.ui.PopupMenu}` type MyRoute = | (Omit & { path: Path }) + | (Omit & { path: '/' }) | (Omit & { redirect: Path }) @@ -19,7 +20,11 @@ const createRoutes = (): MyRoute[] => [ }, { path: '/limit', component: () => import('./components/Limit'), - }, + }, { + // Use to remove warnings of "No match found for location with path '/'" + path: '/', + component: () => import('./components/Percentage'), + } ] export const isMenu = createStringUnionGuard('limit', 'percentage', 'ranking') diff --git a/src/util/guard.ts b/src/util/guard.ts index 13477380e..b3cfbf3ac 100644 --- a/src/util/guard.ts +++ b/src/util/guard.ts @@ -1,5 +1,5 @@ import { isInt } from 'typescript-guard' -export const isRecord = (unk: unknown): unk is Record => typeof unk === 'object' && unk !== null +export const isRecord = (unk: unknown): unk is Record => typeof unk === 'object' && unk !== null && !Array.isArray(unk) export const isVector2 = (unk: unknown): unk is Vector<2> => Array.isArray(unk) && unk.length === 2 && unk.every(isInt) \ No newline at end of file diff --git a/src/util/lang.ts b/src/util/lang.ts index 76cd6a2e6..e606fa5b1 100644 --- a/src/util/lang.ts +++ b/src/util/lang.ts @@ -1,12 +1,15 @@ -export const mergeObject = >(defaults: T, newVal: Partial | undefined): T => { - if (newVal === undefined) return defaults +import { isRecord } from './guard' - Object.entries(newVal).forEach(([k, v]) => { - if (typeof v === 'object' && !!v && !Array.isArray(v) && typeof defaults[k] === 'object' && !!defaults[k]) { - (defaults as any)[k] = mergeObject(defaults[k], v as Record) +export const mergeObject = >(target: T, toMerge: Partial | undefined): T => { + if (toMerge === undefined) return target + + Object.entries(toMerge).forEach(([k, v]) => { + const oldV = target[k] + if (isRecord(v) && isRecord(oldV)) { + (target as any)[k] = mergeObject(oldV, v) } else { - (defaults as any)[k] = v + (target as any)[k] = v } }) - return defaults + return target } \ No newline at end of file diff --git a/test-e2e/common/overlay.ts b/test-e2e/common/overlay.ts new file mode 100644 index 000000000..39c3feba5 --- /dev/null +++ b/test-e2e/common/overlay.ts @@ -0,0 +1,30 @@ +import type { Frame, Page } from 'puppeteer' + +const OVERLAY_TAG = 'extension-time-tracker-overlay' +const LIMIT_HTML = 'limit.html' + +export async function waitForLimitFrame(page: Page, timeout = 5000): Promise { + return page.waitForFrame(f => f.url().includes(LIMIT_HTML), { timeout }) +} + +export async function assertOverlayVisible(page: Page, timeout = 3000): Promise { + await page.waitForFunction((tag: string) => { + const overlay = document.querySelector(tag) + if (!overlay) return false + const iframe = overlay.shadowRoot?.firstElementChild + if (!(iframe instanceof HTMLIFrameElement)) return false + const { visibility, display } = iframe.style + return visibility !== 'hidden' && display !== 'none' + }, { timeout }, OVERLAY_TAG) +} + +export async function assertOverlayHidden(page: Page, timeout = 3000): Promise { + await page.waitForFunction((tag: string) => { + const overlay = document.querySelector(tag) + if (!overlay) return true + const iframe = overlay.shadowRoot?.firstElementChild + if (!(iframe instanceof HTMLIFrameElement)) return true + const { visibility, display } = iframe.style + return visibility === 'hidden' || display === 'none' + }, { timeout }, OVERLAY_TAG) +} \ No newline at end of file diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index b6526b36e..439e4f92a 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -1,21 +1,8 @@ -import type { ElementHandle, Frame, Page } from "puppeteer" +import type { ElementHandle, Page } from "puppeteer" import { fillCondEditor } from "../common/cond-editor" +import { waitForLimitFrame } from '../common/overlay' import { sleep } from "../common/util" -export async function waitForLimitModalHidden(page: Page, timeout = 15000): Promise { - await page.waitForFunction( - () => { - const overlay = document.querySelector('extension-time-tracker-overlay') - if (!overlay) return true - const iframe = overlay.shadowRoot?.firstElementChild - return !(iframe instanceof HTMLIFrameElement) - || iframe.style.visibility === 'hidden' - || iframe.style.display === 'none' - }, - { timeout }, - ) -} - export async function waitForLimitModal(page: Page, timeout = 15000): Promise { await page.waitForFunction( () => { @@ -30,26 +17,6 @@ export async function waitForLimitModal(page: Page, timeout = 15000): Promise { - return page.waitForFrame(f => f.url().includes('limit.html'), { timeout }) -} - -export async function queryLimitModalVisible(page: Page): Promise { - return await page.evaluate(async () => { - const overlay = document.querySelector('extension-time-tracker-overlay') - if (!overlay) return false - const iframe = overlay.shadowRoot?.firstElementChild - return iframe instanceof HTMLIFrameElement - && iframe.style.visibility !== 'hidden' - && iframe.style.display !== 'none' - }) -} - -export async function isLimitModalVisible(page: Page): Promise { - await page.waitForSelector('extension-time-tracker-overlay', { timeout: 3000 }) - return await queryLimitModalVisible(page) -} - export async function createLimitRule(rule: tt4b.limit.Rule, page: Page) { const createButton = await page.$('.el-card:first-child .el-button:last-child') await createButton!.click() diff --git a/test-e2e/limit/daily-limit.test.ts b/test-e2e/limit/daily-limit.test.ts index 5495b5eb3..068428a33 100644 --- a/test-e2e/limit/daily-limit.test.ts +++ b/test-e2e/limit/daily-limit.test.ts @@ -1,6 +1,7 @@ import { useLaunchContext } from '../common/base' +import { assertOverlayHidden, assertOverlayVisible, waitForLimitFrame } from '../common/overlay' import { MOCK_URL, sleep } from '../common/util' -import { createLimitRule, fillTimeLimit, isLimitModalVisible, queryLimitModalVisible, waitForLimitFrame, waitForLimitModal, waitForLimitModalHidden } from './common' +import { createLimitRule, fillTimeLimit, waitForLimitModal } from './common' describe('Daily limit', () => { const context = useLaunchContext() @@ -79,9 +80,7 @@ describe('Daily limit', () => { // 7. Modal disappear await testPage.bringToFront() - await sleep(.5) - const modalExist = await isLimitModalVisible(testPage) - expect(modalExist).toBeFalsy() + await assertOverlayHidden(testPage) }, 60000) test('blocks expired path after spa navigation', async () => { @@ -104,7 +103,7 @@ describe('Daily limit', () => { await blockedPage.close() const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) - expect(await queryLimitModalVisible(testPage)).toBeFalsy() + await assertOverlayHidden(testPage) await testPage.bringToFront() await testPage.evaluate(url => history.pushState({}, '', url), blockedUrl) @@ -114,11 +113,10 @@ describe('Daily limit', () => { const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') return td?.textContent && td.textContent !== '-' }, { timeout: 5000 }) - expect(await queryLimitModalVisible(testPage)).toBeTruthy() + await assertOverlayVisible(testPage) await testPage.evaluate(url => history.pushState({}, '', url), MOCK_URL) - await waitForLimitModalHidden(testPage) - expect(await queryLimitModalVisible(testPage)).toBeFalsy() + await assertOverlayHidden(testPage) }, 60000) test('Daily visit limit', async () => { @@ -183,8 +181,6 @@ describe('Daily limit', () => { // 5. The modal disappear await testPage.bringToFront() - await sleep(.5) - const modalExist = await isLimitModalVisible(testPage) - expect(modalExist).toBeFalsy() + await assertOverlayHidden(testPage) }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/delay-duration.test.ts b/test-e2e/limit/delay-duration.test.ts index 765d3de0c..bf312a284 100644 --- a/test-e2e/limit/delay-duration.test.ts +++ b/test-e2e/limit/delay-duration.test.ts @@ -1,8 +1,9 @@ import { formatTimeYMD, MILL_PER_SECOND } from '@util/time' import type { Page } from 'puppeteer' import { useLaunchContext } from '../common/base' +import { assertOverlayHidden, assertOverlayVisible } from '../common/overlay' import { MOCK_URL, sleep } from '../common/util' -import { clickDelay, createLimitRule, isLimitModalVisible } from './common' +import { clickDelay, createLimitRule } from './common' async function setDelayDuration(page: Page, value: number) { const delayInput = await page.waitForSelector('.el-input-number input') @@ -75,15 +76,15 @@ describe('Limit delay duration', () => { const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) await sleep(1) - expect(await isLimitModalVisible(testPage)).toBeTruthy() + await assertOverlayVisible(testPage) await clickDelay(testPage) // Not disappear if only delay once (1 minute delay) - expect(await isLimitModalVisible(testPage)).toBeTruthy() + await assertOverlayVisible(testPage) // Disappear if delay twice (2 minutes delay) await clickDelay(testPage) - expect(await isLimitModalVisible(testPage)).toBeFalsy() + await assertOverlayHidden(testPage) }, 45000) }) diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts index c5e586661..08fcbcdd6 100644 --- a/test-e2e/limit/visit-limit.test.ts +++ b/test-e2e/limit/visit-limit.test.ts @@ -1,6 +1,7 @@ import { useLaunchContext } from '../common/base' +import { assertOverlayHidden, waitForLimitFrame } from '../common/overlay' import { MOCK_URL, sleep } from '../common/util' -import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from './common' +import { createLimitRule } from './common' describe('Time limit per visit', () => { const context = useLaunchContext() @@ -28,9 +29,7 @@ describe('Time limit per visit', () => { await button!.click() // 4. Modal disappear - await sleep(.5) - const modalExist = await isLimitModalVisible(testPage) - expect(modalExist).toBeFalsy() + await assertOverlayHidden(testPage) }, 1000000000) }) \ No newline at end of file diff --git a/test/content-script/location-watcher.test.ts b/test/content-script/location-watcher.test.ts index 6de09d9f1..8896e823c 100644 --- a/test/content-script/location-watcher.test.ts +++ b/test/content-script/location-watcher.test.ts @@ -1,34 +1,50 @@ import LocationWatcher from '@cs/location-watcher' describe('LocationWatcher', () => { - beforeEach(() => { - history.replaceState({}, '', '/') - }) + beforeEach(() => history.replaceState({}, '', '/')) - test('pushState triggers handler immediately', () => { + test('pushState triggers handler immediately', async () => { const initialUrl = window.location.href + const host = window.location.host const handler = rstest.fn() - const watcher = new LocationWatcher(initialUrl, handler) - watcher.init() + const watcher = new LocationWatcher() + watcher.onChange(handler) + await watcher.init() history.pushState({}, '', '/page-a') + // Wait for interval to trigger the handler + await new Promise(resolve => setTimeout(resolve, 1000)) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(`${window.location.origin}/page-a`, initialUrl) + expect(handler).toHaveBeenCalledWith({ + nextUrl: `${window.location.origin}/page-a`, + prevUrl: initialUrl, + nextHost: host, + prevHost: host + }) watcher.dispose() }) - test('replaceState triggers handler immediately', () => { + test('replaceState triggers handler immediately', async () => { const initialUrl = window.location.href + const host = window.location.host const handler = rstest.fn() - const watcher = new LocationWatcher(initialUrl, handler) - watcher.init() + const watcher = new LocationWatcher() + watcher.onChange(handler) + await watcher.init() history.replaceState({}, '', '/page-b') + // Wait for interval to trigger the handler + await new Promise(resolve => setTimeout(resolve, 1000)) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(`${window.location.origin}/page-b`, initialUrl) + expect(handler).toHaveBeenCalledWith({ + nextUrl: `${window.location.origin}/page-b`, + prevUrl: initialUrl, + nextHost: host, + prevHost: host, + }) watcher.dispose() }) @@ -39,27 +55,36 @@ describe('LocationWatcher', () => { history.pushState({}, '', '/page-a') const pageAUrl = window.location.href const handler = rstest.fn() - const watcher = new LocationWatcher(pageAUrl, handler) - watcher.init() + const watcher = new LocationWatcher() + watcher.onChange(handler) + await watcher.init() const popstate = new Promise(resolve => { window.addEventListener('popstate', () => resolve(), { once: true }) }) history.back() await popstate + // Wait for interval to trigger the handler + await new Promise(resolve => setTimeout(resolve, 1000)) expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith(rootUrl, pageAUrl) + expect(handler).toHaveBeenCalledWith({ + nextUrl: rootUrl, + prevUrl: pageAUrl, + nextHost: window.location.host, + prevHost: window.location.host, + }) watcher.dispose() }) - test('dispose restores native history methods', () => { + test('dispose restores native history methods', async () => { const nativePushState = history.pushState const nativeReplaceState = history.replaceState const handler = rstest.fn() - const watcher = new LocationWatcher(window.location.href, handler) - watcher.init() + const watcher = new LocationWatcher() + watcher.onChange(handler) + await watcher.init() watcher.dispose() @@ -68,17 +93,4 @@ describe('LocationWatcher', () => { history.pushState({}, '', '/page-after-dispose') expect(handler).not.toHaveBeenCalled() }) - - test('init is idempotent', () => { - const handler = rstest.fn() - const watcher = new LocationWatcher(window.location.href, handler) - watcher.init() - watcher.init() - - history.pushState({}, '', '/page-a') - - expect(handler).toHaveBeenCalledTimes(1) - - watcher.dispose() - }) }) diff --git a/test/util/lang.test.ts b/test/util/lang.test.ts new file mode 100644 index 000000000..bbb306eec --- /dev/null +++ b/test/util/lang.test.ts @@ -0,0 +1,16 @@ +import { mergeObject } from '@util/lang' + +describe('mergeObject', () => { + test('should merge two objects correctly', () => { + const a: any = { x: 1, y: { z: 2 } } + + const b: any = { y: { w: 3, z: undefined }, v: 4 } + const r1 = mergeObject(a, b) + expect(r1).toEqual({ x: 1, y: { z: undefined, w: 3 }, v: 4 }) + + const c: any = { x: 1, y: { z: 2 } } + const d: any = { y: { z: {} }, v: 4 } + const r2 = mergeObject(c, d) + expect(r2).toEqual({ x: 1, y: { z: {} }, v: 4 }) + }) +}) \ No newline at end of file From 28aac5d9261b56c802f58d9c688f0664fe542a25 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 21 Jun 2026 22:27:00 +0800 Subject: [PATCH 45/95] refactor: types --- src/content-script/limit/processor/message-adaptor.ts | 5 ++--- src/content-script/limit/processor/period-processor.ts | 5 ++--- src/content-script/limit/processor/visit-processor.ts | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 09591c0ee..bab2db5a8 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,8 +1,7 @@ import { trySendMsg2Runtime } from '@api/sw/common' import LocationWatcher from '@cs/location-watcher' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import ModalInstance from '../modal/instance' -import type { LimitReason, Processor } from '../types' +import type { LimitReason, MaskModal, Processor } from '../types' const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item @@ -14,7 +13,7 @@ const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitR class MessageAdaptor implements Processor { constructor( - private readonly modal: ModalInstance, + private readonly modal: MaskModal, private readonly location: LocationWatcher, private readonly delayDuration: number, ) { } diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index b1af01b3e..34e8afa72 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -2,14 +2,13 @@ import { trySendMsg2Runtime } from '@api/sw/common' import LocationWatcher from '@cs/location-watcher' import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import ModalInstance from '../modal/instance' -import type { LimitReason, Processor } from '../types' +import type { LimitReason, MaskModal, Processor } from '../types' class PeriodProcessor implements Processor { #timers: ReturnType[] = [] constructor( - private readonly modal: ModalInstance, + private readonly modal: MaskModal, private readonly location: LocationWatcher, ) { } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index b3a28fbb7..7e3725806 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -2,8 +2,7 @@ import { trySendMsg2Runtime } from '@api/sw/common' import LocationWatcher from '@cs/location-watcher' import NormalTracker from "@cs/tracker/normal" import { MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import ModalInstance from '../modal/instance' -import type { Processor } from '../types' +import type { MaskModal, Processor } from '../types' class VisitProcessor implements Processor { private focusTime: number = 0 @@ -12,7 +11,7 @@ class VisitProcessor implements Processor { private delayCount: number = 0 constructor( - private readonly modal: ModalInstance, + private readonly modal: MaskModal, private readonly location: LocationWatcher, private readonly delayDuration: number, ) { From 0e4aedfdc44b066d1de2b6f36932ced32cd816ef Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 21 Jun 2026 22:52:39 +0800 Subject: [PATCH 46/95] fix: icon error --- src/pages/app/components/Record/Filter/MergeFilterItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/app/components/Record/Filter/MergeFilterItem.tsx b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx index 40b3e40ef..a0fe244eb 100644 --- a/src/pages/app/components/Record/Filter/MergeFilterItem.tsx +++ b/src/pages/app/components/Record/Filter/MergeFilterItem.tsx @@ -5,7 +5,7 @@ import { useSiteMerge } from '@hooks' import Flex from "@pages/components/Flex" import { ElCheckboxButton, ElCheckboxGroup, ElIcon, ElText, ElTooltip } from "element-plus" import { createArrayGuard, createStringUnionGuard } from 'typescript-guard' -import { type Component, computed, defineComponent, type StyleValue } from "vue" +import { type Component, computed, defineComponent, h, type StyleValue } from "vue" import { useRecordFilter } from "../context" const METHOD_ICONS: Record = { @@ -60,7 +60,7 @@ const MergeFilterItem = defineComponent<{}>(() => { msg.shared.merge.mergeMethod[method])} offset={20} placement="top"> - {METHOD_ICONS[method]} + {h(METHOD_ICONS[method])} From 8dd628738d2f716a78a9d14c7b0ae0a9c62d4ad3 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 21 Jun 2026 23:27:10 +0800 Subject: [PATCH 47/95] fix: limit modify --- .../components/Limit/components/List/Rule.tsx | 46 ++++++++----------- .../Limit/components/Modify/Step2.tsx | 4 +- .../Limit/components/Modify/index.tsx | 18 +++++++- src/pages/app/components/Limit/types.d.ts | 2 +- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx index c54f34b3a..063bb16f4 100644 --- a/src/pages/app/components/Limit/components/List/Rule.tsx +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -4,7 +4,7 @@ import Flex from '@pages/components/Flex' import { isEffective, meetLimit, meetTimeLimit, period2Str } from '@util/limit' import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' -import { defineComponent, type FunctionalComponent, toRefs } from 'vue' +import { defineComponent, type FunctionalComponent } from 'vue' import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' type Props = { @@ -48,34 +48,26 @@ const WastePair: FunctionalComponent = props => { ) } -const Rule = defineComponent(({ value }) => { - const { - time, count, waste, visit, - weekly, weeklyCount, weeklyWaste, weeklyVisit, - visitTime, periods, - weekdays, - allowDelay, delayCount, weeklyDelayCount, - } = toRefs(value) - +const Rule = defineComponent(props => { const delayDuration = useDelayDuration() return () => <> - msg.shared.limit.daily)} v-show={time?.value || count?.value}> - + msg.shared.limit.daily)} v-show={props.value.time || props.value.count}> + - msg.shared.limit.weekly)} v-show={weekly?.value || weeklyCount?.value}> - + msg.shared.limit.weekly)} v-show={props.value.weekly || props.value.weeklyCount}> + - {!!visitTime?.value && ( + {!!props.value.visitTime && ( msg.limit.item.visitTime)}> - {formatPeriodCommon(visitTime.value * MILL_PER_SECOND, true)} + {formatPeriodCommon(props.value.visitTime * MILL_PER_SECOND, true)} )} - {!!periods?.value?.length && ( + {!!props.value.periods?.length && ( msg.shared.limit.period)}> - {periods.value.map((p, idx) => ( + {props.value.periods.map((p, idx) => ( {period2Str(p)} ))} @@ -84,12 +76,12 @@ const Rule = defineComponent(({ value }) => { msg.calendar.range.today)}> - {isEffective(weekdays?.value) ? ( + {isEffective(props.value.weekdays) ? ( ) : ( @@ -99,10 +91,10 @@ const Rule = defineComponent(({ value }) => { msg.calendar.range.thisWeek)}> diff --git a/src/pages/app/components/Limit/components/Modify/Step2.tsx b/src/pages/app/components/Limit/components/Modify/Step2.tsx index 00e1fcad3..b0e10af80 100644 --- a/src/pages/app/components/Limit/components/Modify/Step2.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2.tsx @@ -12,10 +12,10 @@ import CondEditor, { type CondEditorInstance } from '@pages/components/CondEdito import Flex from "@pages/components/Flex" import { defineComponent, onUpdated, ref } from "vue" -const _default = defineComponent(() => { +const _default = defineComponent<{}>(() => { const { form: data } = useDialogSop() const editor = ref() - onUpdated(() => editor.value?.focus()) + onUpdated(() => !data.cond.length && editor.value?.focus()) return () => ( diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 0085e1470..1236c6af2 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -41,6 +41,21 @@ const createInitial = (url?: string): ModifyForm => ({ locked: false, }) +const createFormData = (data: tt4b.limit.Item): ModifyForm => ({ + name: data.name, + cond: [...data.cond], + enabled: data.enabled, + locked: data.locked, + allowDelay: data.allowDelay, + time: data.time, + count: data.count, + weekly: data.weekly, + weeklyCount: data.weeklyCount, + visitTime: data.visitTime, + weekdays: data.weekdays, + periods: data.periods, +}) + const _default = defineComponent((_, ctx) => { const { refresh } = useLimitData() const mode = ref() @@ -108,13 +123,12 @@ const _default = defineComponent((_, ctx) => { modifyingItem = undefined }, modify(row: tt4b.limit.Item) { - open(toRaw(row)) + open(createFormData(toRaw(row))) mode.value = 'modify' modifyingItem = { ...row } }, } satisfies ModifyInstance) - return () => ( diff --git a/src/pages/app/components/Limit/types.d.ts b/src/pages/app/components/Limit/types.d.ts index f2c2c8ccc..0b1f1e193 100644 --- a/src/pages/app/components/Limit/types.d.ts +++ b/src/pages/app/components/Limit/types.d.ts @@ -16,6 +16,6 @@ export type LimitInstance = { getSelected(): tt4b.limit.Item[] } -export type ModifyForm = Omit & { +export type ModifyForm = MakeOptionalUndefined> & { urlMiss?: boolean } \ No newline at end of file From 4725167a91db68e7854f24ef099a373e8492eed5 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 21 Jun 2026 23:30:56 +0800 Subject: [PATCH 48/95] v4.3.7 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d139fcc0c..755c9d647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.7] - 2027-06-15 + +- Supported custom icons for websites +- Fixed some bugs for Mobile + ## [4.3.6] - 2026-06-14 - Fixed some bugs diff --git a/package.json b/package.json index 9fdd1d601..64c2e8d74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.6", + "version": "4.3.7", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 21faa7ae2266911de6abada8c6c8e388bbf7680a Mon Sep 17 00:00:00 2001 From: bgme Date: Mon, 22 Jun 2026 12:34:07 +0800 Subject: [PATCH 49/95] fix(web-dav): fix report error for 204 response --- src/api/web-dav.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index 5d22367fa..4ce66530e 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -77,7 +77,7 @@ export async function deleteDir(context: WebDAVContext, dirPath: string) { if (status === 403) { throw new Error("Unauthorized to delete directory") } - if (status !== 201 && status !== 200) { + if (![201, 200, 204].includes(status)) { throw new Error("Failed to delete directory: " + status) } } @@ -96,7 +96,7 @@ function handleWriteResponse(response: Response) { if (status === 403) { throw new Error("Unauthorized to write file or create directory") } - if (status !== 201 && status !== 200) { + if (![201, 200, 204].includes(status)) { throw new Error("Failed to write file or create directory: " + status) } } From d40c66d8e2886c3365072e61246140b4002a843d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 23 Jun 2026 01:04:41 +0800 Subject: [PATCH 50/95] refactor: popup layout --- package.json | 8 +- rspack/rspack.e2e.ts | 2 +- .../database/common/storage-promise.ts | 15 +-- src/pages/popup/Main.tsx | 9 +- src/pages/popup/components/Footer/index.tsx | 21 ---- .../popup/components/Header/DarkSwitch.tsx | 2 +- src/pages/popup/components/Header/Option.tsx | 95 ------------------- src/pages/popup/components/Header/index.tsx | 4 +- .../popup/components/Limit/Content/index.tsx | 9 +- src/pages/popup/components/Limit/Summary.tsx | 9 +- .../LimitToolbar.tsx => Limit/Toolbar.tsx} | 9 +- src/pages/popup/components/Limit/context.ts | 26 +++++ src/pages/popup/components/Limit/index.tsx | 36 +++++-- .../popup/components/{Footer => }/Menu.tsx | 3 +- src/pages/popup/components/Option.tsx | 59 ++++++++++++ .../components/Percentage/Cate/Wrapper.ts | 6 +- .../popup/components/Percentage/Option.tsx | 53 +++++++++++ .../popup/components/Percentage/chart.ts | 2 +- .../popup/components/Percentage/index.tsx | 30 ++++-- .../popup/components/Percentage/query.ts | 45 ++++----- src/pages/popup/components/Ranking/Item.tsx | 9 +- src/pages/popup/components/Ranking/index.tsx | 30 ++++-- src/pages/popup/components/Ranking/query.tsx | 12 +-- .../{Footer => stat}/DurationSelect.tsx | 12 +-- .../DataToolbar.tsx => stat/StatToolbar.tsx} | 8 +- .../popup/{ => components/stat}/common.tsx | 6 +- src/pages/popup/components/stat/context.ts | 66 +++++++++++++ src/pages/popup/context.ts | 80 ++-------------- src/pages/popup/router.ts | 3 - src/pages/popup/slot.ts | 2 + src/pages/popup/types.d.ts | 18 ---- 31 files changed, 359 insertions(+), 330 deletions(-) delete mode 100644 src/pages/popup/components/Footer/index.tsx delete mode 100644 src/pages/popup/components/Header/Option.tsx rename src/pages/popup/components/{Footer/LimitToolbar.tsx => Limit/Toolbar.tsx} (92%) create mode 100644 src/pages/popup/components/Limit/context.ts rename src/pages/popup/components/{Footer => }/Menu.tsx (93%) create mode 100644 src/pages/popup/components/Option.tsx create mode 100644 src/pages/popup/components/Percentage/Option.tsx rename src/pages/popup/components/{Footer => stat}/DurationSelect.tsx (82%) rename src/pages/popup/components/{Footer/DataToolbar.tsx => stat/StatToolbar.tsx} (92%) rename src/pages/popup/{ => components/stat}/common.tsx (91%) create mode 100644 src/pages/popup/components/stat/context.ts create mode 100644 src/pages/popup/slot.ts diff --git a/package.json b/package.json index 64c2e8d74..fb9496a1e 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,11 @@ "@rspack/core": "^2.0.8", "@rstest/core": "^0.10.6", "@rstest/coverage-istanbul": "^0.10.6", - "@types/chrome": "0.1.43", + "@types/chrome": "0.2.0", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.9.3", - "@vue/babel-plugin-jsx": "^2.0.1", + "@types/node": "^26.0.0", + "@vue/babel-plugin-jsx": "^3.0.0", "babel-loader": "^10.1.1", "commitlint": "^21.0.2", "css-loader": "^7.1.4", @@ -50,7 +50,7 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.17.1", + "knip": "^6.17.2", "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", diff --git a/rspack/rspack.e2e.ts b/rspack/rspack.e2e.ts index 632cf4795..2b83682b9 100644 --- a/rspack/rspack.e2e.ts +++ b/rspack/rspack.e2e.ts @@ -6,7 +6,7 @@ import generateOption from "./rspack.common" manifest.name = E2E_NAME // Grant all permissions as required for e2e testing const permissions = manifest.permissions ??= [] -permissions.push(...(manifest.optional_permissions ?? [])) +permissions.push(...manifest.optional_permissions ?? []) manifest.optional_permissions = [] const options = generateOption({ diff --git a/src/background/database/common/storage-promise.ts b/src/background/database/common/storage-promise.ts index 14c47dc97..1df35b03f 100644 --- a/src/background/database/common/storage-promise.ts +++ b/src/background/database/common/storage-promise.ts @@ -5,28 +5,23 @@ * https://opensource.org/licenses/MIT */ -/** - * Copy from chrome.storage - */ -type NoInferX = T[][T extends any ? 0 : never] +type StorageArea = chrome.storage.StorageArea /** * Wrap the storage with promise */ export default class StoragePromise { - private storage: chrome.storage.StorageArea | undefined + private storage: StorageArea | undefined - constructor(storage?: chrome.storage.StorageArea) { + constructor(storage?: StorageArea) { this.storage = storage } - private getStorage(): chrome.storage.StorageArea { + private getStorage(): StorageArea { return this.storage ?? chrome.storage.local } - get( - keys?: NoInferX | Array> | Partial> | null, - ): Promise { + get(keys?: Parameters[0]): Promise { return new Promise(resolve => this.getStorage().get(keys ?? null, resolve)) } diff --git a/src/pages/popup/Main.tsx b/src/pages/popup/Main.tsx index 311b63bff..0d2588aa7 100644 --- a/src/pages/popup/Main.tsx +++ b/src/pages/popup/Main.tsx @@ -1,12 +1,12 @@ import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import { RouterView } from "vue-router" -import Footer from "./components/Footer" import Header from "./components/Header" +import Menu from './components/Menu' import { initPopupContext } from "./context" +import { TOOLBAR_SLOT } from './slot' const Main = defineComponent(() => { - const appKey = initPopupContext() return () => ( @@ -15,7 +15,10 @@ const Main = defineComponent(() => { -