From ef989a36242b97b3bec58856b16ea6cb3b9803ad Mon Sep 17 00:00:00 2001 From: Frederik Shull Date: Thu, 26 Feb 2026 03:48:13 +0100 Subject: [PATCH 1/6] i18n: fix typo (#690) fixed a typo I found tab group --- src/i18n/message/app/option-resource.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a16f19e9..e79fe62a 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -299,7 +299,7 @@ "localFileTime": "local files", "localFilesInfo": "Supports files of types such as PDF, image, txt and json.", "countTabGroup": "{input} Whether to track the time of tab groups {info}", - "tabGroupInfo": "When you delete a tag group, the data will also be deleted.", + "tabGroupInfo": "When you delete a tab group, the data will also be deleted.", "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", "weekStart": "The first day for each week {input}", @@ -1595,4 +1595,4 @@ "reloadButton": "Odśwież", "defaultValue": "Domyślnie: {default}" } -} \ No newline at end of file +} From ac9f1b6f9e2ea3f9e1827c385fbb9be3a0715f19 Mon Sep 17 00:00:00 2001 From: sheepie Date: Fri, 27 Feb 2026 00:25:02 +0800 Subject: [PATCH 2/6] fix: upgrade idb (#691) --- package.json | 2 +- src/background/index.ts | 2 +- src/background/migrator/indexed-migrator.ts | 20 +- src/database/common/indexed-storage.ts | 195 +++++++++++++++----- src/database/stat-database/idb.ts | 34 +--- test/database/idb.ts | 17 -- test/database/stat-database/idb.test.ts | 12 +- 7 files changed, 187 insertions(+), 95 deletions(-) delete mode 100644 test/database/idb.ts diff --git a/package.json b/package.json index e470059d..d587a9b2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", diff --git a/src/background/index.ts b/src/background/index.ts index 1c6fbfa0..4dbad3cb 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -15,7 +15,7 @@ import badgeTextManager from "./badge-manager" import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from "./install-handler" +import handleInstall from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" import VersionMigrator from "./migrator" diff --git a/src/background/migrator/indexed-migrator.ts b/src/background/migrator/indexed-migrator.ts index 5c2c7119..63ee0169 100644 --- a/src/background/migrator/indexed-migrator.ts +++ b/src/background/migrator/indexed-migrator.ts @@ -1,10 +1,28 @@ +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 { } - onUpdate(_version: string): 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)) diff --git a/src/database/common/indexed-storage.ts b/src/database/common/indexed-storage.ts index 893d19be..b228bb5f 100644 --- a/src/database/common/indexed-storage.ts +++ b/src/database/common/indexed-storage.ts @@ -63,6 +63,21 @@ export async function iterateCursor( }) } +type TransactionError = 'Connection' | 'StoreNotFound' | 'Unknown' + +const detectTransactionError = (err: unknown): TransactionError => { + if (!(err instanceof DOMException)) { + return 'Unknown' + } + if (err.name === 'InvalidStateError' || err.name === 'AbortError') { + return 'Connection' + } + if (err.name === 'NotFoundError') { + return 'StoreNotFound' + } + return 'Unknown' +} + export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { if (lower !== undefined && upper !== undefined) { if (lower > upper) { @@ -87,6 +102,8 @@ export abstract class BaseIDBStorage> { private DB_NAME = `tt4b_${chrome.runtime.id}` as const private db: IDBDatabase | undefined + private static initPromises = new Map>() + abstract indexes: Index[] abstract key: Key | Key[] abstract table: Table @@ -94,46 +111,122 @@ export abstract class BaseIDBStorage> { protected async initDb(): Promise { if (this.db) return this.db + let initPromise = BaseIDBStorage.initPromises.get(this.table) + if (!initPromise) { + initPromise = this.doInitDb() + BaseIDBStorage.initPromises.set(this.table, initPromise) + } + + try { + this.db = await initPromise + this.setupDbCloseHandler(this.db) + return this.db + } catch (error) { + BaseIDBStorage.initPromises.delete(this.table) + throw error + } + } + + private setupDbCloseHandler(db: IDBDatabase): void { + db.onclose = () => { + if (this.db !== db) return + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + } + } + + private async doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB - const checkRequest = factory.open(this.DB_NAME) - return new Promise((resolve, reject) => { - checkRequest.onsuccess = () => { - const db = checkRequest.result - const storeExisted = db.objectStoreNames.contains(this.table) - const needUpgrade = !storeExisted || this.needUpgradeIndexes(db) + const checkDb = await 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")) + }) - if (!needUpgrade) { - this.db = db - return resolve(db) - } + return checkDb + } + + // Only used for testing, be careful when using in production + public async clear(): Promise { + await this.withStore(store => store.clear(), 'readwrite') + } + + async upgrade(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const checkDb = await 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")) + checkRequest.onblocked = () => { + 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) + + if (!needUpgrade) { + checkDb.close() + return + } - const currentVersion = db.version - db.close() + const currentVersion = checkDb.version + checkDb.close() - const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + return new Promise((resolve, reject) => { + const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) - upgradeRequest.onupgradeneeded = () => { + upgradeRequest.onupgradeneeded = () => { + try { const upgradeDb = upgradeRequest.result const transaction = upgradeRequest.transaction - if (!transaction) return reject("Failed to get transaction of upgrading request") + if (!transaction) { + reject(new Error("Failed to get transaction of upgrading request")) + return + } + + transaction.onerror = () => { + reject(transaction.error || new Error("Transaction failed")) + } + + transaction.onabort = () => { + reject(new Error("Upgrade transaction was aborted")) + } let store = upgradeDb.objectStoreNames.contains(this.table) ? transaction.objectStore(this.table) - : upgradeDb.createObjectStore(this.table, { keyPath: this.key }) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) this.createIndexes(store) + } catch (error) { + console.error("Failed to upgrade database in onupgradeneeded", error) + upgradeRequest.transaction?.abort() + reject(error instanceof Error ? error : new Error(String(error))) } + } - upgradeRequest.onsuccess = () => { - console.log("IndexedDB upgraded") - this.db = upgradeRequest.result - resolve(upgradeRequest.result) - } + upgradeRequest.onsuccess = () => { + console.log(`IndexedDB upgraded for table "${this.table}"`) + upgradeRequest.result.close() + resolve() + } - upgradeRequest.onerror = () => reject(upgradeRequest.error) + upgradeRequest.onerror = (event) => { + console.error("Failed to upgrade database", event, upgradeRequest.error) + reject(upgradeRequest.error || new Error("Failed to upgrade database")) } - checkRequest.onerror = () => reject(checkRequest.error) + upgradeRequest.onblocked = () => { + 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}), ` + + `waiting for other connections to close. ` + + `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}` + ) + } }) } @@ -170,27 +263,45 @@ export abstract class BaseIDBStorage> { } protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { - const db = await this.initDb() - const trans = db.transaction(this.table, mode ?? 'readwrite') - try { - const store = trans.objectStore(this.table) - const result = await operation(store) - // Waiting for transaction completed - await new Promise((resolve, reject) => { - trans.oncomplete = () => resolve() - trans.onerror = () => reject(trans.error) - trans.onabort = () => reject(new Error('Transaction aborted')) - }) - return result - } catch (e) { - console.error("Failed to process with transaction", e) - if (!trans.error && trans.mode !== 'readonly') { - try { - trans.abort() - } catch (ignored) { } + let db = await this.initDb() + + for (let retryCount = 0; retryCount < 2; retryCount++) { + let trans: IDBTransaction | undefined + try { + trans = db.transaction(this.table, mode ?? 'readwrite') + const store = trans.objectStore(this.table) + const result = await operation(store) + const transaction = trans + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + const errorType = detectTransactionError(e) + + if (errorType === 'Unknown') { + console.error("Failed to process with transaction", e) + if (trans && !trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + + if (errorType === 'StoreNotFound') { + this.db?.close() + await this.upgrade() + } + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + db = await this.initDb() } - throw e } + throw new Error("Max retries exceeded") } protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { diff --git a/src/database/stat-database/idb.ts b/src/database/stat-database/idb.ts index b24b99fe..f9e7fba5 100644 --- a/src/database/stat-database/idb.ts +++ b/src/database/stat-database/idb.ts @@ -1,5 +1,5 @@ import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' -import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroRow } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' @@ -8,15 +8,7 @@ type StoredRow = timer.core.Row & { groupId?: number } -function fromStoredRow(stored: StoredRow): timer.core.Row { - if (stored.groupId !== undefined) { - const { groupId, ...row } = stored - return { ...row, host: cvtGroupId2Host(groupId) } - } - return stored -} - -const GROUP_HOST_PATTERN = /^_g_(\d+)$/ +const GROUP_HOST_PATTERN = new RegExp(`^${GROUP_PREFIX}(\\d+)$`) const INDEXES: (Key | Key[])[] = [ 'date', 'host', 'groupId', @@ -144,12 +136,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa const rows = await iterateCursor(cursorReq) for (const row of rows) { - if (expectGroup) { - if (!isGroup(row)) continue - } else { - if (isGroup(row)) continue - } - + if (expectGroup !== isGroup(row)) continue if (!filter(row)) continue if (expectGroup) { @@ -196,9 +183,8 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa await iterateCursor(cursorReq, cursor => { const stored = cursor.value as StoredRow | undefined - if (stored && !isGroup(stored)) { - toUpdate[stored.host] = fromStoredRow(stored) - } + if (!stored || isGroup(stored)) return + toUpdate[stored.host] = stored }) for (const [host, result] of Object.entries(data)) { @@ -332,15 +318,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa } forceUpdate(...rows: timer.core.Row[]): Promise { - return this.withStore(store => { - for (const row of rows) { - const { host, date, time, focus, run } = row - const groupMatch = host.match(GROUP_HOST_PATTERN) - const newData: StoredRow = { host, date, time, focus, run } - groupMatch && (newData.groupId = parseInt(groupMatch[1])) - store.put(newData) - } - }, 'readwrite') + return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') } forceUpdateGroup(...rows: timer.core.Row[]): Promise { diff --git a/test/database/idb.ts b/test/database/idb.ts deleted file mode 100644 index 1295425f..00000000 --- a/test/database/idb.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { } from 'fake-indexeddb' -import 'fake-indexeddb/auto' - -/** - * Clean up all IndexedDB databases - */ -export async function cleanupIDB() { - return new Promise((resolve) => { - try { - global.indexedDB = new IDBFactory() - setTimeout(resolve, 0) - } catch (error) { - console.error('Failed to cleanup database:', error) - resolve(undefined) - } - }) -} \ No newline at end of file diff --git a/test/database/stat-database/idb.test.ts b/test/database/stat-database/idb.test.ts index 4f0ed31f..b5afe6a3 100644 --- a/test/database/stat-database/idb.test.ts +++ b/test/database/stat-database/idb.test.ts @@ -2,9 +2,7 @@ import { zeroResult, zeroRow } from '@db/stat-database/common' import { IDBStatDatabase } from '@db/stat-database/idb' import 'fake-indexeddb/auto' import { mockRuntime } from '../../__mock__/runtime' -import { cleanupIDB } from '../idb' -let db: IDBStatDatabase const GOOGLE = 'www.google.com' const GITHUB = 'www.github.com' const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**' @@ -12,13 +10,17 @@ const GROUP_1 = 1 const GROUP_2 = 2 const MAYBE_GROUP_1 = '1' +let db: IDBStatDatabase + describe('stat-database/idb', () => { - beforeAll(() => mockRuntime()) - beforeEach(async () => { - await cleanupIDB() + beforeAll(async () => { + mockRuntime() db = new IDBStatDatabase() + await db.upgrade() }) + beforeEach(() => db.clear()) + test('accumulate', async () => { await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 }) await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 }) From a72908ebbb81510d19e683e99dc79142e4ff6704 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 27 Feb 2026 00:42:12 +0800 Subject: [PATCH 3/6] v4.0.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 914d110c..ba8942f3 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.0.1] - 2026-02-27 + +- Fixed an IndexedDB upgrade bug on Edge + ## [4.0.0] - 2026-02-26 - Supported IndexedDB to store the tracking data diff --git a/package.json b/package.json index d587a9b2..cd87a5fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.0.0", + "version": "4.0.1", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 834a7d833cea846f1d315f9b8f6123f177090e41 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:27 +0800 Subject: [PATCH 4/6] i18n(download): download translations by bot (#692) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/data-manage-resource.json | 1 + src/i18n/message/app/option-resource.json | 6 ++++-- src/i18n/message/popup/header-resource.json | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 7b3df4c3..697882a9 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -324,6 +324,7 @@ "totalMemoryAlert": "Tarayıcı, her uzantı için yerel verileri depolamak üzere {size} MB alan sağlar", "totalMemoryAlert1": "Tarayıcı tarafından izin verilen maksimum depolama alanı belirlenemiyor", "usedMemoryAlert": "Şu anda {size} MB kullanılıyor", + "idbAlert": "Depolama kullanımını azaltmak için verilerinizi IndexedDB'ye taşı", "operationAlert": "Depolama alanını azaltmak için önemsiz verileri silebilirsiniz", "filterItems": "Verileri filtrele", "filterFocus": "Günün gezinme süresi {start} saniye ile {end} saniye arasındadır", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index e79fe62a..07a31146 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1405,7 +1405,9 @@ "tabGroupsPermGrant": "Bu özellik ilgili izinleri gerektirir", "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", "weekStart": "Her haftanın ilk günü {input}", - "weekStartAsNormal": "Normal" + "weekStartAsNormal": "Normal", + "storage": "İzleme verilerini {input} içinde saklayın.", + "storageConfirm": "Depolama türünü {type} olarak değiştirmek ister misiniz?" }, "limit": { "prompt": "Kısıtlı olduğunda gösterilecek uyarı {input}", @@ -1595,4 +1597,4 @@ "reloadButton": "Odśwież", "defaultValue": "Domyślnie: {default}" } -} +} \ No newline at end of file diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 6efdaf2a..c6a2299c 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -43,6 +43,7 @@ }, "ar": {}, "tr": { + "rating": "Değerlendirmeyi Gönder", "showSiteName": "Site adını göster", "showTopN": "En iyi {n} göster", "donutChart": "Halka grafik olarak göster" From 6dce672268a5a72e773a62001f5fb5e43441beb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:01:21 +0800 Subject: [PATCH 5/6] chore(psl): update PSL list by bot (#694) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 92 +++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index 63a73990..751dbeee 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -2134,7 +2134,18 @@ "es-1": 1 } }, - "convex": 1, + "begetcdn": { + "c": { + "*": 1 + } + }, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "diadem": 1, "elementor": 1, "emergent": 1, @@ -2620,6 +2631,7 @@ } }, "180r": 1, + "1cooldns": 1, "1kapp": 1, "3utilities": 1, "4u": 1, @@ -4145,6 +4157,7 @@ "bplaced": 1, "br": 1, "builtwithdark": 1, + "bumbleshrimp": 1, "cafjs": 1, "canva-apps": 1, "canva-hosted-embed": 1, @@ -4219,6 +4232,7 @@ "dattoweb": 1, "ddnsfree": 1, "ddnsgeek": 1, + "ddnsguru": 1, "ddnsking": 1, "de": 1, "deus-canvas": 1, @@ -4247,6 +4261,7 @@ "dopaas": 1, "drayddns": 1, "dreamhosters": 1, + "drive-platform": 1, "dsmynas": 1, "durumis": 1, "dyn-o-saur": 1, @@ -4266,6 +4281,8 @@ "dyndns-wiki": 1, "dyndns-work": 1, "dynns": 1, + "dynuddns": 1, + "dynuhosting": 1, "elasticbeanstalk": { "c": { "af-south-1": 1, @@ -4499,15 +4516,6 @@ "demo": 1 } }, - "joyent": { - "c": { - "cns": { - "c": { - "*": 1 - } - } - } - }, "jpn": 1, "kasserver": 1, "kozow": 1, @@ -4648,6 +4656,7 @@ } }, "pgfog": 1, + "pivohosting": 1, "pixolino": 1, "playstation-cloud": 1, "pleskns": 1, @@ -4822,6 +4831,7 @@ "pages": 1 } }, + "wiredbladehosting": 1, "withgoogle": 1, "withyoutube": 1, "wixsite": 1, @@ -4861,7 +4871,12 @@ }, "l": 1 }, - "company": 1, + "company": { + "c": { + "mybox": 1 + }, + "l": 1 + }, "compare": 1, "computer": 1, "comsec": 1, @@ -5168,7 +5183,6 @@ }, "dev": { "c": { - "12chars": 1, "barsy": 1, "bearblog": 1, "botdash": 1, @@ -5443,7 +5457,8 @@ "dish": 1, "diy": { "c": { - "discourse": 1 + "discourse": 1, + "imagine": 1 }, "l": 1 }, @@ -5634,6 +5649,7 @@ "on": 1 } }, + "intouch": 1, "tawk": { "c": { "p": 1 @@ -5899,6 +5915,7 @@ "gouv": 1, "greta": 1, "huissier-justice": 1, + "kdns": 1, "medecin": 1, "myspreadshop": 1, "nom": 1, @@ -6072,7 +6089,6 @@ "gold": 1, "goldpoint": 1, "golf": 1, - "goo": 1, "goodyear": 1, "goog": { "c": { @@ -6592,6 +6608,7 @@ "darklang": 1, "dedyn": 1, "definima": 1, + "drive-platform": 1, "editorx": 1, "edu": 1, "edugit": 1, @@ -6802,7 +6819,6 @@ "it": { "c": { "123homepage": 1, - "12chars": 1, "16-b": 1, "32-b": 1, "64-b": 1, @@ -9507,8 +9523,13 @@ }, "kh": { "c": { - "*": 1 - } + "com": 1, + "edu": 1, + "gov": 1, + "net": 1, + "org": 1 + }, + "l": 1 }, "ki": { "c": { @@ -9947,6 +9968,7 @@ "loginto": 1, "lohmus": 1, "mcdir": 1, + "mybox": 1, "myds": 1, "net": 1, "nohost": 1, @@ -10369,7 +10391,12 @@ "de5": 1, "debian": 1, "definima": 1, - "deno": 1, + "deno": { + "c": { + "sandbox": 1 + }, + "l": 1 + }, "dns-cloud": 1, "dns-dynamic": 1, "dnsalias": 1, @@ -10381,6 +10408,7 @@ "dynalias": 1, "dynathome": 1, "dynu": 1, + "dynuddns": 1, "dynv6": 1, "eating-organic": 1, "edgekey": 1, @@ -10500,11 +10528,13 @@ "myradweb": 1, "mysecuritycamera": 1, "myspreadshop": 1, + "mysynology": 1, "nhlfan": 1, "no-ip": 1, "now-dns": 1, "office-on-the": 1, "oninferno": 1, + "opik": 1, "ovh": { "c": { "hosting": { @@ -10553,6 +10583,7 @@ "server-on": 1, "shopselect": 1, "siteleaf": 1, + "spryt": 1, "square7": 1, "squares": 1, "srcf": { @@ -10589,10 +10620,18 @@ "uni5": 1, "usgovcloudapi": { "c": { + "core": { + "c": { + "blob": 1, + "file": 1, + "web": 1 + } + }, "servicebus": 1 } }, "usgovcloudapp": 1, + "usgovtrafficmanager": 1, "vpndns": 1, "vps-host": { "c": { @@ -10612,7 +10651,9 @@ "c": { "core": { "c": { - "blob": 1 + "blob": 1, + "file": 1, + "web": 1 } }, "servicebus": 1 @@ -11939,6 +11980,7 @@ "read-books": 1, "readmyblog": 1, "routingthecloud": 1, + "roxa": 1, "selfip": 1, "sellsyourhome": 1, "servebbs": 1, @@ -12007,6 +12049,7 @@ "deuxfleurs": 1, "heyflow": 1, "hlx": 1, + "mybox": 1, "pdns": 1, "plesk": 1, "prvcy": 1, @@ -12442,7 +12485,6 @@ "prime": 1, "pro": { "c": { - "12chars": 1, "aaa": 1, "aca": 1, "acct": 1, @@ -13023,7 +13065,13 @@ } }, "co": 1, - "convex": 1, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "cpanel": 1, "cyon": 1, "fastvps": 1, @@ -14440,6 +14488,7 @@ "haugiang": 1, "health": 1, "hoabinh": 1, + "hue": 1, "hungyen": 1, "id": 1, "info": 1, @@ -14542,7 +14591,6 @@ "wine": 1, "winners": 1, "wme": 1, - "wolterskluwer": 1, "woodside": 1, "work": { "c": { From 4457cc6b7ebb21dfb557febcbe31f5b3826a3458 Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 1 Mar 2026 16:55:22 +0800 Subject: [PATCH 6/6] feat: support site blocking for mobile (#579) --- package.json | 6 +- .../limit/modal/components/Alert.tsx | 9 +- .../limit/modal/components/Reason.tsx | 19 +- src/pages/app/Layout/menu/item.ts | 3 +- src/pages/app/components/Dashboard/index.tsx | 2 +- .../Limit/LimitModify/Sop/Step3/TimeInput.tsx | 258 ------------------ .../Limit/LimitTable/RuleContent.tsx | 78 ------ .../app/components/Limit/LimitTable/Waste.tsx | 52 ---- .../Filter.tsx} | 21 +- .../components/Limit/components/List/Card.tsx | 111 ++++++++ .../components/Limit/components/List/Rule.tsx | 115 ++++++++ .../Limit/components/List/index.tsx | 28 ++ .../Modify}/Sop/Step1.tsx | 6 +- .../Modify}/Sop/Step2/SiteInput.tsx | 0 .../Modify}/Sop/Step2/index.tsx | 0 .../Modify}/Sop/Step3/PeriodInput.tsx | 46 ++-- .../components/Modify/Sop/Step3/TimeInput.tsx | 251 +++++++++++++++++ .../Modify}/Sop/Step3/index.tsx | 14 +- .../Modify}/Sop/context.ts | 2 +- .../Modify}/Sop/index.tsx | 37 ++- .../Modify}/index.tsx | 11 +- .../Table/OperationColumn.tsx} | 4 +- .../Limit/components/Table/Rule.tsx | 68 +++++ .../Limit/components/Table/Waste.tsx | 48 ++++ .../Table}/Weekday.tsx | 0 .../Table}/index.tsx | 25 +- .../{LimitTest.tsx => components/Test.tsx} | 8 +- .../app/components/Limit/components/index.tsx | 5 + .../app/components/Limit/components/style.ts | 5 + src/pages/app/components/Limit/context.ts | 33 ++- src/pages/app/components/Limit/index.tsx | 20 +- src/pages/app/components/Option/Select.tsx | 11 +- .../Option/components/LimitOption/index.tsx | 2 +- .../app/components/common/TooltipWrapper.tsx | 4 +- .../common/filter/ButtonFilterItem.tsx | 21 +- src/pages/components/Flex.tsx | 50 ++-- src/pages/popup/components/Header/index.tsx | 4 +- src/util/time.ts | 52 ++-- test-e2e/common/base.ts | 2 +- .../{whitelist.ts => whitelist.test.ts} | 16 +- test-e2e/tracker/base.test.ts | 2 +- test-e2e/tracker/run-time.test.ts | 2 +- 42 files changed, 897 insertions(+), 554 deletions(-) delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/RuleContent.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/Waste.tsx rename src/pages/app/components/Limit/{LimitFilter.tsx => components/Filter.tsx} (85%) create mode 100644 src/pages/app/components/Limit/components/List/Card.tsx create mode 100644 src/pages/app/components/Limit/components/List/Rule.tsx create mode 100644 src/pages/app/components/Limit/components/List/index.tsx rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step1.tsx (90%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step2/SiteInput.tsx (100%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step2/index.tsx (100%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step3/PeriodInput.tsx (82%) create mode 100644 src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step3/index.tsx (80%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/context.ts (98%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/index.tsx (54%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/index.tsx (91%) rename src/pages/app/components/Limit/{LimitTable/column/LimitOperationColumn.tsx => components/Table/OperationColumn.tsx} (93%) create mode 100644 src/pages/app/components/Limit/components/Table/Rule.tsx create mode 100644 src/pages/app/components/Limit/components/Table/Waste.tsx rename src/pages/app/components/Limit/{LimitTable => components/Table}/Weekday.tsx (100%) rename src/pages/app/components/Limit/{LimitTable => components/Table}/index.tsx (92%) rename src/pages/app/components/Limit/{LimitTest.tsx => components/Test.tsx} (87%) create mode 100644 src/pages/app/components/Limit/components/index.tsx create mode 100644 src/pages/app/components/Limit/components/style.ts rename test-e2e/common/{whitelist.ts => whitelist.test.ts} (70%) diff --git a/package.json b/package.json index cd87a5fa..4c7f19ce 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,12 @@ "@rsdoctor/rspack-plugin": "^1.5.2", "@rspack/cli": "^1.7.6", "@rspack/core": "^1.7.6", - "@swc/core": "^1.15.13", + "@swc/core": "^1.15.17", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.1", + "@types/node": "^25.3.2", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -64,7 +64,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.2", + "element-plus": "2.13.3", "punycode": "^2.3.1", "typescript-guard": "^0.2.1", "vue": "^3.5.29", diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index b8d96977..af0ce41d 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,5 +1,6 @@ import { getUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" +import { useXsState } from '@hooks/useMediaSize' import { useRequest } from "@hooks/useRequest" import Box from '@pages/components/Box' import Flex from '@pages/components/Flex' @@ -21,13 +22,19 @@ const _default = defineComponent(() => { return option?.limitPrompt || defaultPrompt }, { defaultValue: defaultPrompt }) + const isXs = useXsState() + return () => ( {t(msg => msg.meta.name)?.toUpperCase()} - + {prompt.value} diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 78bbe160..1dc2204e 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -1,4 +1,5 @@ import { t } from "@cs/locale" +import { useXsState } from '@hooks/index' import { useRequest } from "@hooks/useRequest" import Flex from "@pages/components/Flex" import { matchCond, meetLimit, meetTimeLimit, period2Str } from "@util/limit" @@ -7,8 +8,13 @@ import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' import { computed, defineComponent, type StyleValue } from "vue" import { useGlobalParam, useReason, useRule } from "../context" -const DESCRIPTIONS_STYLE: StyleValue = { - width: '400px', +const useDescriptions = () => { + const isXs = useXsState() + const style = computed(() => ({ + width: isXs.value ? '90vw' : '400px', + } satisfies StyleValue)) + const size = computed(() => isXs.value ? 'small' : undefined) + return { style, size } } const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> @@ -35,12 +41,13 @@ const TimeDescriptions = defineComponent({ const rule = useRule() const reason = useReason() const { url } = useGlobalParam() + const { style, size } = useDescriptions() const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) return () => ( - + {renderBaseItems(rule.value, url)} @@ -90,6 +97,8 @@ const _default = defineComponent(() => { setInterval(refreshBrowsingTime, 1000) + const { style, size } = useDescriptions() + return () => ( { ruleLabel={t(msg => msg.limit.item.weekly)} dataLabel={t(msg => msg.calendar.range.thisWeek)} /> - + {renderBaseItems(rule.value, url)} msg.limit.item.visitTime)} labelAlign="right"> {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} @@ -124,7 +133,7 @@ const _default = defineComponent(() => { {reason.value?.delayCount ?? 0} - + {renderBaseItems(rule.value, url)} msg.limit.item.period)} labelAlign="right"> {rule.value?.periods?.length diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index c8f1c4b2..90c9f206 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -76,7 +76,8 @@ export const menuGroups = (): MenuGroup[] => [{ }, { title: msg => msg.menu.limit, route: '/behavior/limit', - icon: Timer + icon: Timer, + mobile: true, }] }, { title: msg => msg.menu.additional, diff --git a/src/pages/app/components/Dashboard/index.tsx b/src/pages/app/components/Dashboard/index.tsx index 55673ab9..c9d9d62b 100644 --- a/src/pages/app/components/Dashboard/index.tsx +++ b/src/pages/app/components/Dashboard/index.tsx @@ -12,7 +12,7 @@ import Flex from "@pages/components/Flex" import { recommendRate, saveFlag } from "@service/meta-service" import { REVIEW_PAGE } from "@util/constant/url" import { ElRow, ElScrollbar } from "element-plus" -import { computed, defineComponent, FunctionalComponent } from "vue" +import { computed, defineComponent, type FunctionalComponent } from "vue" import { useRouter } from "vue-router" import ContentContainer from "../common/ContentContainer" import Calendar from "./components/Calendar" diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx deleted file mode 100644 index fde155be..00000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { CircleClose, Clock } from "@element-plus/icons-vue" -import { useDebounceFn, useState } from "@hooks" -import { getStyle } from "@pages/util/style" -import { range } from "@util/array" -import { Effect, ElIcon, ElInput, ElPopover, ElScrollbar, ScrollbarInstance, useLocale, useNamespace } from "element-plus" -import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" - -function computeSecond2LimitInfo(time: number): [number, number, number] { - time = time || 0 - const second = time % 60 - const totalMinutes = (time - second) / 60 - const minute = totalMinutes % 60 - const hour = (totalMinutes - minute) / 60 - return [hour, minute, second] -} - -const formatTimeVal = (val: number): string => { - return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' -} - -const TimeSpinner = defineComponent({ - props: { - max: { - type: Number, - required: true, - }, - visible: Boolean, - modelValue: { - type: Number, - required: true, - }, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const ns = useNamespace('time') - const scrollbar = ref() - const scrolling = ref(false) - - const debounceChangeValue = useDebounceFn((val: number) => { - scrolling.value = false - ctx.emit('change', val) - }, 200) - - const getScrollbarElement = () => { - const el = scrollbar.value?.$el - return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement - } - - const adjustSpinner = (value: number) => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) - } - - watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) - watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) - - const typeItemHeight = (): number => { - const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement - if (listItem) { - return Number.parseFloat(getStyle(listItem, 'height')) || 0 - } - return 0 - } - - const bindScroll = () => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.addEventListener('scroll', () => { - scrolling.value = true - const scrollTop = getScrollbarElement()?.scrollTop ?? 0 - const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 - const itemH = typeItemHeight() - const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) - const value = Math.min(estimatedIdx, props.max - 1) - debounceChangeValue(value) - }, { passive: true }) - } - - onMounted(() => { - bindScroll() - adjustSpinner(props.modelValue) - }) - - return () => ( - - {range(props.max).map(idx => ( -
  • ctx.emit('change', idx)} - class={[ - ns.be('spinner', 'item'), - ns.is('active', idx === props.modelValue), - ]} - > - {idx.toString().padStart(2, '0')} -
  • - ))} -
    - ) - }, -}) - -const useTimeInput = (source: () => number) => { - 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) - setHour(hour) - setMinute(min) - setSecond(sec) - } - - watch(source, reset) - - const getTotalSecond = () => { - let time = 0 - time += (hour.value ?? 0) * 3600 - time += (minute.value ?? 0) * 60 - time += (second.value ?? 0) - return time - } - - return { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } -} - -/** - * Rewrite - * - * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue - */ -const TimeInput = defineComponent({ - props: { - modelValue: { - type: Number, - required: true, - }, - hourMax: Number, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const [popoverVisible, setPopoverVisible] = useState(false) - const { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } = useTimeInput(() => props.modelValue) - - const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) - - const ns = useNamespace('time') - const nsDate = useNamespace('date') - const nsInput = useNamespace('input') - - const { t: tEle } = useLocale() - - const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) - - const handleCancel = () => { - reset() - setPopoverVisible(false) - } - - const handleConfirm = () => { - ctx.emit('change', getTotalSecond()) - setPopoverVisible(false) - } - - const handleVisibleChange = (newVal: boolean) => { - setPopoverVisible(newVal) - !newVal && handleCancel() - } - - const handleClear = (ev: MouseEvent) => { - ctx.emit('change', 0) - ev.stopPropagation() - } - - return () => ( - ( - !!props.modelValue && ( -
    - - - -
    - ) - }} - /> - ) - }}> - -
    -
    -
    - - - -
    -
    -
    - - -
    -
    -
    -
    - ) - }, -}) - -export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx b/src/pages/app/components/Limit/LimitTable/RuleContent.tsx deleted file mode 100644 index 0a969f2c..00000000 --- a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { period2Str } from "@util/limit" -import { formatPeriod, MILL_PER_SECOND } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent, toRef, type PropType } from "vue" - -const TIME_FORMAT = { - dayMsg: '{day}d{hour}h{minute}m{second}s', - hourMsg: '{hour}h{minute}m{second}s', - minuteMsg: '{minute}m{second}s', - secondMsg: '{second}s', -} - -const TimeCountTag = defineComponent({ - props: { - time: Number, - count: Number, - label: String, - }, - setup(props) { - const visible = computed(() => !!props.time || !!props.count) - const content = computed(() => { - const timeContent = props.time ? formatPeriod(props.time * MILL_PER_SECOND, TIME_FORMAT) : '' - const countContent = props.count ? `${props.count} ${t(msg => msg.limit.item.visits)}` : '' - return [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) - }) - - return () => ( -
    - - {props.label}: {content.value} - -
    - ) - }, -}) - -const RuleContent = defineComponent({ - props: { - value: Object as PropType - }, - setup(props) { - const row = toRef(props, 'value') - - return () => ( - - msg.limit.item.daily)} - /> - msg.limit.item.weekly)} - /> - {!!row.value?.visitTime && ( -
    - - {t(msg => msg.limit.item.visitTime)}: {formatPeriod(row.value?.visitTime * MILL_PER_SECOND, TIME_FORMAT)} - -
    - )} - {!!row.value?.periods?.length && <> -
    - {t(msg => msg.limit.item.period)} -
    - - {row.value?.periods?.map(p => {period2Str(p)})} - - } -
    - ) - }, -}) - -export default RuleContent \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Waste.tsx b/src/pages/app/components/Limit/LimitTable/Waste.tsx deleted file mode 100644 index 0b1baa8f..00000000 --- a/src/pages/app/components/Limit/LimitTable/Waste.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { meetLimit, meetTimeLimit } from "@util/limit" -import { formatPeriodCommon } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent } from "vue" - -const Waste = defineComponent({ - props: { - time: Number, - waste: { - type: Number, - required: true, - }, - count: Number, - visit: Number, - delayCount: Number, - allowDelay: Boolean, - }, - setup(props) { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') - const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') - - return () => ( - -
    - `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, - default: () => ( - - {formatPeriodCommon(props.waste)} - - ), - }} - /> -
    -
    - - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} - -
    -
    - ) - }, -}) - -export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitFilter.tsx b/src/pages/app/components/Limit/components/Filter.tsx similarity index 85% rename from src/pages/app/components/Limit/LimitFilter.tsx rename to src/pages/app/components/Limit/components/Filter.tsx index f7089654..0c230209 100644 --- a/src/pages/app/components/Limit/LimitFilter.tsx +++ b/src/pages/app/components/Limit/components/Filter.tsx @@ -12,18 +12,19 @@ import SwitchFilterItem from "@app/components/common/filter/SwitchFilterItem" 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" +import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { getAppPageUrl } from "@util/constant/url" import { ElIcon, ElText, ElTooltip } from 'element-plus' -import { defineComponent, ref, Ref, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import { useLimitAction, useLimitBatch, useLimitFilter } from "./context" +import { computed, defineComponent, ref, Ref, watch } from "vue" +import DropdownButton, { type DropdownButtonItem } from "../../common/DropdownButton" +import { useLimitAction, useLimitBatch, useLimitFilter } from "../context" const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'limit' }) type BatchOpt = 'delete' | 'enable' | 'disable' -const useCreateTip = (empty: Ref) => { +const useCreateTip = (empty: Ref, isXs: Ref) => { const tipVisible = ref(false) let initialized = false watch(empty, emptyVal => { @@ -33,12 +34,14 @@ const useCreateTip = (empty: Ref) => { setTimeout(closeTip, 10000) }) const closeTip = () => tipVisible.value = false - return { tipVisible, closeTip } + const finalVisible = computed(() => !isXs.value && tipVisible.value) + return { tipVisible: finalVisible, closeTip } } const _default = defineComponent(() => { const { create, test, empty } = useLimitAction() - const { tipVisible, closeTip } = useCreateTip(empty) + const isXs = useXsState() + const { tipVisible, closeTip } = useCreateTip(empty, isXs) const filter = useLimitFilter() const { batchDelete, batchDisable, batchEnable } = useLimitBatch() @@ -75,20 +78,22 @@ const _default = defineComponent(() => { onSearch={val => filter.url = val} /> msg.limit.filterDisabled)} defaultValue={filter.onlyEnabled} onChange={val => filter.onlyEnabled = val} />
    - - + + msg.limit.button.test} icon={Operation} onClick={test} /> msg.base.option} icon={SetUp} onClick={() => createTabAfterCurrent(optionPageUrl)} diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx new file mode 100644 index 00000000..d09ec6e2 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { t } from "@app/locale" +import { Delete, EditPen } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import Flex from "@pages/components/Flex" +import { ElButton, ElCard, ElDivider, ElMessageBox, ElTag, type TagProps, useNamespace } from "element-plus" +import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" +import { verifyCanModify } from "../../common" +import { useLimitAction, useLimitData } from "../../context" +import Rule from "./Rule" + +type Props = { + value: timer.limit.Item +} + +const CARD_PADDING = 10 + +const useStyle = () => { + const cardNs = useNamespace('card') + const cardCls = css` + .${cardNs.e('body')} { + padding: ${CARD_PADDING}px; + } + ` + return cardCls +} + +const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays).split('|') + +const Divider: FunctionalComponent<{}> = () => { + const marginInline = `${-CARD_PADDING}px` + const width = `calc(100% + ${CARD_PADDING * 2}px)` + return +} + +const EffectiveDays: FunctionalComponent<{ weekdays?: number[] }> = ({ weekdays = [] }) => { + const weekdayNum = weekdays?.length + let text: string = '' + let type: TagProps['type'] | undefined = undefined + if (!weekdayNum || weekdayNum === 7) { + text = t(msg => msg.calendar.range.everyday) + type = 'success' + } else if (weekdayNum === 1) { + text = ALL_WEEKDAYS[weekdays[0]] + } else { + const firstDay = ALL_WEEKDAYS[weekdays[0]] + text = `${firstDay}...${weekdayNum}` + } + + return {text} +} + +const _default = defineComponent(props => { + const { deleteRow } = useLimitData() + const { modify } = useLimitAction() + + const handleModify = () => verifyCanModify(props.value) + .then(() => modify(props.value)) + .catch(() => {/** Do nothing */ }) + + const handleDelete = () => verifyCanModify(props.value) + .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: props.value.name }))) + .then(() => deleteRow(props.value)) + .catch(() => {/** Do nothing */ }) + + const clz = useStyle() + + return () => ( + + + + + + {props.value.name ?? 'Unnamed'} + + + + + + + {/* Sites */} + + {props.value.cond.map((c, idx) => {c})} + + + {/** Content */} + + + {/* Footer Button */} + + + {t(msg => msg.button.modify)} + + + + + ) +}, { props: ['value'] }) + +export default _default diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx new file mode 100644 index 00000000..b53664d7 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -0,0 +1,115 @@ +import { t } from '@app/locale' +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 { computed, defineComponent, type FunctionalComponent, toRefs } from 'vue' +import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type Props = { + value: timer.limit.Item +} + +const TimeCountPair: FunctionalComponent<{ time?: number, count?: number }> = ({ time, count }) => { + if (!time && !count) return null + return ( + + {!!time && ( + {formatPeriodCommon(time * MILL_PER_SECOND, true)} + )} + {!!count && ( + {`${count} ${t(msg => msg.limit.item.visits)}`} + )} + + ) +} + +type WastePairProps = { + time?: number + waste: number + count?: number + visit?: number + delayCount?: number + allowDelay?: boolean +} + +const WastePair: FunctionalComponent = props => { + const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') + const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + + return ( + + + {formatPeriodCommon(props.waste)} + + + {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + + + ) +} + +const Rule = defineComponent(({ value }) => { + const { + time, count, waste, visit, + weekly, weeklyCount, weeklyWaste, weeklyVisit, + visitTime, periods, + weekdays, + allowDelay, delayCount, weeklyDelayCount, + } = toRefs(value) + + return () => <> + + msg.limit.item.daily)} v-show={time?.value || count?.value}> + + + msg.limit.item.weekly)} v-show={weekly?.value || weeklyCount?.value}> + + + {!!visitTime?.value && ( + msg.limit.item.visitTime)}> + {formatPeriodCommon(visitTime.value * MILL_PER_SECOND, true)} + + )} + {!!periods?.value?.length && ( + msg.limit.item.period)}> + + {periods.value.map((p, idx) => ( + {period2Str(p)} + ))} + + + )} + + + msg.calendar.range.today)}> + {isEffective(weekdays?.value) ? ( + + ) : ( + + {t(msg => msg.limit.item.notEffective)} + + )} + + msg.calendar.range.thisWeek)}> + + + + +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/List/index.tsx b/src/pages/app/components/Limit/components/List/index.tsx new file mode 100644 index 00000000..5dd51920 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/index.tsx @@ -0,0 +1,28 @@ +/** + * 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 { ElScrollbar } from 'element-plus' +import { defineComponent } from "vue" +import { useLimitData } from "../../context" +import Card from "./Card" + +const _default = defineComponent(() => { + const { list } = useLimitData() + + return () => ( + + + {list.value.map(row => )} + + + ) +}) + +export default _default diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx similarity index 90% rename from src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx index c27a9713..e9f4d1de 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx @@ -1,15 +1,17 @@ import { t } from "@app/locale" +import { useXsState } from '@hooks/useMediaSize' import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" import { useSopData } from "./context" const _default = defineComponent(() => { const data = useSopData() + const isXs = useXsState() return () => ( - + msg.limit.item.name)} required> data.name = val} @@ -17,7 +19,7 @@ const _default = defineComponent(() => { /> - + msg.limit.item.enabled)} required> data.enabled = !!v} /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx similarity index 82% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx index 6c90e74d..59f1fcb3 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx @@ -8,7 +8,7 @@ import { t } from "@app/locale" import { Check, Close, Plus } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch, useXsState } from "@hooks" import Flex from "@pages/components/Flex" import { dateMinute2Idx, period2Str } from "@util/limit" import { MILL_PER_HOUR } from "@util/time" @@ -95,6 +95,7 @@ const usePickerStyle = () => { const PeriodInput = defineComponent>(props => { const [editing, openEditing, closeEditing] = useSwitch(false) const [editingRange, setEditingRange] = useState(rangeInitial()) + const isXs = useXsState() const handleEdit = () => { openEditing() @@ -118,17 +119,19 @@ const PeriodInput = defineComponent>(props => { const pickerCls = usePickerStyle() return () => ( - - {props.modelValue?.map((p, idx) => - handleDelete(idx)} - > - {period2Str(p)} - - )} -
    + + + {props.modelValue?.map((p, idx) => + handleDelete(idx)} + > + {period2Str(p)} + + )} + + >(props => { onClick={handleSave} style={{ ...BUTTON_STYLE, marginInlineStart: 0 } satisfies StyleValue} /> + +
    + + {t(msg => msg.button.create)} +
    - - {t(msg => msg.button.create)} -
    ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx new file mode 100644 index 00000000..a04b4dc9 --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx @@ -0,0 +1,251 @@ +import { CircleClose, Clock } from "@element-plus/icons-vue" +import { useDebounceFn, useState, useXsState } from "@hooks" +import { getStyle } from "@pages/util/style" +import { range } from "@util/array" +import { + Effect, ElIcon, ElInput, ElPopover, ElScrollbar, + type ScrollbarInstance, + useLocale, useNamespace +} from "element-plus" +import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" + +function computeSecond2LimitInfo(time: number): [number, number, number] { + time = time || 0 + const second = time % 60 + const totalMinutes = (time - second) / 60 + const minute = totalMinutes % 60 + const hour = (totalMinutes - minute) / 60 + return [hour, minute, second] +} + +const formatTimeVal = (val: number): string => { + return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' +} + +type TimeSpinnerProps = { + max: number + modelValue: number + visible: boolean + onChange?: (val: number) => void +} + +const TimeSpinner = defineComponent(props => { + const ns = useNamespace('time') + const scrollbar = ref() + const scrolling = ref(false) + + const debounceChangeValue = useDebounceFn((val: number) => { + scrolling.value = false + props.onChange?.(val) + }, 200) + + const getScrollbarElement = () => { + const el = scrollbar.value?.$el + return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement + } + + const adjustSpinner = (value: number) => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) + } + + watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) + watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) + + const typeItemHeight = (): number => { + const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement + if (listItem) { + return Number.parseFloat(getStyle(listItem, 'height')) || 0 + } + return 0 + } + + const bindScroll = () => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.addEventListener('scroll', () => { + scrolling.value = true + const scrollTop = getScrollbarElement()?.scrollTop ?? 0 + const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 + const itemH = typeItemHeight() + const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) + const value = Math.min(estimatedIdx, props.max - 1) + debounceChangeValue(value) + }, { passive: true }) + } + + onMounted(() => { + bindScroll() + adjustSpinner(props.modelValue) + }) + + return () => ( + + {range(props.max).map(idx => ( +
  • props.onChange?.(idx)} + class={[ + ns.be('spinner', 'item'), + ns.is('active', idx === props.modelValue), + ]} + > + {idx.toString().padStart(2, '0')} +
  • + ))} +
    + ) +}, { props: ['max', 'modelValue', 'visible', 'onChange'] }) + +const useTimeInput = (source: () => number) => { + 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) + setHour(hour) + setMinute(min) + setSecond(sec) + } + + watch(source, reset) + + const getTotalSecond = () => { + let time = 0 + time += (hour.value ?? 0) * 3600 + time += (minute.value ?? 0) * 60 + time += (second.value ?? 0) + return time + } + + return { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } +} + +type TimeInputProps = { + modelValue: number + hourMax?: number + onChange?: (val: number) => void +} + +/** + * Rewrite + * + * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue + */ +const TimeInput = defineComponent(props => { + const [popoverVisible, setPopoverVisible] = useState(false) + const { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } = useTimeInput(() => props.modelValue) + + const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) + + const ns = useNamespace('time') + const nsDate = useNamespace('date') + const nsInput = useNamespace('input') + + const { t: tEle } = useLocale() + + const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) + + const handleCancel = () => { + reset() + setPopoverVisible(false) + } + + const handleConfirm = () => { + props.onChange?.(getTotalSecond()) + setPopoverVisible(false) + } + + const handleVisibleChange = (newVal: boolean) => { + setPopoverVisible(newVal) + !newVal && handleCancel() + } + + const handleClear = (ev: MouseEvent) => { + props.onChange?.(0) + ev.stopPropagation() + } + + const isXs = useXsState() + + return () => ( + ( + !!props.modelValue && ( +
    + + + +
    + ) + }} + /> + ) + }}> + +
    +
    +
    + + + +
    +
    +
    + + +
    +
    +
    +
    + ) +}, { props: ['hourMax', 'modelValue', 'onChange'] }) + +export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx similarity index 80% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx index 25acb2b8..6c815967 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx @@ -6,6 +6,7 @@ */ import { t } from "@app/locale" +import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" @@ -17,32 +18,35 @@ const MAX_HOUR_WEEKLY = 7 * 24 const _default = defineComponent(() => { const data = useSopData() + const isXs = useXsState() return () => ( - + msg.limit.item.daily)}> - + data.time = v} /> - {t(msg => msg.limit.item.or)} + {!isXs.value && t(msg => msg.limit.item.or)} data.count = v ?? 0} + size={isXs.value ? 'small' : undefined} v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} /> msg.limit.item.weekly)}> - + data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> - {t(msg => msg.limit.item.or)} + {!isXs.value && t(msg => msg.limit.item.or)} data.weeklyCount = v ?? 0} + size={isXs.value ? 'small' : undefined} v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/context.ts b/src/pages/app/components/Limit/components/Modify/Sop/context.ts similarity index 98% rename from src/pages/app/components/Limit/LimitModify/Sop/context.ts rename to src/pages/app/components/Limit/components/Modify/Sop/context.ts index 51eb12b7..d2ea4273 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/context.ts +++ b/src/pages/app/components/Limit/components/Modify/Sop/context.ts @@ -4,7 +4,7 @@ import { range } from "@util/array" import { ElMessage } from "element-plus" import { type Reactive, reactive, type Ref, ref, toRaw } from "vue" -type Step = 0 | 1 | 2 +export type Step = 0 | 1 | 2 type SopData = Required> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/index.tsx similarity index 54% rename from src/pages/app/components/Limit/LimitModify/Sop/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/index.tsx index 6dbd7b5a..a3d5a11f 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/index.tsx @@ -7,9 +7,11 @@ import DialogSop from "@app/components/common/DialogSop" import { t } from "@app/locale" -import { ElStep, ElSteps } from "element-plus" -import { computed, defineComponent } from "vue" -import { initSop } from "./context" +import { useXsState } from '@hooks' +import Flex from '@pages/components/Flex' +import { ElDivider, ElStep, ElSteps, ElText } from "element-plus" +import { computed, defineComponent, StyleValue } from "vue" +import { initSop, type Step } from "./context" import Step1 from "./Step1" import Step2 from "./Step2" import Step3 from "./Step3" @@ -26,12 +28,20 @@ type Props = { onSave?: ArgCallback> } +const STEP_TITLE: Record = { + 0: t(msg => msg.limit.step.base), + 1: t(msg => msg.limit.step.url), + 2: t(msg => msg.limit.step.rule), +} + const _default = defineComponent(({ onSave, onCancel }, ctx) => { const { reset, step, handleNext } = initSop({ onSave }) const last = computed(() => step.value === 2) const first = computed(() => step.value === 0) ctx.expose({ reset } satisfies SopInstance) + const isXs = useXsState() + return () => ( (({ onSave, onCancel }, ctx) => { onNext={handleNext} onFinish={handleNext} v-slots={{ - steps: () => ( - - msg.limit.step.base)} /> - msg.limit.step.url)} /> - msg.limit.step.rule)} /> - - ), + steps: () => isXs.value + ? ( + + {STEP_TITLE[step.value]} + + + ) + : ( + + + + + + ), content: () => <> diff --git a/src/pages/app/components/Limit/LimitModify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx similarity index 91% rename from src/pages/app/components/Limit/LimitModify/index.tsx rename to src/pages/app/components/Limit/components/Modify/index.tsx index 2abe7dbe..1a01ad79 100644 --- a/src/pages/app/components/Limit/LimitModify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -6,17 +6,17 @@ */ import { t } from "@app/locale" -import { useSwitch } from "@hooks" +import { useSwitch, useXsState } from "@hooks" import limitService from "@service/limit-service" import { ElDialog, ElMessage } from "element-plus" import { computed, defineComponent, nextTick, ref, toRaw } from "vue" -import { type ModifyInstance, useLimitTable } from "../context" +import { type ModifyInstance, useLimitData } from "../../context" import Sop, { type SopInstance } from "./Sop" type Mode = "create" | "modify" const _default = defineComponent((_, ctx) => { - const { refresh } = useLimitTable() + const { refresh } = useLimitData() const [visible, open, close] = useSwitch() const sop = ref() const mode = ref() @@ -68,12 +68,15 @@ const _default = defineComponent((_, ctx) => { }, } satisfies ModifyInstance) + const isXs = useXsState() + return () => ( diff --git a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx similarity index 93% rename from src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx rename to src/pages/app/components/Limit/components/Table/OperationColumn.tsx index 3d6e7f08..9d531f12 100644 --- a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx +++ b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx @@ -10,7 +10,7 @@ import { locale } from "@i18n" import { ElButton, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" import { verifyCanModify } from "../../common" -import { useLimitAction, useLimitTable } from "../../context" +import { useLimitAction, useLimitData } from "../../context" const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { en: 220, @@ -29,7 +29,7 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { } const _default = defineComponent<{}>(() => { - const { deleteRow } = useLimitTable() + const { deleteRow } = useLimitData() const { modify } = useLimitAction() const handleModify = (row: timer.limit.Item) => verifyCanModify(row) diff --git a/src/pages/app/components/Limit/components/Table/Rule.tsx b/src/pages/app/components/Limit/components/Table/Rule.tsx new file mode 100644 index 00000000..6ed4ce8e --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Rule.tsx @@ -0,0 +1,68 @@ +import { t } from "@app/locale" +import Flex from "@pages/components/Flex" +import { period2Str } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElTag, TagProps } from 'element-plus' +import { defineComponent, type FunctionalComponent, toRef } from "vue" +import { DAILY_WEEKLY_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type TimeCountPairProps = { + time?: number + count?: number + label: string + type?: TagProps['type'] +} + +const TimeCountPair: FunctionalComponent = ({ time, count, label, type = DAILY_WEEKLY_TAG_TYPE }) => { + if (!time && !count) return null + + const timeContent = time ? formatPeriodCommon(time * MILL_PER_SECOND, true) : null + const countContent = count ? `${count} ${t(msg => msg.limit.item.visits)}` : null + const content = [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) + + return ( +
    + {label}: {content} +
    + ) +} + +const PeriodTag: FunctionalComponent<{ periods?: timer.limit.Period[], }> = ({ periods }) => { + if (!periods?.length) return null + + return <> +
    + {t(msg => msg.limit.item.period)} +
    + + {periods.map((p, idx) => {period2Str(p)})} + + +} + +const Rule = defineComponent<{ value: timer.limit.Item }>(props => { + const row = toRef(props, 'value') + + return () => ( + + msg.limit.item.daily)} + /> + msg.limit.item.weekly)} + /> + msg.limit.item.visitTime)} + type={VISIT_TAG_TYPE} + /> + + + ) +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/Waste.tsx b/src/pages/app/components/Limit/components/Table/Waste.tsx new file mode 100644 index 00000000..226a05ac --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Waste.tsx @@ -0,0 +1,48 @@ +import TooltipWrapper from "@app/components/common/TooltipWrapper" +import { t } from "@app/locale" +import Flex from "@pages/components/Flex" +import { meetLimit, meetTimeLimit } from "@util/limit" +import { formatPeriodCommon } from "@util/time" +import { ElTag } from "element-plus" +import { computed, defineComponent } from "vue" + +type Props = { + time?: number + waste: number + count?: number + visit?: number + delayCount?: number + allowDelay?: boolean +} + +const Waste = defineComponent(props => { + const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') + const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + + return () => ( + +
    + `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, + default: () => ( + + {formatPeriodCommon(props.waste)} + + ), + }} + /> +
    +
    + + {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + +
    +
    + ) +}, { props: ['time', 'waste', 'count', 'visit', 'allowDelay', 'delayCount'] }) + +export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Weekday.tsx b/src/pages/app/components/Limit/components/Table/Weekday.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitTable/Weekday.tsx rename to src/pages/app/components/Limit/components/Table/Weekday.tsx diff --git a/src/pages/app/components/Limit/LimitTable/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx similarity index 92% rename from src/pages/app/components/Limit/LimitTable/index.tsx rename to src/pages/app/components/Limit/components/Table/index.tsx index ea7c6401..18f80643 100644 --- a/src/pages/app/components/Limit/LimitTable/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -9,11 +9,11 @@ import { t } from "@app/locale" import { useLocalStorage, useRequest, useState } from "@hooks" import weekHelper from "@service/components/week-helper" import { isEffective } from "@util/limit" -import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort } from "element-plus" -import { defineComponent, watch } from "vue" -import { useLimitTable } from "../context" -import LimitOperationColumn from "./column/LimitOperationColumn" -import RuleContent from "./RuleContent" +import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance } from "element-plus" +import { defineComponent, ref, watch } from "vue" +import { useLimitData, type LimitInstance } from "../../context" +import LimitOperationColumn from "./OperationColumn" +import Rule from "./Rule" import Waste from "./Waste" import Weekday from "./Weekday" @@ -27,17 +27,14 @@ const sortMethodByNumVal = (key: keyof timer.limit.Item & 'waste' | 'weeklyWaste const sortByEffectiveDays = ({ weekdays: a }: timer.limit.Item, { weekdays: b }: timer.limit.Item) => (a?.length ?? 0) - (b?.length ?? 0) -const _default = defineComponent(() => { +const _default = defineComponent((_, ctx) => { const { data: weekStartName } = useRequest(async () => { const offset = await weekHelper.getRealWeekStart() const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] return name || 'NaN' }) - const { - list, table, - changeEnabled, changeDelay, changeLocked - } = useLimitTable() + const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() const [cachedSort, setCachedSort] = useLocalStorage( '__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' } @@ -46,6 +43,12 @@ const _default = defineComponent(() => { const [sort, setSort] = useState(cachedSort) watch(sort, () => setCachedSort(sort.value)) + const table = ref() + + ctx.expose({ + getSelected: () => table.value?.getSelectionRows?.() ?? [] + } satisfies LimitInstance) + return () => ( { minWidth={200} align="center" > - {({ row }: RenderRowData) => } + {({ row }: RenderRowData) => } { if (!url) { @@ -35,6 +35,7 @@ const _default = defineComponent((_props, ctx) => { const debouncedUrl = useDebounce(url) const [visible, open, close] = useSwitch() const { data: result } = useRequest(() => fetchResult(debouncedUrl.value), { deps: debouncedUrl }) + const isXs = useXsState() ctx.expose({ show: () => { @@ -49,6 +50,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={visible.value} closeOnClickModal={false} onClose={close} + fullscreen={isXs.value} > filter: Reactive - list: Ref, refresh: NoArgCallback, + list: Ref + refresh: NoArgCallback deleteRow: ArgCallback batchDelete: NoArgCallback batchEnable: NoArgCallback @@ -69,10 +73,10 @@ export const useLimitProvider = () => { } }) - const table = ref() + const inst = ref() const selectedAndThen = (then: (list: timer.limit.Item[]) => void): void => { - const list = table.value?.getSelectionRows?.() + const list = inst.value?.getSelected?.() if (!list?.length) { ElMessage.info('No limit rule selected') return @@ -85,10 +89,14 @@ export const useLimitProvider = () => { refresh() } - const handleBatchDelete = (list: timer.limit.Item[]) => verifyCanModify(...list) - .then(() => limitService.remove(...list)) - .then(onBatchSuccess) - .catch(() => { }) + const handleBatchDelete = (list: timer.limit.Item[]) => { + const names = list.map(item => item.name ?? item.id).join(', ') + verifyCanModify(...list) + .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: names }), { type: "warning" })) + .then(() => limitService.remove(...list)) + .then(onBatchSuccess) + .catch(() => { }) + } const handleBatchEnable = (list: timer.limit.Item[]) => { list.forEach(item => item.enabled = true) @@ -149,7 +157,6 @@ export const useLimitProvider = () => { const empty = computed(() => !loading.value && !list.value.length) useProvide(NAMESPACE, { - table, filter, list, empty, refresh, deleteRow, @@ -160,13 +167,13 @@ export const useLimitProvider = () => { modify, create, test, }) - return { modifyInst, testInst } + return { modifyInst, testInst, inst } } export const useLimitFilter = (): Reactive => useProvider(NAMESPACE, "filter").filter -export const useLimitTable = () => useProvider( - NAMESPACE, 'list', 'table', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' +export const useLimitData = () => useProvider( + NAMESPACE, 'list', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' ) export const useLimitBatch = () => useProvider( diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx index 77877f85..611ca303 100644 --- a/src/pages/app/components/Limit/index.tsx +++ b/src/pages/app/components/Limit/index.tsx @@ -5,24 +5,24 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { defineComponent } from "vue" +import ContentCard from '../common/ContentCard' import ContentContainer from "../common/ContentContainer" +import { Filter, List, Modify, Table, Test } from "./components" import { useLimitProvider } from "./context" -import LimitFilter from "./LimitFilter" -import LimitModify from "./LimitModify" -import LimitTable from "./LimitTable" -import LimitTest from "./LimitTest" const _default = defineComponent(() => { - const { modifyInst, testInst } = useLimitProvider() + const { modifyInst, testInst, inst } = useLimitProvider() + const isXs = useXsState() return () => ( , - content: () => <> - - - + filter: () => , + default: () => <> + {isXs.value ? : } + + }} /> ) diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index 77f880af..d1c17871 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -5,8 +5,6 @@ import { useRouter } from "vue-router" import ContentContainer from "../common/ContentContainer" import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" -const IGNORED_CATE: OptionCategory[] = ['limit'] - const _default = defineComponent(() => { const tab = ref(parseQuery() || 'appearance') const router = useRouter() @@ -21,12 +19,9 @@ const _default = defineComponent(() => { modelValue={tab.value} onChange={val => tab.value = val} > - {Object.keys(slots) - .filter(key => !IGNORED_CATE.includes(key as OptionCategory) && key !== 'default') - .map(cate => ( - - )) - } + {Object.keys(slots).map(cate => ( + + ))} ), default: () => { diff --git a/src/pages/app/components/Option/components/LimitOption/index.tsx b/src/pages/app/components/Option/components/LimitOption/index.tsx index b414d8d7..aebf7517 100644 --- a/src/pages/app/components/Option/components/LimitOption/index.tsx +++ b/src/pages/app/components/Option/components/LimitOption/index.tsx @@ -126,7 +126,7 @@ const _default = defineComponent((_, ctx) => { default: () => ( option.limitReminder = val as boolean} + onChange={val => option.limitReminder = !!val} /> ), minInput: () => ( diff --git a/src/pages/app/components/common/TooltipWrapper.tsx b/src/pages/app/components/common/TooltipWrapper.tsx index 3d502c14..e4f15b18 100644 --- a/src/pages/app/components/common/TooltipWrapper.tsx +++ b/src/pages/app/components/common/TooltipWrapper.tsx @@ -1,7 +1,7 @@ -import { ElTooltip, ElTooltipProps } from "element-plus" +import { ElTooltip, type UseTooltipProps } from "element-plus" import { defineComponent, ref, useSlots } from "vue" -type Props = PartialPick & { +type Props = PartialPick & { usePopover?: boolean } diff --git a/src/pages/app/components/common/filter/ButtonFilterItem.tsx b/src/pages/app/components/common/filter/ButtonFilterItem.tsx index 4e173b2e..9eda1817 100644 --- a/src/pages/app/components/common/filter/ButtonFilterItem.tsx +++ b/src/pages/app/components/common/filter/ButtonFilterItem.tsx @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ import { type I18nKey, t } from '@app/locale' +import { useXsState } from '@hooks/useMediaSize' import { type ButtonProps, ElButton } from "element-plus" import { defineComponent } from "vue" @@ -16,11 +17,21 @@ type Props = { } const ButtonFilterItem = defineComponent(props => { - return () => ( - - {t(props.text)} - - ) + const isXs = useXsState() + return () => isXs.value + ? ( + + ) : ( + + {t(props.text)} + + ) }, { props: ['icon', 'onClick', 'text', 'type'] }) export default ButtonFilterItem \ No newline at end of file diff --git a/src/pages/components/Flex.tsx b/src/pages/components/Flex.tsx index f611489a..711c256f 100644 --- a/src/pages/components/Flex.tsx +++ b/src/pages/components/Flex.tsx @@ -23,32 +23,34 @@ type Props = { } & BaseProps const Flex = defineComponent(props => { - const { default: defaultSlots } = useSlots() const Comp = props.as ?? 'div' - return () => ( - - {defaultSlots && h(defaultSlots)} - - ) + return () => { + const { default: defaultSlots } = useSlots() + return ( + + {defaultSlots && h(defaultSlots)} + + ) + } }, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'rowGap', 'wrap', 'as'] }) export default Flex \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index 41aa376b..caaf3366 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -6,7 +6,7 @@ import { t } from "@popup/locale" import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" -import { FunctionalComponent } from "vue" +import type { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" import Logo from "./Logo" import MoreInfo from './MoreInfo' @@ -29,7 +29,7 @@ const openAppPage = async () => { await createTab(appPageUrl) } -const Header: FunctionalComponent = () => ( +const Header: FunctionalComponent<{}> = () => ( diff --git a/src/util/time.ts b/src/util/time.ts index 8cb3e195..9d5dd137 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -51,10 +51,12 @@ export function formatTimeYMD(time: Date | number) { return formatTime(time, '{y}{m}{d}') } +type PeriodMsgFormat = Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string> + /** * Format milliseconds for display */ -export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string>): string { +export function formatPeriod(milliseconds: number, message: PeriodMsgFormat): string { const prefix = milliseconds < 0 ? '-' : '' milliseconds = Math.abs(milliseconds) const { dayMsg, hourMsg, minuteMsg, secondMsg } = message @@ -82,29 +84,45 @@ export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'h return prefix + result } +const PERIOD_RTL: PeriodMsgFormat = { + dayMsg: 's{second} m{minute} h{hour} d{day}', + hourMsg: 's{second} m{minute} h{hour}', + minuteMsg: 's{second} m{minute}', + secondMsg: 's{second}', +} +const PERIOD_RTL_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: 's{second}m{minute}h{hour}d{day}', + hourMsg: 's{second}m{minute}h{hour}', + minuteMsg: 's{second}m{minute}', + secondMsg: 's{second}', +} +const PERIOD_LTR: PeriodMsgFormat = { + dayMsg: '{day}d {hour}h {minute}m {second}s', + hourMsg: '{hour}h {minute}m {second}s', + minuteMsg: '{minute}m {second}s', + secondMsg: '{second}s', +} +const PERIOD_LTR_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: '{day}d{hour}h{minute}m{second}s', + hourMsg: '{hour}h{minute}m{second}s', + minuteMsg: '{minute}m{second}s', + secondMsg: '{second}s', +} + /** * e.g. * - * 100h0m0s - * 20h10m59s - * 20h0m1s - * 10m20s + * 2d 10h 0m 0s + * 20h 0m 1s + * 10m 20s * 30s * * @return (xx+h)(xx+m)xx+s */ -export function formatPeriodCommon(milliseconds: number): string { - const defaultMessage = isRtl() ? { - dayMsg: 's{second} m{minute} h{hour} d{day}', - hourMsg: 's{second} m{minute} h{hour}', - minuteMsg: 's{second} m{minute}', - secondMsg: 's{second}', - } : { - dayMsg: '{day}d {hour}h {minute}m {second}s', - hourMsg: '{hour}h {minute}m {second}s', - minuteMsg: '{minute}m {second}s', - secondMsg: '{second}s', - } +export function formatPeriodCommon(milliseconds: number, simplified?: boolean): string { + const defaultMessage = isRtl() + ? (simplified ? PERIOD_RTL_SIMPLIFIED : PERIOD_RTL) + : (simplified ? PERIOD_LTR_SIMPLIFIED : PERIOD_LTR) return formatPeriod(milliseconds, defaultMessage) } diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index eb5e75e3..1ce96271 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,6 +1,6 @@ import { type Browser, launch, type Page } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" -import { removeAllWhitelist } from './whitelist' +import { removeAllWhitelist } from './whitelist.test' const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] diff --git a/test-e2e/common/whitelist.ts b/test-e2e/common/whitelist.test.ts similarity index 70% rename from test-e2e/common/whitelist.ts rename to test-e2e/common/whitelist.test.ts index 357e7afc..c15ed833 100644 --- a/test-e2e/common/whitelist.ts +++ b/test-e2e/common/whitelist.test.ts @@ -1,4 +1,4 @@ -import { type LaunchContext, sleep } from "./base" +import { launchBrowser, type LaunchContext, sleep } from "./base" export async function createWhitelist(context: LaunchContext, white: string) { const whitePage = await context.openAppPage('/additional/whitelist') @@ -11,8 +11,10 @@ export async function createWhitelist(context: LaunchContext, white: string) { await input?.focus() await whitePage.keyboard.type(white) await sleep(.4) - const selectItem = await whitePage.waitForSelector('.el-popper .el-select-dropdown li:nth-child(1)') - await selectItem?.click() + 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() @@ -25,4 +27,10 @@ export async function removeAllWhitelist(context: LaunchContext) { await chrome.storage.local.remove('__timer__WHITELIST') }) await whitePage.close() -} \ No newline at end of file +} + +// Run to test the function, but skip it in normal test runs +test.skip('create whitelist', async () => { + const context = await launchBrowser() + await createWhitelist(context, 'example.com') +}) \ No newline at end of file diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index aeb4fccf..3857b394 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -1,6 +1,6 @@ import { launchBrowser, type LaunchContext, MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from "../common/base" import { readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist" +import { createWhitelist } from "../common/whitelist.test" let context: LaunchContext diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index 238791bf..ce35a29f 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -1,6 +1,6 @@ import { launchBrowser, MOCK_HOST, MOCK_URL, sleep, type LaunchContext } from "../common/base" import { parseTime2Sec, readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist" +import { createWhitelist } from "../common/whitelist.test" let context: LaunchContext