From cec580ebc152f0eeab15ae223997b99cee57763d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 13 Nov 2023 18:57:48 +0800 Subject: [PATCH] Support to limit the time of each visit and daily banned time period (#215, #228) --- src/api/chrome/tab.ts | 7 +- .../components/analysis/components/filter.ts | 1 - .../analysis/components/trend/index.ts | 2 +- src/app/components/analysis/index.ts | 2 - .../components/common/content-container.ts | 22 +- .../dashboard/components/calendar-heat-map.ts | 2 +- .../dashboard/components/top-k-visit.ts | 3 +- .../data-manage/clear/filter/index.ts | 25 ++- src/app/components/data-manage/clear/index.ts | 8 +- src/app/components/data-manage/index.ts | 8 +- src/app/components/data-manage/memory-info.ts | 11 +- .../data-manage/migration/import-button.ts | 8 +- .../migration/import-other-button/style.sass | 23 -- .../components/habit/component/chart/index.ts | 11 +- src/app/components/habit/index.ts | 7 +- src/app/components/limit/index.ts | 8 +- src/app/components/limit/modify/footer.ts | 30 --- .../components/limit/modify/form/common.ts | 16 -- .../components/limit/modify/form/form.d.ts | 41 ---- src/app/components/limit/modify/form/index.ts | 105 --------- .../components/limit/modify/form/path-edit.ts | 95 -------- src/app/components/limit/modify/index.ts | 84 +++---- src/app/components/limit/modify/modify.d.ts | 1 - src/app/components/limit/modify/sop/common.ts | 50 +++++ src/app/components/limit/modify/sop/index.ts | 99 +++++++++ .../components/limit/modify/sop/path-edit.ts | 117 ++++++++++ src/app/components/limit/modify/sop/period.ts | 127 +++++++++++ src/app/components/limit/modify/sop/step1.ts | 94 ++++++++ src/app/components/limit/modify/sop/step2.ts | 71 ++++++ .../limit/modify/{form => sop}/time-limit.ts | 54 +++-- .../limit/modify/{form => sop}/url.ts | 12 +- .../limit/modify/style/el-input.sass | 13 +- .../components/limit/modify/style/sop.sass | 30 +++ .../components/limit/table/column/common.ts | 26 ++- .../components/limit/table/column/delay.ts | 6 +- .../components/limit/table/column/enabled.ts | 6 +- .../limit/table/column/operation.ts | 6 +- .../components/limit/table/column/period.ts | 36 +++ src/app/components/limit/table/column/time.ts | 4 +- .../components/limit/table/column/visit.ts | 28 +++ .../components/limit/table/column/waste.ts | 2 +- src/app/components/limit/table/index.ts | 4 + src/app/components/limit/test.ts | 100 +++++---- .../option/components/appearance/index.ts | 1 - .../option/components/backup/style.sass | 12 - src/app/components/report/index.ts | 8 +- src/app/components/report/table/index.ts | 14 +- .../rule-merge/components/add-button.ts | 14 +- .../components/rule-merge/components/item.ts | 14 +- src/app/components/rule-merge/item-list.ts | 16 +- src/app/components/site-manage/index.ts | 8 +- .../components/site-manage/modify/index.ts | 27 ++- .../whitelist/components/add-button.ts | 13 +- .../components/whitelist/components/item.ts | 14 +- src/app/components/whitelist/item-list.ts | 12 +- src/app/styles/compatible.sass | 25 +++ src/background/content-script-handler.ts | 3 +- src/background/limit-processor.ts | 19 ++ src/background/timer/client.ts | 9 +- src/content-script/index.ts | 16 +- src/content-script/limit.ts | 210 ------------------ src/content-script/limit/common.ts | 28 +++ src/content-script/limit/daily-processor.ts | 52 +++++ src/content-script/limit/index.ts | 41 ++++ src/content-script/limit/modal-style.ts | 29 +++ src/content-script/limit/modal.ts | 195 ++++++++++++++++ src/content-script/limit/period-processor.ts | 52 +++++ src/content-script/limit/visit-processor.ts | 55 +++++ src/database/limit-database.ts | 29 ++- src/i18n/chrome/t.ts | 2 - src/i18n/message/app/limit-resource.json | 52 +++-- src/i18n/message/app/limit.ts | 6 +- src/i18n/message/app/menu-resource.json | 10 +- .../common/content-script-resource.json | 25 ++- src/i18n/message/common/content-script.ts | 3 + src/i18n/message/guide/limit-resource.json | 20 +- src/i18n/message/guide/privacy-resource.json | 2 +- src/service/limit-service/index.ts | 47 +++- src/util/array.ts | 8 + src/util/limit.ts | 41 +++- test/database/limit-database.test.ts | 2 +- types/timer/limit.d.ts | 20 +- types/timer/mq.d.ts | 7 + types/timer/option.d.ts | 6 - 84 files changed, 1710 insertions(+), 862 deletions(-) delete mode 100644 src/app/components/limit/modify/footer.ts delete mode 100644 src/app/components/limit/modify/form/common.ts delete mode 100644 src/app/components/limit/modify/form/form.d.ts delete mode 100644 src/app/components/limit/modify/form/index.ts delete mode 100644 src/app/components/limit/modify/form/path-edit.ts delete mode 100644 src/app/components/limit/modify/modify.d.ts create mode 100644 src/app/components/limit/modify/sop/common.ts create mode 100644 src/app/components/limit/modify/sop/index.ts create mode 100644 src/app/components/limit/modify/sop/path-edit.ts create mode 100644 src/app/components/limit/modify/sop/period.ts create mode 100644 src/app/components/limit/modify/sop/step1.ts create mode 100644 src/app/components/limit/modify/sop/step2.ts rename src/app/components/limit/modify/{form => sop}/time-limit.ts (68%) rename src/app/components/limit/modify/{form => sop}/url.ts (93%) create mode 100644 src/app/components/limit/modify/style/sop.sass create mode 100644 src/app/components/limit/table/column/period.ts create mode 100644 src/app/components/limit/table/column/visit.ts delete mode 100644 src/content-script/limit.ts create mode 100644 src/content-script/limit/common.ts create mode 100644 src/content-script/limit/daily-processor.ts create mode 100644 src/content-script/limit/index.ts create mode 100644 src/content-script/limit/modal-style.ts create mode 100644 src/content-script/limit/modal.ts create mode 100644 src/content-script/limit/period-processor.ts create mode 100644 src/content-script/limit/visit-processor.ts diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index da3f4c8c..803a871c 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -61,11 +61,10 @@ export function sendMsg2Tab(tabId: number, code: timer.mq.ReqC const request: timer.mq.Request = { code, data } return new Promise((resolve, reject) => { chrome.tabs.sendMessage, timer.mq.Response>(tabId, request, response => { - handleError('sendMsgTab') + handleError('sendMsg2Tab') const resCode = response?.code - resCode === 'success' - ? resolve(response.data) - : reject(new Error(response?.msg)) + resCode === 'success' && resolve(response.data) + resCode === "fail" && reject(new Error(response?.msg)) }) }) } diff --git a/src/app/components/analysis/components/filter.ts b/src/app/components/analysis/components/filter.ts index 07c1f10b..2f9e99a0 100644 --- a/src/app/components/analysis/components/filter.ts +++ b/src/app/components/analysis/components/filter.ts @@ -87,7 +87,6 @@ function renderHostLabel({ host, merged, virtual, alias }: timer.site.SiteInfo): } const _default = defineComponent({ - name: "TrendFilter", props: { site: Object as PropType, timeFormat: String as PropType diff --git a/src/app/components/analysis/components/trend/index.ts b/src/app/components/analysis/components/trend/index.ts index ba1b3e0a..1cc474c6 100644 --- a/src/app/components/analysis/components/trend/index.ts +++ b/src/app/components/analysis/components/trend/index.ts @@ -195,7 +195,7 @@ const _default = defineComponent({ const indicators: Ref = ref() const lastIndicators: Ref = ref() const timeFormat: Ref = ref(props.timeFormat) - const rangeLength: ComputedRef = computed(() => getDayLenth(dateRange.value?.[0], dateRange.value?.[1])) + const rangeLength: ComputedRef = computed(() => getDayLenth(dateRange.value?.[0], dateRange.value?.[1])) const compute = () => handleDataChange( { dateRange: dateRange.value, rows: props.rows }, diff --git a/src/app/components/analysis/index.ts b/src/app/components/analysis/index.ts index 71241f3f..11015c10 100644 --- a/src/app/components/analysis/index.ts +++ b/src/app/components/analysis/index.ts @@ -53,7 +53,6 @@ const _default = defineComponent(() => { const site: Ref = ref(siteFromQuery) const timeFormat: Ref = ref('default') const rows: Ref = ref() - const filter: Ref = ref() const queryInner = async () => { const siteKey = site.value @@ -67,7 +66,6 @@ const _default = defineComponent(() => { filter: () => h(Filter, { site: site.value, timeFormat: timeFormat.value, - ref: filter, onSiteChange: (newSite: timer.site.SiteKey) => site.value = newSite, onTimeFormatChange: (newFormat: timer.app.TimeFormat) => timeFormat.value = newFormat }), diff --git a/src/app/components/common/content-container.ts b/src/app/components/common/content-container.ts index cf293828..150718d2 100644 --- a/src/app/components/common/content-container.ts +++ b/src/app/components/common/content-container.ts @@ -9,17 +9,19 @@ import { ElCard, ElScrollbar } from "element-plus" import ContentCard from "./content-card" import { defineComponent, h, useSlots } from "vue" -const _default = defineComponent(() => { - const slots = useSlots() - const children = [] - const { default: default_, filter, content } = slots - filter && children.push(h(ElCard, { class: "filter-container" }, () => h(filter))) - if (default_) { - children.push(h(slots.default)) - } else { - content && children.push(h(ContentCard, () => h(content))) +const _default = defineComponent({ + setup() { + const slots = useSlots() + const children = [] + const { default: default_, filter, content } = slots + filter && children.push(h(ElCard, { class: "filter-container" }, () => h(filter))) + if (default_) { + children.push(h(slots.default)) + } else { + content && children.push(h(ContentCard, () => h(content))) + } + return () => h(ElScrollbar, () => h("div", { class: "content-container" }, children)) } - return () => h(ElScrollbar, () => h("div", { class: "content-container" }, children)) }) export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/components/calendar-heat-map.ts b/src/app/components/dashboard/components/calendar-heat-map.ts index 8d664cec..55520a93 100644 --- a/src/app/components/dashboard/components/calendar-heat-map.ts +++ b/src/app/components/dashboard/components/calendar-heat-map.ts @@ -258,7 +258,7 @@ const _default = defineComponent({ const now = new Date() const startTime: Date = getWeeksAgo(now, isChinese, WEEK_NUM) - const chart: Ref = ref() + const chart: Ref = ref() const chartWrapper: ChartWrapper = new ChartWrapper(startTime, now) onMounted(async () => { diff --git a/src/app/components/dashboard/components/top-k-visit.ts b/src/app/components/dashboard/components/top-k-visit.ts index 2c504f5a..f2016fed 100644 --- a/src/app/components/dashboard/components/top-k-visit.ts +++ b/src/app/components/dashboard/components/top-k-visit.ts @@ -97,12 +97,11 @@ class ChartWrapper { } const _default = defineComponent({ - name: "TopKVisit", setup() { const now = new Date() const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * DAY_NUM) - const chart: Ref = ref() + const chart: Ref = ref() const chartWrapper: ChartWrapper = new ChartWrapper() onMounted(async () => { diff --git a/src/app/components/data-manage/clear/filter/index.ts b/src/app/components/data-manage/clear/filter/index.ts index 8218d698..166ea4c2 100644 --- a/src/app/components/data-manage/clear/filter/index.ts +++ b/src/app/components/data-manage/clear/filter/index.ts @@ -13,6 +13,10 @@ import DateFilter from "./date-filter" import NumberFilter from "./number-filter" import DeleteButton from "./delete-button" +export type FilterInstance = { + getFilterOption(): DataManageClearFilterOption +} + const _default = defineComponent({ emits: { delete: () => true @@ -23,17 +27,18 @@ const _default = defineComponent({ const focusEndRef: Ref = ref('2') const timeStartRef: Ref = ref('0') const timeEndRef: Ref = ref('') - const computeFilterOption = () => ({ - dateRange: dateRangeRef.value, - focusStart: focusStartRef.value, - focusEnd: focusEndRef.value, - timeStart: timeStartRef.value, - timeEnd: timeEndRef.value, - } as DataManageClearFilterOption) - ctx.expose({ - getFilterOption: computeFilterOption - }) + const instance: FilterInstance = { + getFilterOption: () => ({ + dateRange: dateRangeRef.value, + focusStart: focusStartRef.value, + focusEnd: focusEndRef.value, + timeStart: timeStartRef.value, + timeEnd: timeEndRef.value, + } as DataManageClearFilterOption) + } + + ctx.expose(instance) return () => h('div', { class: 'clear-panel' }, [ h('h3', t(msg => msg.dataManage.filterItems)), diff --git a/src/app/components/data-manage/clear/index.ts b/src/app/components/data-manage/clear/index.ts index a06784de..224215aa 100644 --- a/src/app/components/data-manage/clear/index.ts +++ b/src/app/components/data-manage/clear/index.ts @@ -9,7 +9,7 @@ import { ElAlert, ElCard, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, h, Ref, ref, SetupContext } from "vue" import { t } from "@app/locale" import { alertProps } from "../common" -import Filter from "./filter" +import Filter, { FilterInstance } from "./filter" import StatDatabase, { StatCondition } from "@db/stat-database" import { MILL_PER_DAY } from "@util/time" @@ -109,7 +109,7 @@ const _default = defineComponent({ dataDelete: () => true }, setup(_, ctx) { - const filterRef: Ref = ref() + const filter: Ref = ref() return () => h(ElCard, { class: 'clear-container' }, () => [ @@ -118,8 +118,8 @@ const _default = defineComponent({ title: t(msg => msg.dataManage.operationAlert) }), h(Filter, { - ref: filterRef, - onDelete: () => handleClick(filterRef, ctx), + ref: filter, + onDelete: () => handleClick(filter, ctx), }), ]) } diff --git a/src/app/components/data-manage/index.ts b/src/app/components/data-manage/index.ts index ade5595d..5a6433ec 100644 --- a/src/app/components/data-manage/index.ts +++ b/src/app/components/data-manage/index.ts @@ -9,13 +9,15 @@ import { ElRow, ElCol } from "element-plus" import { defineComponent, h, Ref, ref } from "vue" import ContentContainer from "../common/content-container" import Migration from "./migration" -import MemeryInfo from "./memory-info" +import MemeryInfo, { MemeryInfoInstance } from "./memory-info" import ClearPanel from "./clear" import './style' export default defineComponent(() => { - const memeryInfoRef: Ref = ref() - const queryData = () => memeryInfoRef?.value?.queryData() + const memeryInfoRef: Ref = ref() + + const queryData = () => memeryInfoRef.value?.queryData() + return () => h(ContentContainer, { class: 'data-manage-container' }, () => h(ElRow, { gutter: 20 }, diff --git a/src/app/components/data-manage/memory-info.ts b/src/app/components/data-manage/memory-info.ts index f91b6cf7..5b48df14 100644 --- a/src/app/components/data-manage/memory-info.ts +++ b/src/app/components/data-manage/memory-info.ts @@ -11,6 +11,10 @@ import { t } from "@app/locale" import { alertProps } from "./common" import { getUsedStorage } from "@db/memory-detector" +export type MemeryInfoInstance = { + queryData(): void +} + const memoryAlert = (totalMb: number) => { const title = totalMb ? t(msg => msg.dataManage.totalMemoryAlert, { size: totalMb }) @@ -19,6 +23,7 @@ const memoryAlert = (totalMb: number) => { !totalMb && (props.type = 'warning') return h(ElAlert, props) } + const progressStyle: Partial = { height: '260px', paddingTop: '50px' @@ -53,7 +58,6 @@ function computeColor(percentage: number, total: number): string { } const _default = defineComponent({ - name: "MemoryInfo", setup(_, ctx) { // Total memory with byte const usedRef: Ref = ref(0) @@ -64,8 +68,11 @@ const _default = defineComponent({ usedRef.value = used || 0 totalRef.value = total } + + const instance: MemeryInfoInstance = { queryData } + ctx.expose(instance) + queryData() - ctx.expose({ queryData }) const usedMbRef: ComputedRef = computed(() => byte2Mb(usedRef.value)) const totalMbRef: ComputedRef = computed(() => byte2Mb(totalRef.value)) diff --git a/src/app/components/data-manage/migration/import-button.ts b/src/app/components/data-manage/migration/import-button.ts index 06c2ba33..ff1630e8 100644 --- a/src/app/components/data-manage/migration/import-button.ts +++ b/src/app/components/data-manage/migration/import-button.ts @@ -37,20 +37,20 @@ const _default = defineComponent({ import: () => true }, setup(_, ctx) { - const fileInputRef = ref() + const fileInput = ref() return () => h(ElButton, { size: 'large', type: 'primary', icon: Upload, - onClick: () => fileInputRef.value.click() + onClick: () => fileInput.value.click() }, () => [ t(msg => msg.item.operation.importWholeData), h('input', { - ref: fileInputRef, + ref: fileInput, type: 'file', accept: '.json', style: { display: 'none' }, - onChange: () => handleFileSelected(fileInputRef, () => ctx.emit('import')) + onChange: () => handleFileSelected(fileInput, () => ctx.emit('import')) }) ]) } diff --git a/src/app/components/data-manage/migration/import-other-button/style.sass b/src/app/components/data-manage/migration/import-other-button/style.sass index 84bd6079..8e227863 100644 --- a/src/app/components/data-manage/migration/import-other-button/style.sass +++ b/src/app/components/data-manage/migration/import-other-button/style.sass @@ -1,16 +1,4 @@ -.sop-dialog-container - margin-right: 16px -.step-container - width: 400px - margin: auto - .el-step__head - .el-step__line - margin-right: -50% !important - left: 50% !important - .el-step__title - text-align: center - .operation-container margin: 40px 20px 0 20px @@ -46,14 +34,3 @@ margin-top: 20px text-align: center line-height: 32px - -.sop-footer - display: block - width: 100% - margin: auto - margin-top: 40px - .el-button - width: initial !important - height: initial !important - .el-button:not(:last-child) - margin-right: 10px diff --git a/src/app/components/habit/component/chart/index.ts b/src/app/components/habit/component/chart/index.ts index 82420726..6d43c245 100644 --- a/src/app/components/habit/component/chart/index.ts +++ b/src/app/components/habit/component/chart/index.ts @@ -10,15 +10,20 @@ import type { Ref } from "vue" import ChartWrapper from "./wrapper" import { defineComponent, h, onMounted, ref } from "vue" +export type HabitChartInstance = { + render(data: timer.period.Row[], averageByDate: boolean, periodSize: number): void +} + const _default = defineComponent({ name: "HabitChart", setup(_, ctx) { const elRef: Ref = ref() const wrapper: ChartWrapper = new ChartWrapper() onMounted(() => wrapper.init(elRef.value)) - ctx.expose({ - render: (data: timer.period.Row[], averageByDate: boolean, periodSize: number) => wrapper.render(data, averageByDate, periodSize) - }) + const instance: HabitChartInstance = { + render: (data, averageByDate, periodSize) => wrapper.render(data, averageByDate, periodSize) + } + ctx.expose(instance) return () => h('div', { class: 'chart-container', ref: elRef diff --git a/src/app/components/habit/index.ts b/src/app/components/habit/index.ts index c0c98e26..2eea29f2 100644 --- a/src/app/components/habit/index.ts +++ b/src/app/components/habit/index.ts @@ -11,7 +11,7 @@ import { defineComponent, h, ref, onMounted } from "vue" import periodService from "@service/period-service" import { daysAgo, isSameDay } from "@util/time" import ContentContainer from "@app/components/common/content-container" -import HabitChart from "./component/chart" +import HabitChart, { HabitChartInstance } from "./component/chart" import HabitFilter from "./component/filter" import { keyOf, MAX_PERIOD_ORDER, keyBefore } from "@util/period" @@ -49,9 +49,8 @@ function computeParam(periodSize: Ref, dateRange: Ref, averageBy } const _default = defineComponent({ - name: "Habit", setup() { - const chart: Ref = ref() + const chart: Ref = ref() const periodSize: Ref = ref(1) //@ts-ignore ts(2322) const dateRange: Ref = ref(daysAgo(1, 0)) @@ -60,7 +59,7 @@ const _default = defineComponent({ async function queryAndRender() { const queryParam = computeParam(periodSize, dateRange, averageByDate) const result = await periodService.list(queryParam) - chart?.value.render?.(result, averageByDate.value, periodSize.value) + chart.value.render?.(result, averageByDate.value, periodSize.value) } onMounted(queryAndRender) diff --git a/src/app/components/limit/index.ts b/src/app/components/limit/index.ts index 80deb65f..ba18112a 100644 --- a/src/app/components/limit/index.ts +++ b/src/app/components/limit/index.ts @@ -9,8 +9,8 @@ import { defineComponent, h, ref, Ref } from "vue" import ContentContainer from "../common/content-container" import LimitFilter from "./filter" import LimitTable from "./table" -import LimitModify from "./modify" -import LimitTest from "./test" +import LimitModify, { ModifyInstance } from "./modify" +import LimitTest, { TestInstance } from "./test" import limitService from "@service/limit-service" import { useRoute, useRouter } from "vue-router" import { t } from "@app/locale" @@ -34,8 +34,8 @@ const _default = defineComponent(() => { useRouter().replace({ query: {} }) urlParam && (url.value = decodeURIComponent(urlParam)) - const modify: Ref = ref() - const test: Ref = ref() + const modify: Ref = ref() + const test: Ref = ref() return () => h(ContentContainer, {}, { filter: () => h(LimitFilter, { diff --git a/src/app/components/limit/modify/footer.ts b/src/app/components/limit/modify/footer.ts deleted file mode 100644 index be9744ee..00000000 --- a/src/app/components/limit/modify/footer.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { Check } from "@element-plus/icons-vue" -import { ElButton } from "element-plus" -import { defineComponent, h } from "vue" -import { t } from "@app/locale" - -const buttonText = t(msg => msg.button.save) -const _default = defineComponent({ - name: "SaveButton", - emits: { - save: () => true - }, - setup(_, ctx) { - return () => h('span', {}, - h(ElButton, { - onClick: () => ctx.emit("save"), - type: 'primary', - icon: Check - }, () => buttonText) - ) - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/common.ts b/src/app/components/limit/modify/form/common.ts deleted file mode 100644 index ab5233f5..00000000 --- a/src/app/components/limit/modify/form/common.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function parseUrl(url: string): UrlInfo { - let protocol: Protocol = '*://' - - url = decodeURI(url)?.trim() - if (url.startsWith('http://')) { - protocol = 'http://' - url = url.substring(protocol.length) - } else if (url.startsWith('https://')) { - protocol = 'https://' - url = url.substring(protocol.length) - } else if (url.startsWith('*://')) { - protocol = '*://' - url = url.substring(protocol.length) - } - return { protocol, url } -} \ No newline at end of file diff --git a/src/app/components/limit/modify/form/form.d.ts b/src/app/components/limit/modify/form/form.d.ts deleted file mode 100644 index 24f86476..00000000 --- a/src/app/components/limit/modify/form/form.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -declare type FormInfo = { - /** - * Time / seconds - */ - timeLimit: number - /** - * Protocol + path - */ - condition: string -} - -declare type UrlPart = { - /** - * The origin part text - */ - origin: string - /** - * Whether to replace with wildcard - */ - ignored: boolean -} - -declare type UrlInfo = { - protocol: Protocol - url: string -} - -/** - * The protocol of rule host - */ -declare type Protocol = - | 'http://' - | 'https://' - | '*://' diff --git a/src/app/components/limit/modify/form/index.ts b/src/app/components/limit/modify/form/index.ts deleted file mode 100644 index 0296d1a0..00000000 --- a/src/app/components/limit/modify/form/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { ElForm } from "element-plus" -import { computed, ComputedRef, defineComponent, h, reactive, ref, Ref, UnwrapRef } from "vue" -import '../style/el-input' -import LimitPathEdit from "./path-edit" -import LimitUrlFormItem from "./url" -import LimitTimeFormItem from "./time-limit" -import { parseUrl } from "./common" - -function computeFormInfo(urlInfo: UrlInfo, timeRef: Ref): FormInfo { - const { url, protocol } = urlInfo - const result: FormInfo = { - timeLimit: timeRef.value || 0, - condition: url ? protocol + url : '' - } - return result -} - -function init(timeRef: Ref, urlInfo: UrlInfo) { - // 1 hour - timeRef.value = 3600 - urlInfo.protocol = '*://' - urlInfo.url = '' -} - -function parseRow(row: timer.limit.Item, timeRef: Ref, urlInfo: UrlInfo) { - const { cond, time } = row - timeRef.value = time || 0 - const { protocol, url } = parseUrl(cond) - urlInfo.url = url - urlInfo.protocol = protocol -} - -const _default = defineComponent({ - name: "LimitForm", - setup(_, ctx) { - // Limited time - const timeRef: Ref = ref() - // Limited url - const urlInfo: UnwrapRef = reactive({ - protocol: undefined, - url: undefined - }) - const editMode: Ref = ref('create') - init(timeRef, urlInfo) - - const formInfo: ComputedRef = computed(() => computeFormInfo(urlInfo, timeRef)) - const canEditUrl: ComputedRef = computed(() => editMode.value === 'create') - const pathEditRef: Ref = ref() - - function setUrl(newUrl: string) { - urlInfo.url = newUrl - pathEditRef?.value?.forceUpdateUrl?.(newUrl) - } - - ctx.expose({ - getData: () => formInfo.value, - clean: () => { - editMode.value = 'create' - init(timeRef, urlInfo) - }, - modify: (row: timer.limit.Item) => { - editMode.value = 'modify' - parseRow(row, timeRef, urlInfo) - setUrl(urlInfo.url) - }, - }) - - return () => h(ElForm, - { labelWidth: 120 }, - () => { - const items = [ - h(LimitTimeFormItem, { - modelValue: timeRef.value, - onChange: (newVal: number) => timeRef.value = newVal - }), - h(LimitUrlFormItem, { - url: urlInfo.url, - protocol: urlInfo.protocol, - disabled: !canEditUrl.value, - onUrlChange: (newUrl: string) => setUrl(newUrl), - onProtocolChange: (newProtocol: Protocol) => urlInfo.protocol = newProtocol - }), - ] - canEditUrl.value && items.push( - h(LimitPathEdit, { - ref: pathEditRef, - disabled: editMode.value === 'modify', - url: urlInfo.url, - onUrlChange: (newVal: string) => urlInfo.url = newVal - }) - ) - return items - } - ) - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/path-edit.ts b/src/app/components/limit/modify/form/path-edit.ts deleted file mode 100644 index 8ca5cdb5..00000000 --- a/src/app/components/limit/modify/form/path-edit.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { ElSwitch, ElTag, ElTooltip } from "element-plus" -import { defineComponent, h, ref, Ref, VNode } from "vue" -import { t } from "@app/locale" - -const switchStyle: Partial = { marginRight: '2px' } - -const tagContent = (item: UrlPart, index: number, callback: Function) => { - const result: VNode[] = [] - if (!!index) { - const modelValue = item.ignored - const onChange = (val: boolean) => { - item.ignored = val - callback?.() - } - const switchNode = h(ElSwitch, { style: switchStyle, modelValue, onChange }) - - const tooltipNode = h(ElTooltip, { content: t(msg => msg.limit.useWildcard) }, { default: () => switchNode }) - result.push(tooltipNode) - } - result.push(h('span', {}, item.origin)) - return result -} - -const tabStyle: Partial = { - marginBottom: '5px', - marginRight: '0', -} - -const item2Tag = (item: UrlPart, index: number, arr: UrlPart[], onChange: Function) => { - const isNotHost: boolean = !!index - return h(ElTag, { - type: isNotHost ? '' : 'info', - closable: isNotHost, - onClose: () => { - arr.splice(index) - onChange?.() - }, - style: tabStyle - }, () => tagContent(item, index, onChange)) -} -const combineStyle = { - fontSize: '14px', - margin: '0 2px', - ...tabStyle -} -const combineTags = (arr: VNode[], current: VNode) => { - arr.length && arr.push(h('span', { style: combineStyle }, '/')) - arr.push(current) - return arr -} - -function url2PathItems(url: string): UrlPart[] { - return url.split('/').filter(path => path).map(path => ({ origin: path, ignored: path === '*' })) -} - -function pathItems2Url(pathItems: UrlPart[]): string { - return pathItems.map(i => i.ignored ? '*' : i.origin || '').join('/') -} - -const _default = defineComponent({ - name: 'LimitPathEdit', - props: { - url: { - type: String, - required: false, - defaultValue: '' - }, - }, - emits: { - urlChange: (_url: string) => true - }, - setup(props, ctx) { - const url = props.url - const items: Ref = ref(url2PathItems(url)) - ctx.expose({ - forceUpdateUrl(url: string) { - items.value = url2PathItems(url) - } - }) - const handleUrlChange = () => ctx.emit('urlChange', pathItems2Url(items.value)) - return () => h('div', {}, items.value - .map((item, index, arr) => item2Tag(item, index, arr, handleUrlChange)) - .reduce(combineTags, []) - ) - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/index.ts b/src/app/components/limit/modify/index.ts index 9f5e47b2..185308fa 100644 --- a/src/app/components/limit/modify/index.ts +++ b/src/app/components/limit/modify/index.ts @@ -7,73 +7,77 @@ import { ElDialog, ElMessage } from "element-plus" import { defineComponent, h, nextTick, ref, Ref } from "vue" -import Form from "./form" -import Footer from "./footer" -import LimitDatabase from "@db/limit-database" +import Sop, { SopInstance } from "./sop" +import limitService from "@service/limit-service" import { t } from "@app/locale" +import "./style/el-input.sass" +import "./style/sop.sass" -const db = new LimitDatabase(chrome.storage.local) +export type ModifyInstance = { + create(): void + modify(row: timer.limit.Item): void +} + +type Mode = "create" | "modify" -const noUrlError = t(msg => msg.limit.message.noUrl) -const noTimeError = t(msg => msg.limit.message.noTime) const _default = defineComponent({ emits: { save: (_saved: timer.limit.Rule) => true }, setup: (_, ctx) => { const visible: Ref = ref(false) - const form: Ref = ref() + const sop: Ref = ref() const mode: Ref = ref() // Cache - let modifyingItem: timer.limit.Item = undefined + let modifyingItem: timer.limit.Rule = undefined + + const onSave = async (rule: timer.limit.Rule) => { + const { cond, time, visitTime, periods } = rule + const toSave: timer.limit.Rule = { cond, time, visitTime, periods, enabled: true, allowDelay: false } + if (mode.value === 'modify' && modifyingItem) { + toSave.enabled = modifyingItem.enabled + toSave.allowDelay = modifyingItem.allowDelay + } + if (mode.value === "modify") { + await limitService.update(toSave) + } else { + await limitService.create(toSave) + } + visible.value = false + ElMessage.success(t(msg => msg.limit.message.saved)) + sop.value?.clean?.() + ctx.emit("save", toSave) + } - ctx.expose({ + const onClose = () => visible.value = false + + const instance: ModifyInstance = { create() { visible.value = true mode.value = 'create' modifyingItem = undefined - nextTick(() => form.value?.clean?.()) + nextTick(() => sop.value?.clean?.()) }, modify(row: timer.limit.Item) { visible.value = true mode.value = 'modify' modifyingItem = { ...row } - nextTick(() => form.value?.modify?.(row)) + nextTick(() => sop.value?.modify?.(row)) }, - hide: () => visible.value = false - }) + } + + ctx.expose(instance) return () => h(ElDialog, { title: t(msg => msg.limit.addTitle), modelValue: visible.value, closeOnClickModal: false, - onClose: () => visible.value = false - }, { - default: () => h(Form, { ref: form }), - footer: () => h(Footer, { - async onSave() { - const { condition, timeLimit }: FormInfo = form.value?.getData?.() - if (!condition) { - ElMessage.warning(noUrlError) - return - } - if (!timeLimit) { - ElMessage.warning(noTimeError) - return - } - const toSave: timer.limit.Rule = { cond: condition, time: timeLimit, enabled: true, allowDelay: false } - if (mode.value === 'modify' && modifyingItem) { - toSave.enabled = modifyingItem.enabled - toSave.allowDelay = modifyingItem.allowDelay - } - await db.save(toSave, mode.value === 'modify') - visible.value = false - ElMessage.success(t(msg => msg.limit.message.saved)) - form.value?.clean?.() - ctx.emit("save", toSave) - } - }) - }) + onClose + }, () => h(Sop, { + ref: sop, + onSave, + onCancel: onClose, + })) } }) diff --git a/src/app/components/limit/modify/modify.d.ts b/src/app/components/limit/modify/modify.d.ts deleted file mode 100644 index a5ece4c0..00000000 --- a/src/app/components/limit/modify/modify.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare type Mode = 'modify' | 'create' \ No newline at end of file diff --git a/src/app/components/limit/modify/sop/common.ts b/src/app/components/limit/modify/sop/common.ts new file mode 100644 index 00000000..8170040b --- /dev/null +++ b/src/app/components/limit/modify/sop/common.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +/** + * The protocol of rule host + */ +export type Protocol = + | 'http://' + | 'https://' + | '*://' + +export type UrlInfo = { + protocol: Protocol + url: string +} + +export type UrlPart = { + /** + * The origin part text + */ + origin: string + /** + * Whether to replace with wildcard + */ + ignored: boolean +} + +export function parseUrl(url: string): UrlInfo { + if (!url) { + return { protocol: null, url: null } + } + let protocol: Protocol = '*://' + + url = decodeURI(url)?.trim() + if (url.startsWith('http://')) { + protocol = 'http://' + url = url.substring(protocol.length) + } else if (url.startsWith('https://')) { + protocol = 'https://' + url = url.substring(protocol.length) + } else if (url.startsWith('*://')) { + protocol = '*://' + url = url.substring(protocol.length) + } + return { protocol, url } +} \ No newline at end of file diff --git a/src/app/components/limit/modify/sop/index.ts b/src/app/components/limit/modify/sop/index.ts new file mode 100644 index 00000000..b4f6dc52 --- /dev/null +++ b/src/app/components/limit/modify/sop/index.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { ElStep, ElSteps } from "element-plus" +import { Ref, defineComponent, h, nextTick, reactive, ref, toRaw } from "vue" +import Step1 from "./step1" +import Step2, { RuleFormData } from "./step2" + +type Step = 0 | 1 + +export type SopInstance = { + clean: () => void + modify: (rule: timer.limit.Rule) => void +} + +const createInitial = (): timer.limit.Rule => ({ + time: 3600, + cond: "*://", + visitTime: undefined, + periods: undefined, +}) + +const _default = defineComponent({ + props: { + condDisabled: Boolean, + }, + emits: { + cancel: () => true, + save: (_rule: timer.limit.Rule) => true, + }, + setup({ }, ctx) { + const step: Ref = ref(0) + const data = reactive(createInitial()) + const condDisabled: Ref = ref(false) + + const instance: SopInstance = { + clean: () => { + const initial = createInitial() + Object.entries(initial).forEach(([k, v]) => data[k] = v) + condDisabled.value = false + step.value = 0 + }, + modify: rule => { + Object.entries(rule).forEach(([k, v]) => data[k] = v) + condDisabled.value = true + step.value = 1 + } + } + + ctx.expose(instance) + + const restoreData = (f: RuleFormData) => { + const { time, periods, visitTime } = f || {} + data.time = time ? time : undefined + data.periods = periods?.length ? periods : undefined + data.visitTime = visitTime ? visitTime : undefined + } + + return () => h('div', { class: 'sop-dialog-container' }, [ + h('div', { class: 'step-container' }, h(ElSteps, { + space: 200, + finishStatus: 'success', + active: step.value, + }, () => [ + h(ElStep, { title: t(msg => msg.limit.step1) }), + h(ElStep, { title: t(msg => msg.limit.step2) }), + ])), + h('div', { class: 'operation-container' }, step.value === 0 + ? h(Step1, { + defaultValue: data?.cond, + disabled: condDisabled.value, + onCancel: () => ctx.emit('cancel'), + onNext: (cond: string) => { + step.value = 1 + !condDisabled.value && (data.cond = cond) + }, + }) + : h(Step2, { + rule: data, + onBack: f => { + restoreData(f) + step.value = 0 + }, + onSave: f => { + restoreData(f) + nextTick(() => ctx.emit('save', toRaw(data))) + }, + }) + ), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/sop/path-edit.ts b/src/app/components/limit/modify/sop/path-edit.ts new file mode 100644 index 00000000..2deefcb8 --- /dev/null +++ b/src/app/components/limit/modify/sop/path-edit.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElSwitch, ElTag, ElTooltip } from "element-plus" +import { defineComponent, h, PropType, reactive, ref, VNode, watch } from "vue" +import { t } from "@app/locale" +import { UrlPart } from "./common" + +const switchStyle: Partial = { marginRight: '2px' } + +const tabStyle: Partial = { + marginBottom: '5px', + marginRight: '0', +} + +const ItemTag = defineComponent({ + props: { + part: Object as PropType, + }, + emits: { + close: () => true, + change: (_p: UrlPart) => true, + }, + setup({ part: { origin, ignored } = { origin: "", ignored: false } }, ctx) { + const myIgnored = ref(ignored) + + watch(myIgnored, () => ctx.emit("change", { ignored: myIgnored.value, origin })) + + return () => h(ElTag, { + type: 'info', + closable: true, + onClose: () => ctx.emit("close"), + style: tabStyle + }, () => [ + h(ElTooltip, { + content: t(msg => msg.limit.useWildcard) + }, { + default: () => h(ElSwitch, { + style: switchStyle, + modelValue: myIgnored.value, + onChange: (newVal: boolean) => myIgnored.value = newVal + }) + }), + h('span', {}, origin), + ]) + } +}) + +const item2Tag = (item: UrlPart, index: number, arr: UrlPart[]) => { + const isNotHost: boolean = !!index + return isNotHost + ? h(ItemTag, { part: item, onClose: () => arr.splice(index), onChange: p => arr[index] = p }) + : h(ElTag, { style: tabStyle }, () => h('span', item.origin)) +} + +const combineStyle = { + fontSize: '14px', + margin: '0 2px', + ...tabStyle +} + +const combineTags = (arr: VNode[], current: VNode) => { + arr.length && arr.push(h('span', { style: combineStyle }, '/')) + arr.push(current) + return arr +} + +function url2PathItems(url: string): UrlPart[] { + return url.split('/').filter(path => path).map(path => ({ origin: path, ignored: path === '*' })) +} + +function pathItems2Url(pathItems: UrlPart[]): string { + return pathItems.map(i => i.ignored ? '*' : i.origin || '').join('/') +} + +export type PathEditInstance = { + updateUrl: (url: string) => void +} + +const _default = defineComponent({ + name: 'LimitPathEdit', + props: { + url: { + type: String, + required: false, + defaultValue: '' + }, + }, + emits: { + urlChange: (_url: string) => true + }, + setup({ url }, ctx) { + const items = reactive(url2PathItems(url)) + watch(items, () => ctx.emit('urlChange', pathItems2Url(items))) + + const instance: PathEditInstance = { + updateUrl: url => { + items.splice(0, items.length) + const newItems = url2PathItems(url) + items.push(...newItems) + } + } + + ctx.expose(instance) + + return () => h('div', {}, items + .map((item, index, arr) => item2Tag(item, index, arr)) + .reduce(combineTags, []) + ) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/sop/period.ts b/src/app/components/limit/modify/sop/period.ts new file mode 100644 index 00000000..b62acb87 --- /dev/null +++ b/src/app/components/limit/modify/sop/period.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { Check, Close, Plus } from "@element-plus/icons-vue" +import { ElButton, ElFormItem, ElTag, ElTimePicker } from "element-plus" +import { PropType, defineComponent, h, reactive, ref } from "vue" +import { checkImpact, mergePeriod, period2Str } from "@util/limit" + +const date2Idx = (date: Date): number => { + const hour = date.getHours() + const min = date.getMinutes() + return hour * 60 + min +} + +const range2Period = (range: [Date, Date]): [number, number] => { + const start = range?.[0] + const end = range?.[1] + if (start === undefined || end === undefined) { + return undefined + } + const startIdx = date2Idx(start) + const endIdx = date2Idx(end) + return [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)] +} + +const PeriodInput = defineComponent({ + emits: { + close: () => true, + save: (_p: timer.limit.Period) => true, + }, + setup(_, ctx) { + const range = ref<[Date, Date]>() + const handleSave = () => { + const val = range2Period(range.value) + val && ctx.emit("save", val) + } + return () => h('div', { class: "limit-period-input" }, [ + h(ElTimePicker, { + modelValue: range.value, + "onUpdate:modelValue": newVal => range.value = newVal, + popperClass: "limit-period-time-picker-popper", + isRange: true, + rangeSeparator: '-', + format: "HH:mm", + clearable: false, + }), + h(ElButton, { + icon: Close, + onClick: () => ctx.emit("close"), + }), + h(ElButton, { + icon: Check, + onClick: handleSave, + }), + ]) + } +}) + +const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Period) => { + if (!toInsert || !periods) return + let len = periods.length + if (!len) { + periods.push(toInsert) + return + } + for (let i = 0; i < len; i++) { + const pre = periods[i] + const next = periods[i + 1] + if (checkImpact(pre, toInsert)) { + mergePeriod(pre, toInsert) + if (checkImpact(pre, next)) { + mergePeriod(pre, next) + periods.splice(i + 1, 1) + } + return + } + if (checkImpact(toInsert, next)) { + mergePeriod(next, toInsert) + return + } + } + // Append + periods.push(toInsert) + periods.sort((a, b) => a[0] - b[0]) +} + +const _default = defineComponent({ + props: { + modelValue: Array as PropType + }, + setup({ modelValue }) { + const periods = reactive(modelValue || []) + const periodEditing = ref(false) + + return () => h(ElFormItem, { + label: t(msg => msg.limit.item.period), + }, () => h('div', { class: "period-form-item-container" }, [ + ...periods?.map((p, index) => h(ElTag, { + closable: true, + size: "small", + onClose: () => periods.splice(index, 1) + }, () => period2Str(p))), + periodEditing.value + ? h(PeriodInput, { + onClose: () => periodEditing.value = false, + onSave: p => { + console.log(p) + insertPeriods(periods, p) + periodEditing.value = false + }, + }) + : h(ElButton, { + icon: Plus, + size: "small", + onClick: () => periodEditing.value = true, + }, () => t(msg => msg.button.create)), + ])) + }, +}) + + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/sop/step1.ts b/src/app/components/limit/modify/sop/step1.ts new file mode 100644 index 00000000..8c74c6c3 --- /dev/null +++ b/src/app/components/limit/modify/sop/step1.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { Close, Right } from "@element-plus/icons-vue" +import { ElButton, ElForm, ElMessage } from "element-plus" +import { Ref, defineComponent, h, ref, watch } from "vue" +import { Protocol, parseUrl } from "./common" +import LimitUrlFormItem from "./url" +import LimitPathEdit, { PathEditInstance } from "./path-edit" + +const _default = defineComponent({ + props: { + defaultValue: { + type: String, + required: true, + }, + disabled: Boolean, + }, + emits: { + cancel: () => true, + next: (_cond: string) => true, + }, + setup({ disabled, defaultValue }, ctx) { + const { protocol, url } = parseUrl(defaultValue) + const protocalRef: Ref = ref(protocol) + const pathEditRef: Ref = ref() + const urlRef: Ref = ref(url) + + watch(() => defaultValue, () => { + const { protocol, url } = parseUrl(defaultValue) + protocalRef.value = protocol + urlRef.value = url + }) + + const handleNext = () => { + let cond = defaultValue + if (!disabled) { + const url = urlRef.value?.trim?.() + if (!url) { + return ElMessage.error(t(msg => msg.limit.message.noUrl)) + } + const protocol = protocalRef.value + cond = url ? protocol + url : '' + } + ctx.emit("next", cond) + } + + return () => { + const items = [ + h(ElForm, + { labelWidth: 180, labelPosition: "left" }, + () => h(LimitUrlFormItem, { + url: urlRef.value, + protocol: protocalRef.value, + disabled: disabled, + onUrlChange: (newUrl: string) => { + urlRef.value = newUrl + pathEditRef.value?.updateUrl?.(newUrl) + }, + onProtocolChange: (newProtocol: Protocol) => protocalRef.value = newProtocol, + }), + ), + ] + + !disabled && items.push(h(LimitPathEdit, { + ref: pathEditRef, + url: urlRef.value, + onUrlChange: (newVal: string) => urlRef.value = newVal + })) + items.push( + h('div', { class: 'sop-footer' }, [ + h(ElButton, { + type: 'info', + icon: Close, + onClick: () => ctx.emit('cancel'), + }, () => t(msg => msg.button.cancel)), + h(ElButton, { + type: 'primary', + icon: Right, + onClick: handleNext, + }, () => t(msg => msg.button.next)), + ]) + ) + return items + } + } +}) + +export default _default diff --git a/src/app/components/limit/modify/sop/step2.ts b/src/app/components/limit/modify/sop/step2.ts new file mode 100644 index 00000000..1cdd1570 --- /dev/null +++ b/src/app/components/limit/modify/sop/step2.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { Back, Check } from "@element-plus/icons-vue" +import { ElButton, ElForm, ElMessage } from "element-plus" +import { PropType, Ref, computed, defineComponent, h, ref } from "vue" +import LimitTimeFormItem from "./time-limit" +import LimitPeriodFormItem from "./period" + +export type RuleFormData = Pick + +const _default = defineComponent({ + props: { + rule: Object as PropType + }, + emits: { + back: (_d: RuleFormData) => true, + save: (_d: RuleFormData) => true, + }, + setup({ rule }, ctx) { + const time: Ref = ref(rule?.time) + const visitTime: Ref = ref(rule?.visitTime) + const periods: Ref<[number, number][]> = ref(rule?.periods || []) + const formInfo = computed(() => ({ time: time.value, visitTime: visitTime.value, periods: periods.value } as RuleFormData)) + + const handleSaveClick = () => { + const value = formInfo.value + const { time, visitTime, periods } = value || {} + if (!time && !visitTime && !periods?.length) { + ElMessage.error(t(msg => msg.limit.message.noRule)) + return + } + ctx.emit("save", value) + } + + return () => [ + h(ElForm, { labelWidth: 180, labelPosition: "left" }, () => [ + h(LimitTimeFormItem, { + modelValue: time.value, + label: t(msg => msg.limit.item.time), + onChange: (newVal: number) => time.value = newVal, + }), + h(LimitTimeFormItem, { + modelValue: visitTime.value, + label: t(msg => msg.limit.item.visitTime), + onChange: (newVal: number) => visitTime.value = newVal, + }), + h(LimitPeriodFormItem, { modelValue: periods.value }), + ]), + h('div', { class: 'sop-footer' }, [ + h(ElButton, { + type: 'info', + icon: Back, + onClick: () => ctx.emit('back', formInfo.value), + }, () => t(msg => msg.button.previous)), + h(ElButton, { + type: 'success', + icon: Check, + onClick: handleSaveClick + }, () => t(msg => msg.button.save)), + ]), + ] + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/time-limit.ts b/src/app/components/limit/modify/sop/time-limit.ts similarity index 68% rename from src/app/components/limit/modify/form/time-limit.ts rename to src/app/components/limit/modify/sop/time-limit.ts index 14a5042a..33568008 100644 --- a/src/app/components/limit/modify/form/time-limit.ts +++ b/src/app/components/limit/modify/sop/time-limit.ts @@ -7,9 +7,8 @@ import type { ComputedRef, Ref } from "vue" -import { ElCol, ElFormItem, ElInput, ElRow } from "element-plus" +import { ElFormItem, ElInput } from "element-plus" import { defineComponent, h, computed, ref, watch } from "vue" -import { t } from "@app/locale" const handleInput = (inputVal: string, ref: Ref, maxVal: number) => { inputVal = inputVal?.trim?.() @@ -24,18 +23,16 @@ const handleInput = (inputVal: string, ref: Ref, maxVal: number) => { ref.value = num } -const timeInput = (ref: Ref, unit: string, maxVal: number) => h(ElCol, { span: 8 }, - () => h(ElInput, { - modelValue: ref.value, - clearable: true, - onInput: (val: string) => handleInput(val, ref, maxVal), - onClear: () => ref.value = undefined, - placeholder: '0', - class: 'limit-modify-time-limit-input' - }, { - append: () => unit - }) -) +const timeInput = (ref: Ref, unit: string, maxVal: number) => h(ElInput, { + modelValue: ref.value, + clearable: true, + onInput: (val: string) => handleInput(val, ref, maxVal), + onClear: () => ref.value = undefined, + placeholder: '0', + class: 'limit-modify-time-limit-input' +}, { + append: () => unit +}) function computeSecond2LimitInfo(time: number): [number, number, number] { time = time || 0 @@ -54,22 +51,36 @@ function computeLimitInfo2Second(hourRef: Ref, minuteRef: Ref, s return time } +const LIMIT_STYLE: Partial = { + display: "flex", + justifyContent: "space-between", + width: "100%", + gap: "15px", +} + const _default = defineComponent({ - name: "LimitTimeLimit", props: { modelValue: { type: Number + }, + label: { + type: String, + required: true, + }, + required: { + type: Boolean, + required: false, } }, emits: { change: (_val: number) => true }, - setup(props, ctx) { - const [hour, minute, second] = computeSecond2LimitInfo(props.modelValue) + setup({ modelValue, label, required = false }, ctx) { + const [hour, minute, second] = computeSecond2LimitInfo(modelValue) const hourRef: Ref = ref(hour) const minuteRef: Ref = ref(minute) const secondRef: Ref = ref(second) - watch(() => props.modelValue, (newVal: number) => { + watch(() => modelValue, (newVal: number) => { const [hour, minute, second] = computeSecond2LimitInfo(newVal) hourRef.value = hour minuteRef.value = minute @@ -78,11 +89,12 @@ const _default = defineComponent({ const limitTime: ComputedRef = computed(() => computeLimitInfo2Second(hourRef, minuteRef, secondRef)) watch([hourRef, minuteRef, secondRef], () => ctx.emit('change', limitTime.value)) return () => h(ElFormItem, { - label: t(msg => msg.limit.item.time) - }, () => h(ElRow, { gutter: 10 }, () => [ + label, + required, + }, () => h('div', { style: LIMIT_STYLE }, [ timeInput(hourRef, 'H', 23), timeInput(minuteRef, 'M', 59), - timeInput(secondRef, 'S', 59) + timeInput(secondRef, 'S', 59), ])) } }) diff --git a/src/app/components/limit/modify/form/url.ts b/src/app/components/limit/modify/sop/url.ts similarity index 93% rename from src/app/components/limit/modify/form/url.ts rename to src/app/components/limit/modify/sop/url.ts index be5c7c0a..0fb2cd5c 100644 --- a/src/app/components/limit/modify/form/url.ts +++ b/src/app/components/limit/modify/sop/url.ts @@ -11,7 +11,7 @@ import { t } from "@app/locale" import { ElButton, ElFormItem, ElInput, ElOption, ElSelect } from "element-plus" import { checkPermission, requestPermission } from "@api/chrome/permissions" import { IS_FIREFOX } from "@util/constant/environment" -import { parseUrl } from "./common" +import { parseUrl, Protocol, UrlPart } from "./common" import { AUTHOR_EMAIL } from "@src/package" const ALL_PROTOCOLS: Protocol[] = ['http://', 'https://', '*://'] @@ -45,6 +45,7 @@ function slots(protocolRef: Ref, urlRef: Ref, disabled: boolea modelValue: protocolRef.value, onChange: (val: string) => protocolRef.value = val as Protocol, disabled: disabled, + style: { width: "90px" } }, protocolOptions), } !disabled && (slots.append = () => h(ElButton, { @@ -86,7 +87,6 @@ const pasteButtonText = t(msg => msg.button.paste) const placeholder = t(msg => msg.limit.urlPlaceholder) const _default = defineComponent({ - name: 'LimitUrlFormItem', emits: { urlChange: (_val: string) => true, protocolChange: (_val: Protocol) => true, @@ -96,7 +96,7 @@ const _default = defineComponent({ protocol: String as PropType, disabled: { type: Boolean, - defaultValue: false + defaultValue: false, } }, setup(props, ctx) { @@ -109,7 +109,10 @@ const _default = defineComponent({ watch(urlRef, () => ctx.emit('urlChange', urlRef.value)) watch(() => props.url, () => urlRef.value = props.url) - return () => h(ElFormItem, { label: t(msg => msg.limit.item.condition) }, + return () => h(ElFormItem, { + label: t(msg => msg.limit.item.condition), + required: true, + }, () => { const slots_: _Slots = slots(protocolRef, urlRef, props.disabled) return h(ElInput, { @@ -120,6 +123,7 @@ const _default = defineComponent({ urlRef.value = '' ctx.emit('urlChange', '') }, + class: "limit-time-url-input", // Disabled this input in the css to customized the styles // @see ../style/el-input.sass // @see this.onInput diff --git a/src/app/components/limit/modify/style/el-input.sass b/src/app/components/limit/modify/style/el-input.sass index b9d83e89..1153403e 100644 --- a/src/app/components/limit/modify/style/el-input.sass +++ b/src/app/components/limit/modify/style/el-input.sass @@ -7,13 +7,12 @@ $prefixWidth: 110px -.el-input--prefix>.el-input__inner - padding-left: $prefixWidth - margin-left: 10px - cursor: not-allowed !important - // Hide the cursor, but show the text - color: transparent - text-shadow: 0 0 0 #606266 +.limit-time-url-input + .el-input__wrapper>.el-input__inner + // Hide the cursor, but show the text + cursor: not-allowed !important + color: transparent + text-shadow: 0 0 0 var(--el-text-color-regular) .limit-modify-time-limit-input .el-input__suffix // Fix shaking of clear button diff --git a/src/app/components/limit/modify/style/sop.sass b/src/app/components/limit/modify/style/sop.sass new file mode 100644 index 00000000..f010242c --- /dev/null +++ b/src/app/components/limit/modify/style/sop.sass @@ -0,0 +1,30 @@ +.period-form-item-container + height: 50px + display: flex + width: 100% + padding: 6px 0 + .el-tag + height: 28px + .limit-period-input + display: inline-flex + .el-date-editor + width: 120px !important + height: 28px !important + padding: 0 5px !important + border-top-right-radius: 0px + border-bottom-right-radius: 0px + .el-range__close-icon + width: 0px + .el-range-input + height: 28px + .el-button + height: 28px + width: 28px + line-height: 28px + .el-button:not(:last-child) + border-radius: 0px + .el-button:last-child + border-top-left-radius: 0px + border-bottom-left-radius: 0px + .el-button+.el-button + margin-left: 0px diff --git a/src/app/components/limit/table/column/common.ts b/src/app/components/limit/table/column/common.ts index c7c58cec..dcc70a4b 100644 --- a/src/app/components/limit/table/column/common.ts +++ b/src/app/components/limit/table/column/common.ts @@ -1,7 +1,9 @@ +import { sendMsg2Runtime } from "@api/chrome/runtime" import { t, tN } from "@app/locale" import { locale } from "@i18n" import { VerificationPair } from "@service/limit-service/verification/common" import verificationProcessor from "@service/limit-service/verification/processor" +import { date2Idx, hasLimited } from "@util/limit" import { getCssVariable } from "@util/style" import { ElMessageBox, ElMessage } from "element-plus" import { defineComponent, h, onMounted, ref, VNode } from "vue" @@ -11,9 +13,25 @@ import { defineComponent, h, onMounted, ref, VNode } from "vue" * * @returns T/F */ -export function judgeVerificationRequired(item: timer.limit.Item): boolean { - const { waste, time } = item || {} - return !!(waste > time * 1000) +export async function judgeVerificationRequired(item: timer.limit.Item): Promise { + const { visitTime, periods, enabled } = item || {} + if (!enabled) return false + // Daily + if (hasLimited(item)) return true + // Period + if (periods?.length) { + const idx = date2Idx(new Date()) + const hitPeriod = periods?.find(([s, e]) => s <= idx && e >= idx) + if (hitPeriod) return true + } + // Visit + if (visitTime) { + console.log("visiTime", visitTime) + const hitVisit = await sendMsg2Runtime("askHitVisit", item) + console.log("visiTimeHit", hitVisit) + if (hitVisit) return true + } + return false } const PROMT_TXT_CSS: Partial = { @@ -106,7 +124,7 @@ export async function processVerification(option: timer.option.DailyLimitOption) return resolve() } ElMessage.error(incrorectMessage) - }) + }).catch(() => { }) ) : null } diff --git a/src/app/components/limit/table/column/delay.ts b/src/app/components/limit/table/column/delay.ts index 49418b2d..a12b7bf2 100644 --- a/src/app/components/limit/table/column/delay.ts +++ b/src/app/components/limit/table/column/delay.ts @@ -7,7 +7,7 @@ import { InfoFilled } from "@element-plus/icons-vue" import { ElIcon, ElSwitch, ElTableColumn, ElTooltip } from "element-plus" -import { defineComponent, h } from "vue" +import { defineComponent, h, toRaw } from "vue" import { t } from "@app/locale" import { judgeVerificationRequired, processVerification } from "./common" import optionService from "@service/option-service" @@ -17,7 +17,7 @@ const tooltip = t(msg => msg.limit.item.delayAllowedInfo) async function handleChange(row: timer.limit.Item, newVal: boolean, callback: () => void) { let promise: Promise = null - if (newVal && judgeVerificationRequired(row)) { + if (newVal && await judgeVerificationRequired(row)) { // Open delay for limited rules, so verification is required const option = await optionService.getAllOption() promise = processVerification(option) @@ -42,7 +42,7 @@ const _default = defineComponent({ modelValue: row.allowDelay, onChange: (val: boolean) => handleChange(row, val, () => { row.allowDelay = val - ctx.emit("rowChange", row, val) + ctx.emit("rowChange", toRaw(row), val) }) }), header: () => h('div', [ diff --git a/src/app/components/limit/table/column/enabled.ts b/src/app/components/limit/table/column/enabled.ts index 6e690fd7..ac7939bc 100644 --- a/src/app/components/limit/table/column/enabled.ts +++ b/src/app/components/limit/table/column/enabled.ts @@ -6,7 +6,7 @@ */ import { ElSwitch, ElTableColumn } from "element-plus" -import { defineComponent, h } from "vue" +import { defineComponent, h, toRaw } from "vue" import { t } from "@app/locale" import { judgeVerificationRequired, processVerification } from "./common" import optionService from "@service/option-service" @@ -15,7 +15,7 @@ const label = t(msg => msg.limit.item.enabled) async function handleChange(row: timer.limit.Item, newVal: boolean, callback: () => void) { let promise: Promise = null - if (!newVal && judgeVerificationRequired(row)) { + if (!newVal && await judgeVerificationRequired(row)) { // Disable limited rules, so verification is required const option = await optionService.getAllOption() promise = processVerification(option) @@ -41,7 +41,7 @@ const _default = defineComponent({ modelValue: row.enabled, onChange: (val: boolean) => handleChange(row, val, () => { row.enabled = val - ctx.emit("rowChange", row, val) + ctx.emit("rowChange", toRaw(row), val) }) }) }) diff --git a/src/app/components/limit/table/column/operation.ts b/src/app/components/limit/table/column/operation.ts index d34a9712..5dc5f906 100644 --- a/src/app/components/limit/table/column/operation.ts +++ b/src/app/components/limit/table/column/operation.ts @@ -18,7 +18,7 @@ const modifyButtonText = t(msg => msg.button.modify) async function handleDelete(row: timer.limit.Item, callback: () => void) { let promise = undefined - if (judgeVerificationRequired(row)) { + if (await judgeVerificationRequired(row)) { const option = await optionService.getAllOption() as timer.option.DailyLimitOption promise = processVerification(option) } @@ -31,7 +31,7 @@ async function handleDelete(row: timer.limit.Item, callback: () => void) { async function handleModify(row: timer.limit.Item, callback: () => void) { let promise: Promise = undefined - if (judgeVerificationRequired(row)) { + if (await judgeVerificationRequired(row)) { const option = await optionService.getAllOption() as timer.option.DailyLimitOption promise = processVerification(option) promise @@ -51,7 +51,7 @@ const _default = defineComponent({ return () => h(ElTableColumn, { prop: 'operations', label, - minWidth: 200, + width: 200, align: 'center', }, { default: ({ row }: { row: timer.limit.Item }) => [ diff --git a/src/app/components/limit/table/column/period.ts b/src/app/components/limit/table/column/period.ts new file mode 100644 index 00000000..0cd1e4df --- /dev/null +++ b/src/app/components/limit/table/column/period.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElTableColumn } from "element-plus" +import { defineComponent, h } from "vue" +import { t } from "@app/locale" +import { period2Str } from "@util/limit" + +const label = t(msg => msg.limit.item.period) + +const SPAN_STYLE: Partial = { + display: "block" +} + +const _default = defineComponent({ + setup() { + return () => h(ElTableColumn, { + label, + align: "center", + minWidth: 100, + }, { + default: ({ row }: { row: timer.limit.Item }) => { + const periods = row?.periods + if (!periods?.length) return "-" + const tags = periods.map(p => h("span", { style: SPAN_STYLE }, period2Str(p))) + return h('div', {}, tags) + } + }) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/table/column/time.ts b/src/app/components/limit/table/column/time.ts index 6d6d3bdf..a42a076e 100644 --- a/src/app/components/limit/table/column/time.ts +++ b/src/app/components/limit/table/column/time.ts @@ -17,10 +17,10 @@ const _default = defineComponent({ render: () => h(ElTableColumn, { prop: 'limit', label, - minWidth: 100, + minWidth: 90, align: 'center', }, { - default: ({ row }: { row: timer.limit.Item }) => h('span', formatPeriodCommon(row.time * 1000)) + default: ({ row }: { row: timer.limit.Item }) => row?.time ? h('span', formatPeriodCommon(row.time * 1000)) : '-' }) }) diff --git a/src/app/components/limit/table/column/visit.ts b/src/app/components/limit/table/column/visit.ts new file mode 100644 index 00000000..3389d824 --- /dev/null +++ b/src/app/components/limit/table/column/visit.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElTableColumn } from "element-plus" +import { defineComponent, h } from "vue" +import { formatPeriodCommon } from "@util/time" +import { t } from "@app/locale" + +const label = t(msg => msg.limit.item.visitTime) + +const _default = defineComponent({ + render: () => h(ElTableColumn, { + label, + minWidth: 90, + align: 'center', + }, { + default: ({ row }: { row: timer.limit.Item }) => { + const visitTime = row?.visitTime + return visitTime ? h('span', formatPeriodCommon(row.visitTime * 1000)) : '-' + } + }) +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/limit/table/column/waste.ts b/src/app/components/limit/table/column/waste.ts index 17bbfa58..44f51542 100644 --- a/src/app/components/limit/table/column/waste.ts +++ b/src/app/components/limit/table/column/waste.ts @@ -16,7 +16,7 @@ const _default = defineComponent({ render: () => h(ElTableColumn, { prop: 'waste', label, - minWidth: 100, + minWidth: 90, align: 'center', }, { default: ({ row }: { row: timer.limit.Item }) => h('span', formatPeriodCommon(row.waste)) diff --git a/src/app/components/limit/table/index.ts b/src/app/components/limit/table/index.ts index ff55803a..d1ee945b 100644 --- a/src/app/components/limit/table/index.ts +++ b/src/app/components/limit/table/index.ts @@ -13,6 +13,8 @@ import LimitWasteColumn from "./column/waste" import LimitDelayColumn from "./column/delay" import LimitEnabledColumn from "./column/enabled" import LimitOperationColumn from "./column/operation" +import LimitVisitTimeColumn from "./column/visit" +import LimitPeriodColumn from "./column/period" const _default = defineComponent({ props: { @@ -36,6 +38,8 @@ const _default = defineComponent({ h(LimitCondColumn), h(LimitTimeColumn), h(LimitWasteColumn), + h(LimitVisitTimeColumn), + h(LimitPeriodColumn), h(LimitDelayColumn, { onRowChange: (row: timer.limit.Item) => ctx.emit("delayChange", row) }), diff --git a/src/app/components/limit/test.ts b/src/app/components/limit/test.ts index ced70be6..544b3a7e 100644 --- a/src/app/components/limit/test.ts +++ b/src/app/components/limit/test.ts @@ -10,6 +10,10 @@ import limitService from "@service/limit-service" import { ElAlert, ElButton, ElDialog, ElFormItem, ElInput } from "element-plus" import { defineComponent, Ref, ref, h, ComputedRef, computed } from "vue" +export type TestInstance = { + show(): void +} + async function handleTest(url: string): Promise { const items = await limitService.select({ url, filterDisabled: true }) return items.map(v => v.cond) @@ -45,55 +49,59 @@ function computeResultType(url: string, inputting: boolean, matchedCondition: st return matchedCondition?.length ? 'success' : 'warning' } -const _default = defineComponent((_props, ctx) => { - const urlRef: Ref = ref() - const matchedConditionRef: Ref = ref([]) - const visible: Ref = ref(false) - const urlInputtingRef: Ref = ref(true) - const resultTitleRef: ComputedRef = computed(() => computeResultTitle(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) - const resultTypeRef: ComputedRef<_ResultType> = computed(() => computeResultType(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) - const resultDescRef: ComputedRef = computed(() => computeResultDesc(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) +const _default = defineComponent({ + setup(_props, ctx) { + const urlRef: Ref = ref() + const matchedConditionRef: Ref = ref([]) + const visible: Ref = ref(false) + const urlInputtingRef: Ref = ref(true) + const resultTitleRef: ComputedRef = computed(() => computeResultTitle(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) + const resultTypeRef: ComputedRef<_ResultType> = computed(() => computeResultType(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) + const resultDescRef: ComputedRef = computed(() => computeResultDesc(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) - const changeInput = (newVal: string) => (urlInputtingRef.value = true) && (urlRef.value = newVal?.trim()) - const test = () => { - urlInputtingRef.value = false - handleTest(urlRef.value).then(matched => matchedConditionRef.value = matched) - } + const changeInput = (newVal: string) => (urlInputtingRef.value = true) && (urlRef.value = newVal?.trim()) + const test = () => { + urlInputtingRef.value = false + handleTest(urlRef.value).then(matched => matchedConditionRef.value = matched) + } - ctx.expose({ - show() { - urlRef.value = '' - visible.value = true - urlInputtingRef.value = true - matchedConditionRef.value = [] + const instance: TestInstance = { + show() { + urlRef.value = '' + visible.value = true + urlInputtingRef.value = true + matchedConditionRef.value = [] + } } - }) - return () => h(ElDialog, { - title: t(msg => msg.button.test), - modelValue: visible.value, - closeOnClickModal: false, - onClose: () => visible.value = false - }, () => [ - h(ElFormItem, { - label: t(msg => msg.limit.button.test), - labelWidth: 120 - }, () => h(ElInput, { - modelValue: urlRef.value, - clearable: true, - onClear: () => changeInput(''), - onKeyup: (event: KeyboardEvent) => event.key === 'Enter' && test(), - onInput: (newVal: string) => changeInput(newVal) - }, { - append: () => h(ElButton, { - onClick: () => test() - }, () => t(msg => msg.button.test)), - })), - h(ElAlert, { - closable: false, - type: resultTypeRef.value, - title: resultTitleRef.value, - }, () => resultDescRef.value.map(desc => h('li', desc))) - ]) + ctx.expose(instance) + + return () => h(ElDialog, { + title: t(msg => msg.button.test), + modelValue: visible.value, + closeOnClickModal: false, + onClose: () => visible.value = false + }, () => [ + h(ElFormItem, { + label: t(msg => msg.limit.button.test), + labelWidth: 120 + }, () => h(ElInput, { + modelValue: urlRef.value, + clearable: true, + onClear: () => changeInput(''), + onKeyup: (event: KeyboardEvent) => event.key === 'Enter' && test(), + onInput: (newVal: string) => changeInput(newVal) + }, { + append: () => h(ElButton, { + onClick: () => test() + }, () => t(msg => msg.button.test)), + })), + h(ElAlert, { + closable: false, + type: resultTypeRef.value, + title: resultTitleRef.value, + }, () => resultDescRef.value.map(desc => h('li', desc))) + ]) + } }) export default _default \ No newline at end of file diff --git a/src/app/components/option/components/appearance/index.ts b/src/app/components/option/components/appearance/index.ts index a9b46811..23946827 100644 --- a/src/app/components/option/components/appearance/index.ts +++ b/src/app/components/option/components/appearance/index.ts @@ -88,7 +88,6 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear target.darkMode = source.darkMode target.darkModeTimeStart = source.darkModeTimeStart target.darkModeTimeEnd = source.darkModeTimeEnd - target.limitMarkFilter = source.limitMarkFilter } const _default = defineComponent((_props, ctx) => { diff --git a/src/app/components/option/components/backup/style.sass b/src/app/components/option/components/backup/style.sass index 7d64f966..ded130ca 100644 --- a/src/app/components/option/components/backup/style.sass +++ b/src/app/components/option/components/backup/style.sass @@ -6,18 +6,6 @@ height: 20px margin-right: 0 margin-left: 6px -.sop-dialog-container - margin-right: 16px -.step-container - width: 400px - margin: auto - .el-step__head - .el-step__line - margin-right: -50% !important - left: 50% !important - .el-step__title - text-align: center - .operation-container margin: 40px 20px 0 20px diff --git a/src/app/components/report/index.ts b/src/app/components/report/index.ts index 25393c46..838fb526 100644 --- a/src/app/components/report/index.ts +++ b/src/app/components/report/index.ts @@ -14,7 +14,7 @@ import { I18nKey, t } from "@app/locale" import statService from "@service/stat-service" import whitelistService from "@service/whitelist-service" import './styles/element' -import ReportTable from "./table" +import ReportTable, { TableInstance } from "./table" import ReportFilter from "./filter" import Pagination from "../common/pagination" import ContentContainer from "../common/content-container" @@ -221,7 +221,7 @@ const _default = defineComponent({ const page: UnwrapRef = reactive({ size: 10, num: 1, total: 0 }) const queryParam: ComputedRef = computed(() => computeTimerQueryParam(filterOption, sort)) - const tableEl: Ref = ref() + const table: Ref = ref() const query = () => queryData(queryParam, data, page, remoteRead) // Query data if window become visible @@ -245,7 +245,7 @@ const _default = defineComponent({ format === 'json' && exportJson(filterOption, rows) format === 'csv' && exportCsv(filterOption, rows) }, - onBatchDelete: (filterOption: ReportFilterOption) => handleBatchDelete(tableEl, filterOption, query), + onBatchDelete: (filterOption: ReportFilterOption) => handleBatchDelete(table, filterOption, query), onRemoteChange(newRemoteChange) { remoteRead.value = newRemoteChange query() @@ -261,7 +261,7 @@ const _default = defineComponent({ readRemote: remoteRead.value, data: data.value, defaultSort: sort, - ref: tableEl, + ref: table, onSortChange: ((sortInfo: SortInfo) => { sort.order = sortInfo.order sort.prop = sortInfo.prop diff --git a/src/app/components/report/table/index.ts b/src/app/components/report/table/index.ts index 17aee709..9abc2580 100644 --- a/src/app/components/report/table/index.ts +++ b/src/app/components/report/table/index.ts @@ -17,8 +17,11 @@ import FocusColumn from "./columns/focus" import TimeColumn from "./columns/time" import OperationColumn from "./columns/operation" +export type TableInstance = { + getSelected(): timer.stat.Row[] +} + const _default = defineComponent({ - name: "ReportTable", props: { data: Array as PropType, defaultSort: Object as PropType, @@ -39,11 +42,10 @@ const _default = defineComponent({ let selectedRows: timer.stat.Row[] = [] const readRemote = ref(props.readRemote) watch(() => props.readRemote, newVal => readRemote.value = newVal) - ctx.expose({ - getSelected(): timer.stat.Row[] { - return selectedRows || [] - } - }) + const instance: TableInstance = { + getSelected: () => selectedRows + } + ctx.expose(instance) return () => h(ElTable, { data: props.data, border: true, diff --git a/src/app/components/rule-merge/components/add-button.ts b/src/app/components/rule-merge/components/add-button.ts index d9345e50..7d8ea551 100644 --- a/src/app/components/rule-merge/components/add-button.ts +++ b/src/app/components/rule-merge/components/add-button.ts @@ -13,8 +13,11 @@ import ItemInput from './item-input' const buttonText = `+ ${t(msg => msg.button.create)}` +export type AddButtonInstance = { + closeEdit(): void +} + const _default = defineComponent({ - name: "MergeRuleAddButton", emits: { save: (_origin: string, _merged: string | number) => true, }, @@ -22,11 +25,10 @@ const _default = defineComponent({ const editing: Ref = ref(false) const origin: Ref = ref('') const merged: Ref = ref('') - ctx.expose({ - closeEdit() { - editing.value = false - } - }) + const instance: AddButtonInstance = { + closeEdit: () => editing.value = false + } + ctx.expose(instance) return () => editing.value ? h(ItemInput, { origin: origin.value, diff --git a/src/app/components/rule-merge/components/item.ts b/src/app/components/rule-merge/components/item.ts index 7238606a..0f7af26e 100644 --- a/src/app/components/rule-merge/components/item.ts +++ b/src/app/components/rule-merge/components/item.ts @@ -15,8 +15,11 @@ import { computed, defineComponent, h, ref, watch } from "vue" import ItemInput from "./item-input" import { computeMergeTxt, computeMergeType } from "@util/merge" +export type ItemInstance = { + forceEdit(): void +} + const _default = defineComponent({ - name: "MergeRuleItem", props: { origin: { type: String @@ -44,11 +47,10 @@ const _default = defineComponent({ const tagTxt: Ref = computed(() => computeMergeTxt(origin.value, merged.value, (finder, param) => t(msg => finder(msg.mergeCommon), param) )) - ctx.expose({ - forceEdit() { - editing.value = true - } - }) + const instance: ItemInstance = { + forceEdit: () => editing.value = true + } + ctx.expose(instance) return () => editing.value ? h(ItemInput, { diff --git a/src/app/components/rule-merge/item-list.ts b/src/app/components/rule-merge/item-list.ts index ee62cd0d..bb104b47 100644 --- a/src/app/components/rule-merge/item-list.ts +++ b/src/app/components/rule-merge/item-list.ts @@ -9,8 +9,8 @@ import { ElMessage, ElMessageBox } from "element-plus" import { Ref, ref, h, VNode } from "vue" import MergeRuleDatabase from "@db/merge-rule-database" import { t } from "@app/locale" -import Item from './components/item' -import AddButton from './components/add-button' +import Item, { ItemInstance } from './components/item' +import AddButton, { AddButtonInstance } from './components/add-button' const mergeRuleDatabase = new MergeRuleDatabase(chrome.storage.local) const ruleItemsRef: Ref = ref([]) @@ -23,7 +23,7 @@ function queryData() { queryData() -const handleInputConfirm = (origin: string, merged: string | number, addButtonRef: Ref) => { +const handleInputConfirm = (origin: string, merged: string | number, addButtonRef: Ref) => { const exists = ruleItemsRef.value.filter(item => item.origin === origin).length > 0 if (exists) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) @@ -62,7 +62,7 @@ const handleTagClose = (origin: string) => { .catch(() => { }) } -async function handleChange(origin: string, merged: string | number, index: number, ref: Ref): Promise { +async function handleChange(origin: string, merged: string | number, index: number, ref: Ref): Promise { const hasDuplicate = ruleItemsRef.value.find((o, i) => o.origin === origin && i != index) if (hasDuplicate) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) @@ -79,14 +79,14 @@ async function handleChange(origin: string, merged: string | number, index: numb function generateTagItem(ruleItem: timer.merge.Rule, index: number): VNode { const { origin, merged } = ruleItem - const itemRef: Ref = ref() + const item: Ref = ref() return h(Item, { - ref: itemRef, + ref: item, index, origin, merged, onDelete: origin => handleTagClose(origin), - onChange: (origin, merged, index) => handleChange(origin, merged, index, itemRef) + onChange: (origin, merged, index) => handleChange(origin, merged, index, item) }) } @@ -95,7 +95,7 @@ const itemList = () => { ruleItemsRef.value.forEach((item, index) => { result.push(generateTagItem(item, index)) }) - const addButtonRef: Ref = ref() + const addButtonRef: Ref = ref() const addButton = h(AddButton, { ref: addButtonRef, onSave: (origin, merged) => handleInputConfirm(origin, merged, addButtonRef) diff --git a/src/app/components/site-manage/index.ts b/src/app/components/site-manage/index.ts index 9d4a929f..ebbd3885 100644 --- a/src/app/components/site-manage/index.ts +++ b/src/app/components/site-manage/index.ts @@ -13,7 +13,7 @@ import SiteManageFilter from "./filter" import Pagination from "../common/pagination" import SiteManageTable from "./table" import siteService, { SiteQueryParam } from "@service/site-service" -import Modify from './modify' +import Modify, { ModifyInstance } from './modify' export default defineComponent({ name: "SiteManage", @@ -26,7 +26,7 @@ export default defineComponent({ set: (val: boolean) => sourceRef.value = val ? 'DETECTED' : undefined }) const dataRef: Ref = ref([]) - const modifyDialogRef: Ref = ref() + const modify: Ref = ref() const pageRef: UnwrapRef = reactive({ size: 10, @@ -63,7 +63,7 @@ export default defineComponent({ queryData() }, onCreate() { - modifyDialogRef.value.add?.() + modify.value.add?.() } }), content: () => [ @@ -85,7 +85,7 @@ export default defineComponent({ } }), h(Modify, { - ref: modifyDialogRef, + ref: modify, onSave: queryData }) ] diff --git a/src/app/components/site-manage/modify/index.ts b/src/app/components/site-manage/modify/index.ts index 50bae216..3ce358f6 100644 --- a/src/app/components/site-manage/modify/index.ts +++ b/src/app/components/site-manage/modify/index.ts @@ -7,7 +7,7 @@ import type { Ref, SetupContext, UnwrapRef } from "vue" -import { ElButton, ElDialog, ElForm, ElMessage } from "element-plus" +import { ElButton, ElDialog, ElForm, FormInstance, ElMessage } from "element-plus" import { defineComponent, h, reactive, ref } from "vue" import { t } from "@app/locale" import { Check } from "@element-plus/icons-vue" @@ -15,7 +15,11 @@ import siteService from "@service/site-service" import SiteManageHostFormItem from "./host-form-item" import SiteManageAliasFormItem from "./alias-form-item" -declare type _FormData = { +export type ModifyInstance = { + add(): void +} + +type _FormData = { /** * Value of alias key */ @@ -40,9 +44,9 @@ const formRule = { ] } -function validateForm(formRef: Ref): Promise { +function validateForm(form: Ref): Promise { return new Promise((resolve, reject) => { - const validate = formRef.value?.validate + const validate = form.value?.validate validate ? validate((valid: boolean) => valid ? resolve(true) : resolve(false)) : reject(false) @@ -97,19 +101,20 @@ const _default = defineComponent({ setup: (_, ctx: SetupContext<_Emit>) => { const visible: Ref = ref(false) const formData: UnwrapRef<_FormData> = reactive(initData()) - const formRef: Ref = ref() + const form: Ref = ref() - ctx.expose({ + const instance: ModifyInstance = { add() { formData.key = undefined formData.alias = undefined - visible.value = true }, - hide: () => visible.value = false - }) + } + + ctx.expose(instance) + const save = async () => { - const valid: boolean = await validateForm(formRef) + const valid: boolean = await validateForm(form) if (!valid) { return false } @@ -127,7 +132,7 @@ const _default = defineComponent({ labelPosition: 'right', labelWidth: '100px' }, () => h(ElForm, - { model: formData, rules: formRule, ref: formRef }, + { model: formData, rules: formRule, ref: form }, () => [ // Host form item h(SiteManageHostFormItem, { diff --git a/src/app/components/whitelist/components/add-button.ts b/src/app/components/whitelist/components/add-button.ts index bca95529..dd59f0be 100644 --- a/src/app/components/whitelist/components/add-button.ts +++ b/src/app/components/whitelist/components/add-button.ts @@ -10,6 +10,10 @@ import { ElButton } from "element-plus" import { defineComponent, h, ref, Ref } from "vue" import ItemInput from './item-input' +export type AddButtonInstance = { + closeEdit(): void +} + const buttonText = `+ ${t(msg => msg.button.create)}` const _default = defineComponent({ @@ -20,11 +24,10 @@ const _default = defineComponent({ setup(_props, ctx) { const editing: Ref = ref(false) const white: Ref = ref('') - ctx.expose({ - closeEdit() { - editing.value = false - } - }) + const instance: AddButtonInstance = { + closeEdit: () => editing.value = false + } + ctx.expose(instance) return () => editing.value ? h(ItemInput, { white: white.value, diff --git a/src/app/components/whitelist/components/item.ts b/src/app/components/whitelist/components/item.ts index 8859aa2f..4d5a3e76 100644 --- a/src/app/components/whitelist/components/item.ts +++ b/src/app/components/whitelist/components/item.ts @@ -10,8 +10,11 @@ import { ElTag } from "element-plus" import { defineComponent, h, ref, Ref, watch } from "vue" import ItemInput from "./item-input" +export type ItemInstance = { + forceEdit(): void +} + const _default = defineComponent({ - name: "MergeRuleItem", props: { white: { type: String @@ -30,11 +33,10 @@ const _default = defineComponent({ const id: Ref = ref(props.index || 0) watch(() => props.index, newVal => id.value = newVal) const editing: Ref = ref(false) - ctx.expose({ - forceEdit() { - editing.value = true - } - }) + const instance: ItemInstance = { + forceEdit: () => editing.value = true + } + ctx.expose(instance) return () => editing.value ? h(ItemInput, { diff --git a/src/app/components/whitelist/item-list.ts b/src/app/components/whitelist/item-list.ts index 887e5cc1..33ad416e 100644 --- a/src/app/components/whitelist/item-list.ts +++ b/src/app/components/whitelist/item-list.ts @@ -11,8 +11,8 @@ import { ElMessage, ElMessageBox } from "element-plus" import { t } from "@app/locale" import { h, ref } from "vue" import whitelistService from "@service/whitelist-service" -import Item from './components/item' -import AddButton from './components/add-button' +import Item, { ItemInstance } from './components/item' +import AddButton, { AddButtonInstance } from './components/add-button' const whitelistRef: Ref = ref([]) @@ -36,7 +36,7 @@ const handleClose = (whiteItem: string) => { .catch(() => { }) } -async function handleChanged(inputValue: string, index: number, ref: Ref) { +async function handleChanged(inputValue: string, index: number, ref: Ref) { const duplicate = whitelistRef.value.find((white, i) => white === inputValue && i !== index) if (duplicate) { ElMessage({ type: 'warning', message: t(msg => msg.whitelist.duplicateMsg) }) @@ -50,7 +50,7 @@ async function handleChanged(inputValue: string, index: number, ref: Ref) { ElMessage({ type: 'success', message: t(msg => msg.operation.successMsg) }) } -function handleAdd(inputValue: string, ref: Ref) { +function handleAdd(inputValue: string, ref: Ref) { const whitelist = whitelistRef.value const exists = whitelist.filter(item => item === inputValue).length > 0 if (exists) { @@ -71,7 +71,7 @@ function handleAdd(inputValue: string, ref: Ref) { function tags(): VNode { const result = [] whitelistRef.value.forEach((white: string, index: number) => { - const itemRef: Ref = ref() + const itemRef: Ref = ref() const item = h(Item, { white, index, ref: itemRef, onChange: (newVal, index) => handleChanged(newVal, index, itemRef), @@ -79,7 +79,7 @@ function tags(): VNode { }) result.push(item) }) - const addButtonRef: Ref = ref() + const addButtonRef: Ref = ref() result.push(h(AddButton, { ref: addButtonRef, onSave: (inputVal: string) => handleAdd(inputVal, addButtonRef) diff --git a/src/app/styles/compatible.sass b/src/app/styles/compatible.sass index 0abfbf82..d8e9cefd 100644 --- a/src/app/styles/compatible.sass +++ b/src/app/styles/compatible.sass @@ -53,3 +53,28 @@ \:root --el-input-height: 40px --el-input-inner-height: 38px + +// SOP +.sop-footer + text-align: center + display: block + width: 100% + margin: auto + margin-top: 40px + .el-button + width: initial !important + height: initial !important + .el-button:not(:last-child) + margin-right: 10px +.step-container + width: 400px + margin: auto + .el-step__head + text-align: center + .el-step__line + margin-right: -50% !important + left: 50% !important + .el-step__title + text-align: center +.sop-dialog-container + margin-right: 16px diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 92dd2f98..8e62512f 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -38,8 +38,7 @@ export default function init(dispatcher: MessageDispatcher) { }) // Get today info .register('cs.getTodayInfo', host => statService.getResult(host, new Date())) - // More minutes - .register('cs.moreMinutes', url => limitService.moreMinutes(url)) // cs.getLimitedRules .register('cs.getLimitedRules', url => limitService.getLimited(url)) + .register('cs.getRelatedRules', url => limitService.getRelated(url)) } \ No newline at end of file diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index 6951d842..10c1970a 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -10,6 +10,7 @@ import { LIMIT_ROUTE } from "@app/router/constants" import { getAppPageUrl } from "@util/constant/url" import MessageDispatcher from "./message-dispatcher" import { matches } from "@util/limit" +import limitService from "@service/limit-service" function processLimitWaking(rules: timer.limit.Item[], tab: ChromeTab) { const { url } = tab @@ -36,4 +37,22 @@ export default function init(dispatcher: MessageDispatcher) { tabs.forEach(tab => processLimitWaking(rules, tab)) } ) + // More minutes + .register('cs.moreMinutes', async url => { + const rules = await limitService.moreMinutes(url) + + const tabs = await listTabs({ status: 'complete' }) + tabs.forEach(tab => processLimitWaking(rules, tab)) + }) + // Judge any tag hit the time limit per visit + .register("askHitVisit", async item => { + let tabs = await listTabs() + tabs = tabs?.filter(({ url }) => matches(item, url)) + const { visitTime = 0 } = item || {} + for (const { id } of tabs) { + const tabFocus = await sendMsg2Tab(id, "askVisitTime", undefined) + if (tabFocus && tabFocus > visitTime * 1000) return true + } + return false + }) } \ No newline at end of file diff --git a/src/background/timer/client.ts b/src/background/timer/client.ts index a4ab67d6..c7baa51f 100644 --- a/src/background/timer/client.ts +++ b/src/background/timer/client.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +type ReportFunction = (ev: timer.stat.Event) => Promise /** * Tracker client, used in the content-script @@ -6,6 +6,11 @@ import { sendMsg2Runtime } from "@api/chrome/runtime" export default class TrackerClient { docVisible: boolean = false start: number = Date.now() + report: ReportFunction + + constructor(report: ReportFunction) { + this.report = report + } init() { this.docVisible = document?.visibilityState === 'visible' @@ -33,7 +38,7 @@ export default class TrackerClient { ignoreTabCheck } try { - await sendMsg2Runtime('cs.trackTime', data) + await this.report?.(data) this.start = end } catch (_) { } } diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 6317e734..75f45ca7 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -22,14 +22,26 @@ function getOrSetFlag(): boolean { const flag = document?.createElement('a') flag.style && (flag.style.visibility = 'hidden') flag && (flag.id = FLAG_ID) - document?.body?.appendChild(flag) + + 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 } async function main() { // Execute in every injections - new TrackerClient().init() + const tracker = new TrackerClient( + data => sendMsg2Runtime('cs.trackTime', data) + ) + tracker.init() // Execute only one time if (getOrSetFlag()) return diff --git a/src/content-script/limit.ts b/src/content-script/limit.ts deleted file mode 100644 index fa0b1dc1..00000000 --- a/src/content-script/limit.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { hasLimited, matches } from "@util/limit" -import optionService from "@service/option-service" -import { t2Chrome } from "@i18n/chrome/t" -import { t } from "./locale" -import { onRuntimeMessage, sendMsg2Runtime } from "@api/chrome/runtime" - -class _Modal { - url: string - mask: HTMLDivElement - delayContainer: HTMLParagraphElement - visible: boolean = false - constructor(url: string) { - this.mask = document.createElement('div') - this.mask.id = "_timer_mask" - this.mask.append(link2Setup(url)) - this.url = url - this.initStyle() - } - - private async initStyle() { - const filterType = (await optionService.getAllOption())?.limitMarkFilter - const realMaskStyle = { - ...maskStyle, - ...filterStyle[filterType || 'translucent'] - } - Object.assign(this.mask.style || {}, realMaskStyle) - } - - showModal(showDelay: boolean) { - if (!document.body) { - return - } - // Exist full screen at first - exitScreen().then(() => this.showModalInner(showDelay)) - } - - private showModalInner(showDelay: boolean) { - const _thisUrl = this.url - if (showDelay && this.mask.childElementCount === 1) { - this.delayContainer = document.createElement('p') - this.delayContainer.style.marginTop = '100px' - - // Only delay-allowed rules exist, can delay - // @since 0.4.0 - const link = document.createElement('a') - Object.assign(link.style || {}, linkStyle) - link.setAttribute('href', 'javascript:void(0)') - const text = t(msg => msg.more5Minutes) - link.innerText = text - link.onclick = async () => { - const delayRules: timer.limit.Item[] = await sendMsg2Runtime('cs.moreMinutes', _thisUrl) - const wakingRules = delayRules.filter(rule => !hasLimited(rule)) - sendMsg2Runtime('limitWaking', wakingRules) - this.hideModal() - } - this.delayContainer.append(link) - this.mask.append(this.delayContainer) - } else if (!showDelay && this.mask.childElementCount === 2) { - this.mask.children?.[1]?.remove?.() - } - if (this.visible) { - return - } - document.body.append(this.mask) - document.body.style.overflow = 'hidden' - this.visible = true - } - - hideModal() { - if (!this.visible || !document.body) { - return - } - this.mask.remove() - document.body.style.overflow = '' - this.visible = false - } - - process(data: timer.limit.Item[]) { - const anyMatch = data.map(item => matches(item, this.url)).reduce((a, b) => a || b) - if (anyMatch) { - const anyDelay = data.map(item => matches(item, this.url) && item.allowDelay).reduce((a, b) => a || b) - this.showModal(anyDelay) - } - } - - isVisible(): boolean { - return !!this.visible - } -} - -const maskStyle: Partial = { - width: "100%", - height: "100%", - position: "fixed", - zIndex: '99999', - display: 'block', - top: '0px', - left: '0px', - textAlign: 'center', - paddingTop: '120px' -} - -const filterStyle: Record> = { - translucent: { - backgroundColor: '#444', - opacity: '0.9', - color: '#EEE', - }, - groundGlass: { - backdropFilter: 'blur(5px)', - color: '#111', - } -} - -const linkStyle: Partial = { - color: 'inherit', - fontFamily: '-apple-system,BlinkMacSystemFont,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",Helvetica,Arial,sans-serif', - fontSize: '16px !important' -} - -function exitScreen(): Promise { - const ele = document.fullscreenElement - if (!ele) { - return Promise.resolve() - } - return new Promise(resolve => { - if (document.exitFullscreen) { - document.exitFullscreen() - .then(resolve) - .catch(e => console.warn("Failed to exit fullscreen", e)) - } else { - resolve() - } - }) -} - -function link2Setup(url: string): HTMLParagraphElement { - const link = document.createElement('a') - Object.assign(link.style || {}, linkStyle) - link.setAttribute('href', 'javascript:void(0)') - const text = t(msg => msg.timeLimitMsg) - .replace('{appName}', t2Chrome(msg => msg.meta.name)) - link.innerText = text - link.onclick = () => sendMsg2Runtime('openLimitPage', encodeURIComponent(url)) - const p = document.createElement('p') - p.append(link) - return p -} - -async function handleLimitTimeMeet(msg: timer.mq.Request, modal: _Modal): Promise { - if (msg.code !== "limitTimeMeet") { - return { code: "ignore" } - } - const items: timer.limit.Item[] = msg.data - if (!items?.length) { - return { code: "fail", msg: "Empty time limit item" } - } - modal.process(items) - return { code: "success" } -} - -async function handleLimitWaking(msg: timer.mq.Request, modal: _Modal): Promise { - if (msg.code !== "limitWaking") { - return { code: "ignore" } - } - if (!modal.isVisible()) { - return { code: "ignore" } - } - const items: timer.limit.Item[] = msg.data - if (!items?.length) { - return { code: "success", msg: "Empty time limit item" } - } - for (let index in items) { - const item = items[index] - if (matches(item, modal.url) && !hasLimited(item)) { - modal.hideModal() - break - } - } - return { code: "success" } -} - -async function handleLimitChanged(msg: timer.mq.Request, modal: _Modal): Promise { - if (msg.code === 'limitChanged') { - const items: timer.limit.Item[] = msg.data || [] - items?.length ? modal.process(items) : modal.hideModal() - return { code: 'success' } - } else { - return { code: 'ignore' } - } -} - -export default async function processLimit(url: string) { - const modal = new _Modal(url) - const limitedRules: timer.limit.Item[] = await sendMsg2Runtime('cs.getLimitedRules', url) - if (limitedRules?.length) { - window.onload = () => modal.showModal(!!limitedRules?.filter?.(item => item.allowDelay).length) - } - onRuntimeMessage(msg => handleLimitTimeMeet(msg, modal)) - onRuntimeMessage(msg => handleLimitChanged(msg, modal)) - onRuntimeMessage(msg => handleLimitWaking(msg, modal)) -} - diff --git a/src/content-script/limit/common.ts b/src/content-script/limit/common.ts new file mode 100644 index 00000000..4ab749cd --- /dev/null +++ b/src/content-script/limit/common.ts @@ -0,0 +1,28 @@ +export type LimitReason = { + cond: string + type: LimitType + allowDelay?: boolean +} + +export type LimitType = + | "DAILY" + | "VISIT" + | "PERIOD" + +export interface MaskModal { + addReason(reason: LimitReason): void + removeReason(reason: LimitReason): void + removeReasonsByType(type: LimitType): void + removeReasonsByTypeAndCond(type: LimitType, cound: string): void + addDelayHandler(handler: () => void): void +} + +export type ModalContext = { + url: string + modal: MaskModal +} + +export interface Processor { + handleMsg(code: timer.mq.ReqCode, data: any): timer.mq.Response | Promise + init(): void | Promise +} \ No newline at end of file diff --git a/src/content-script/limit/daily-processor.ts b/src/content-script/limit/daily-processor.ts new file mode 100644 index 00000000..f2f7e9a6 --- /dev/null +++ b/src/content-script/limit/daily-processor.ts @@ -0,0 +1,52 @@ +import { matches } from "@util/limit" +import { LimitReason, ModalContext, Processor } from "./common" +import { sendMsg2Runtime } from "@api/chrome/runtime" + +class DailyProcessor implements Processor { + private context: ModalContext + + constructor(context: ModalContext) { + this.context = context + } + + handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise { + let items = data as timer.limit.Item[] + if (code === "limitTimeMeet") { + if (!items?.length) { + return { code: "fail" } + } + items.filter(item => matches(item, this.context.url)) + .forEach(item => { + const reason: LimitReason = { type: "DAILY", cond: item.cond, allowDelay: item.allowDelay } + this.context.modal.addReason(reason) + }) + return { code: "success" } + } else if (code === "limitChanged") { + this.context.modal.removeReasonsByType("DAILY") + items?.forEach(item => { + const reason: LimitReason = { type: "DAILY", cond: item.cond, allowDelay: item.allowDelay } + this.context.modal.addReason(reason) + }) + return { code: "success" } + } else if (code === "limitWaking") { + items?.forEach(item => { + const reason: LimitReason = { type: "DAILY", cond: item.cond, allowDelay: item.allowDelay } + this.context.modal.removeReason(reason) + }) + return { code: "success" } + } + return { code: "ignore" } + } + + async init(): Promise { + const limitedRules: timer.limit.Item[] = await sendMsg2Runtime('cs.getLimitedRules', this.context.url) + if (!limitedRules?.length) return + + limitedRules?.forEach(item => { + const reason: LimitReason = { type: "DAILY", cond: item.cond, allowDelay: item.allowDelay } + this.context.modal.addReason(reason) + }) + } +} + +export default DailyProcessor \ No newline at end of file diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts new file mode 100644 index 00000000..d3bc0167 --- /dev/null +++ b/src/content-script/limit/index.ts @@ -0,0 +1,41 @@ +import { MaskModal, ModalContext, Processor } from "./common" +import ModalInstance from "./modal" +import DailyProcessor from "./daily-processor" +import VisitProcessor from "./visit-processor" +import PeriodProcessor from "./period-processor" +import { onRuntimeMessage } from "@api/chrome/runtime" +import { allMatch } from "@util/array" + +export default async function processLimit(url: string) { + const modal: MaskModal = new ModalInstance(url) + const context: ModalContext = { modal, url } + + const processors: Processor[] = [ + new DailyProcessor(context), + new PeriodProcessor(context), + new VisitProcessor(context), + ] + + await Promise.all(processors.map(p => p.init())) + + onRuntimeMessage(async msg => { + const results = await Promise.all(processors.map(async p => { + const { code, data } = msg || {} + return await p.handleMsg(code, data) + })) + + const allIgnore = allMatch(results, r => r.code === "ignore") + if (allIgnore) return { code: "ignore" } + + const anyFail = allMatch(results, r => r.code === "fail") + if (anyFail) return { code: "fail" } + // Merge data of all the handlers + const datas = results + .filter(r => r.code === "success") + .map(r => r.data) + .filter(r => r !== undefined && r !== null) + const data = datas.length <= 1 ? datas[0] : datas + console.log("sdadsa", data) + return { code: "success", data } + }) +} diff --git a/src/content-script/limit/modal-style.ts b/src/content-script/limit/modal-style.ts new file mode 100644 index 00000000..309a907f --- /dev/null +++ b/src/content-script/limit/modal-style.ts @@ -0,0 +1,29 @@ +export const LINK_STYLE: Partial = { + color: 'inherit', + fontFamily: '-apple-system,BlinkMacSystemFont,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",Helvetica,Arial,sans-serif', + fontSize: '16px !important' +} + +export const MASK_STYLE: Partial = { + width: "100%", + height: "100%", + position: "fixed", + zIndex: '99999', + display: 'block', + top: '0px', + left: '0px', + textAlign: 'center', + paddingTop: '120px' +} + +export const FILTER_STYLES: Record> = { + translucent: { + backgroundColor: '#444', + opacity: '0.9', + color: '#EEE', + }, + groundGlass: { + backdropFilter: 'blur(5px)', + color: '#111', + } +} \ No newline at end of file diff --git a/src/content-script/limit/modal.ts b/src/content-script/limit/modal.ts new file mode 100644 index 00000000..c86d9d89 --- /dev/null +++ b/src/content-script/limit/modal.ts @@ -0,0 +1,195 @@ +import { t } from "../locale" +import { t2Chrome } from "@i18n/chrome/t" +import { LimitReason, LimitType, MaskModal } from "./common" +import { sendMsg2Runtime } from "@api/chrome/runtime" +import optionService from "@service/option-service" +import { FILTER_STYLES, LINK_STYLE, MASK_STYLE } from "./modal-style" + +const TYPE_SORT: { [reason in LimitType]: number } = { + PERIOD: 0, + VISIT: 1, + DAILY: 2, +} + +function limitAlert(reason: LimitReason): HTMLParagraphElement { + let text = "" + const { type } = reason || {} + if (type === "DAILY") { + text = t(msg => msg.timeLimitMsg) + } else if (type === "VISIT") { + text = t(msg => msg.visitLimitMsg) + } else if (type === "PERIOD") { + text = t(msg => msg.periodLimitMsg) + } + + const p = document.createElement('p') + + Object.assign(p.style || {}, LINK_STYLE) + p.innerText = text + return p +} + +function link2Setup(url: string): HTMLParagraphElement { + const refText = t(msg => msg.limitRefMsg).replace('{appName}', t2Chrome(msg => msg.meta.name)) + + const link = document.createElement('a') + Object.assign(link.style || {}, LINK_STYLE) + link.setAttribute('href', 'javascript:void(0)') + + link.innerText = refText + link.onclick = () => sendMsg2Runtime('openLimitPage', encodeURIComponent(url)) + const p = document.createElement('p') + p.append(link) + return p +} + +function exitScreen(): Promise { + const ele = document.fullscreenElement + if (!ele) { + return Promise.resolve() + } + return new Promise(resolve => { + if (document.exitFullscreen) { + document.exitFullscreen() + .then(resolve) + .catch(e => console.warn("Failed to exit fullscreen", e)) + } else { + resolve() + } + }) +} + +function isSameReason(a: LimitReason, b: LimitReason): boolean { + let same = a?.cond === b?.cond && a?.type === b?.type + if (!same) return false + if (a?.type === "DAILY" || a?.type === "VISIT") { + // Need judge allow delay + same = same && a?.allowDelay === b?.allowDelay + } + return same +} + +class ModalInstance implements MaskModal { + url: string + reasons: LimitReason[] = [] + displayReason: LimitReason + mask: HTMLDivElement + delayHandlers: (() => void)[] = [ + () => sendMsg2Runtime('cs.moreMinutes', this.url) + ] + + constructor(url: string) { + this.url = url + this.mask = document.createElement('div') + this.mask.id = "_timer_mask" + this.initStyle() + window && (window.onload = () => this.refresh()) + } + + addDelayHandler(handler: () => void): void { + if (!handler) return + if (this.delayHandlers?.includes(handler)) return + this.delayHandlers?.push(handler) + } + + private async initStyle() { + const filterType = (await optionService.getAllOption())?.limitFilter + const realMaskStyle = { + ...MASK_STYLE, + ...FILTER_STYLES[filterType || 'translucent'] + } + Object.assign(this.mask.style || {}, realMaskStyle) + } + + addReason(reason: LimitReason): void { + const exist = this.reasons.find(r => isSameReason(r, reason)) + if (exist) return + this.reasons.push(reason) + // Sort + this.reasons.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) + this.refresh() + } + + private refresh() { + if (!document.body) return + + const newReason = this.reasons[0] + if (isSameReason(newReason, this.displayReason)) { + // do nothing + return + } + this.displayReason = newReason + if (newReason) { + // Exist full screen at first + exitScreen().then(() => this.showModalInner(this.displayReason)) + } else { + this.hideModal() + } + } + + removeReason(reason: LimitReason): void { + const beforeCount = this.reasons.length + this.reasons = this.reasons.filter(r => !isSameReason(r, reason)) + const afterCount = this.reasons.length + beforeCount !== afterCount && this.refresh() + } + + removeReasonsByType(type: LimitType): void { + const beforeCount = this.reasons.length + this.reasons = this.reasons.filter(r => r.type !== type) + const afterCount = this.reasons.length + beforeCount !== afterCount && this.refresh() + } + + removeReasonsByTypeAndCond(type: LimitType, cond: string): void { + const beforeCount = this.reasons.length + this.reasons = this.reasons.filter(r => !(r.type === type && r.cond === cond)) + const afterCount = this.reasons.length + beforeCount !== afterCount && this.refresh() + } + + private showModalInner(reason: LimitReason): any { + const url = this.url + const { allowDelay, type } = reason + + // Clear + Array.from(this.mask.children).forEach(e => e.remove()) + // Append alert and link + this.mask.append(limitAlert(reason)) + this.mask.append(document.createElement("br")) + this.mask.append(link2Setup(url)) + + const canDelay = (type === "DAILY" || type === "VISIT") && allowDelay + + if (canDelay) { + const delayContainer = document.createElement('p') + delayContainer.style.marginTop = '100px' + + // Only delay-allowed rules exist, can delay + // @since 0.4.0 + const link = document.createElement('a') + Object.assign(link.style || {}, LINK_STYLE) + link.setAttribute('href', 'javascript:void(0)') + const text = t(msg => msg.more5Minutes) + link.innerText = text + link.onclick = () => [ + this.delayHandlers?.forEach(h => h?.()) + ] + delayContainer.append(link) + this.mask.append(delayContainer) + } + + document.body.append(this.mask) + document.body.style.overflow = 'hidden' + } + + private hideModal() { + if (!document.body) { + return + } + this.mask.remove() + document.body.style.overflow = '' + } +} + +export default ModalInstance \ No newline at end of file diff --git a/src/content-script/limit/period-processor.ts b/src/content-script/limit/period-processor.ts new file mode 100644 index 00000000..a99c60d3 --- /dev/null +++ b/src/content-script/limit/period-processor.ts @@ -0,0 +1,52 @@ +import { sendMsg2Runtime } from "@api/chrome/runtime" +import { LimitReason, ModalContext, Processor } from "./common" +import { date2Idx } from "@util/limit" + +class PeriodProcessor implements Processor { + private context: ModalContext + private timers: NodeJS.Timer[] = [] + + constructor(context: ModalContext) { + this.context = context + } + + async handleMsg(code: timer.mq.ReqCode): Promise { + if (code === "limitPeriodChange") { + this.timers?.forEach(clearInterval) + await this.init() + return { code: "success" } + } + return { code: "ignore" } + } + + async init(): Promise { + const rules: timer.limit.Item[] = await sendMsg2Runtime("cs.getRelatedRules", this.context.url) + // Clear first + this.context.modal.removeReasonsByType("PERIOD") + this.timers = this.calcInterval(rules, this.context) + } + + private calcInterval(rules: timer.limit.Rule[], context: ModalContext): NodeJS.Timer[] { + const nowSeconds = date2Idx(new Date()) + const timers = [] + rules?.forEach?.(rule => { + const { cond, periods } = rule + periods?.forEach(p => { + const [s, e] = p + const startSeconds = s * 60 + const endSeconds = (e + 1) * 60 + const reason: LimitReason = { cond, type: "PERIOD" } + if (nowSeconds < startSeconds) { + timers.push(setInterval(() => context.modal.addReason(reason), (startSeconds - nowSeconds) * 1000)) + timers.push(setInterval(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * 1000)) + } else if (nowSeconds >= startSeconds && nowSeconds <= endSeconds) { + context.modal.addReason(reason) + timers.push(setInterval(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * 1000)) + } + }) + }) + return undefined + } +} + +export default PeriodProcessor \ No newline at end of file diff --git a/src/content-script/limit/visit-processor.ts b/src/content-script/limit/visit-processor.ts new file mode 100644 index 00000000..f15d2383 --- /dev/null +++ b/src/content-script/limit/visit-processor.ts @@ -0,0 +1,55 @@ +import TrackerClient from "@src/background/timer/client" +import { ModalContext, Processor } from "./common" +import { sendMsg2Runtime } from "@api/chrome/runtime" + +class VisitProcessor implements Processor { + + private context: ModalContext + private focusTime: number = 0 + private rules: timer.limit.Rule[] + private tracker: TrackerClient + + constructor(context: ModalContext) { + this.context = context + } + + async handleMsg(code: timer.mq.ReqCode, _data: any): Promise { + if (code === "limitChanged") { + this.initRules() + return { code: "success" } + } else if (code === "askVisitTime") { + return { code: "success", data: this.focusTime } + } + return { code: "ignore" } + } + + async handleTracker(data: timer.stat.Event) { + const diff = (data?.end ?? 0) - (data?.start ?? 0) + this.focusTime += diff + this.rules?.forEach?.(({ visitTime, cond, allowDelay }) => { + if (!visitTime) return + if (visitTime * 1000 < this.focusTime) { + this.context.modal.addReason({ cond, type: "VISIT", allowDelay }) + } + }) + } + + async initRules() { + this.rules = await sendMsg2Runtime("cs.getRelatedRules", this.context.url) + this.context.modal.removeReasonsByType("VISIT") + } + + async init(): Promise { + this.tracker = new TrackerClient(data => this.handleTracker(data)) + this.tracker.init() + this.initRules() + this.context.modal.addDelayHandler(() => this.processMore5Minutes()) + } + + private processMore5Minutes() { + this.focusTime = Math.max(0, this.focusTime - 5 * 60 * 1000) + this.context.modal.removeReasonsByType("VISIT") + } +} + +export default VisitProcessor \ No newline at end of file diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index 844d9572..89f8c771 100644 --- a/src/database/limit-database.ts +++ b/src/database/limit-database.ts @@ -15,6 +15,14 @@ type ItemValue = { * Limited time, second */ t: number + /** + * Limited time per visit, second + */ + v?: number + /** + * Forbiden periods + */ + p?: [number, number][] /** * Enabled flag */ @@ -43,8 +51,8 @@ function migrate(exist: Item, toMigrate: any) { // Not rewrite if (exist[cond]) return const itemValue: ItemValue = value as ItemValue - const { t, e, ad, d, w } = itemValue - exist[cond] = { t: t || 0, e: !!e, ad: !!ad, d, w: w || 0 } + const { t, e, ad, d, w, v, p } = itemValue + exist[cond] = { t, e: !!e, ad: !!ad, d, w: w || 0, v, p } }) } @@ -68,13 +76,22 @@ class LimitDatabase extends BaseDatabase { const items = await this.getItems() return Object.entries(items).map(([cond, info]) => { const item: ItemValue = info as ItemValue - return { cond, time: item.t, enabled: item.e, allowDelay: !!item.ad, wasteTime: item.w, latestDate: item.d } as timer.limit.Record + return { + cond, + time: item.t, + visitTime: item.v, + periods: item.p, + enabled: item.e, + allowDelay: !!item.ad, + wasteTime: item.w, + latestDate: item.d, + } as timer.limit.Record }) } async save(data: timer.limit.Rule, rewrite?: boolean): Promise { const items = await this.getItems() - const { cond, time, enabled, allowDelay } = data + const { cond, time, enabled, allowDelay, visitTime, periods } = data const existItem = items[cond] if (existItem) { if (!rewrite) { @@ -85,9 +102,11 @@ class LimitDatabase extends BaseDatabase { existItem.t = time existItem.e = enabled existItem.ad = allowDelay + existItem.v = visitTime + existItem.p = periods } else { // New one - items[cond] = { t: time, e: enabled, ad: allowDelay, w: 0, d: '' } + items[cond] = { t: time, e: enabled, ad: allowDelay, w: 0, d: '', v: visitTime, p: periods } } await this.update(items) } diff --git a/src/i18n/chrome/t.ts b/src/i18n/chrome/t.ts index 5f9bebdc..8fa58788 100644 --- a/src/i18n/chrome/t.ts +++ b/src/i18n/chrome/t.ts @@ -7,7 +7,6 @@ import { getMessage } from "@api/chrome/i18n" import messages, { router, ChromeMessage } from "./message" -import { IS_CHROME } from "@util/constant/environment" import { t } from ".." export const keyPathOf = (key: (root: ChromeMessage) => string) => key(router) @@ -16,6 +15,5 @@ export const t2Chrome = (key: (root: ChromeMessage) => string) => { if (getMessage) { return getMessage(keyPathOf(key)) } - console.error(IS_CHROME) return t(messages, { key }, 'en') } diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 5ebc53c0..1f6dbdbe 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -6,11 +6,15 @@ "condition": "限制网址", "time": "每日限制时长", "waste": "今日浏览时长", + "visitTime": "每次访问限制时长", + "period": "受限时间段", "enabled": "是否有效", "delayAllowed": "再看 5 分钟", "delayAllowedInfo": "上网时间超过限制时,点击【再看 5 分钟】短暂延时。如果关闭该功能则不能延时。", "operation": "操作" }, + "step1": "设置 URL", + "step2": "设置规则", "button": { "test": "网址测试", "option": "全局设置" @@ -20,7 +24,7 @@ "message": { "saved": "保存成功", "noUrl": "未填写限制网址", - "noTime": "未填写每日限制时长", + "noRule": "未填写任何规则", "deleteConfirm": "是否删除限制:{cond}?", "deleted": "删除成功", "noPermissionFirefox": "请先在插件管理页[about:addons]开启该插件的粘贴板权限", @@ -50,7 +54,9 @@ "enabled": "是否有效", "delayAllowed": "再看 5 分鐘", "delayAllowedInfo": "上網時間超過限製時,點擊【再看 5 分鐘】短暫延時。如果關閉該功能則不能延時。", - "operation": "操作" + "operation": "操作", + "visitTime": "每次訪問限制時長", + "period": "受限時段" }, "button": { "test": "網址測試", @@ -61,14 +67,14 @@ "message": { "saved": "保存成功", "noUrl": "未填冩限製網址", - "noTime": "未填冩每日限製時長", "deleteConfirm": "是否刪除限製:{cond}?", "deleted": "刪除成功", "noPermissionFirefox": "請先在插件管理頁【about:addons】開啟該插件的粘貼闆權限", "inputTestUrl": "請先輸入需要測試的網址鏈接", "clickTestButton": "輸入完成後請點擊【{buttonText}】按鈕", "noRuleMatched": "該網址未命中任何規則", - "rulesMatched": "該網址命中以下規則:" + "rulesMatched": "該網址命中以下規則:", + "noRule": "未填寫任何規則" }, "urlPlaceholder": "請直接粘貼網址 ➡️", "verification": { @@ -79,20 +85,26 @@ "incorrectAnswer": "答案不正確", "pi": "圓周率 π 的小數部分第 {startIndex} 位到第 {endIndex} 位的共 {digitCount} 位數字", "confession": "一寸光陰一寸金,寸金難買寸光陰" - } + }, + "step1": "設定 URL", + "step2": "設定規則" }, "en": { "conditionFilter": "URL", "filterDisabled": "Only enabled", "item": { "condition": "Restricted URL", - "time": "Daily time limit", + "time": "Daily limit", "waste": "Browsed today", + "visitTime": "Limit per visit", + "period": "Unallowed periods", "enabled": "Enabled", "delayAllowed": "More 5 minutes", "delayAllowedInfo": "If it times out, allow a temporary delay of 5 minutes", "operation": "Operations" }, + "step1": "Config URL", + "step2": "Config rule", "button": { "test": "Test URL", "option": "Options" @@ -101,8 +113,8 @@ "useWildcard": "Whether to use wildcard", "message": { "saved": "Saved successfully", - "noUrl": "Unfilled limited URL", - "noTime": "Unfilled limited time per day", + "noUrl": "Unfilled restricted URL", + "noRule": "No rules filled in", "deleteConfirm": "Do you want to delete the rule of {cond}?", "deleted": "Deleted successfully", "noPermissionFirefox": "Please enable the clipboard permission of this addon on the management page (about:addons) first", @@ -132,7 +144,9 @@ "enabled": "有效", "delayAllowed": "さらに5分間閲覧する", "delayAllowedInfo": "時間が経過した場合は、一時的に5分遅らせることができます", - "operation": "操作" + "operation": "操作", + "visitTime": "訪問ごとの制限", + "period": "許可されない期間" }, "button": { "test": "テストURL", @@ -142,7 +156,6 @@ "useWildcard": "ワイルドカードを使用するかどうか", "message": { "noUrl": "埋められていない制限URL", - "noTime": "1日の制限時間を記入しない", "saved": "正常に保存", "deleteConfirm": "{cond} の制限を削除しますか?", "deleted": "正常に削除", @@ -150,7 +163,8 @@ "inputTestUrl": "最初にテストする URL リンクを入力してください", "clickTestButton": "入力後、ボタン({buttonText})をクリックしてください", "noRuleMatched": "URL がどのルールとも一致しません", - "rulesMatched": "URL は次のルールに一致します。" + "rulesMatched": "URL は次のルールに一致します。", + "noRule": "ルールが記入されていません" }, "urlPlaceholder": "URLを直接貼り付けてください➡️", "verification": { @@ -161,7 +175,9 @@ "incorrectAnswer": "間違った回答", "pi": "πの小数部の{digitCount} から {startIndex} までの {endIndex} 桁の数", "confession": "人生とは今日一日のことである" - } + }, + "step1": "設定 URL", + "step2": "設定ルール" }, "pt_PT": { "conditionFilter": "URL", @@ -176,7 +192,9 @@ "enabled": "Ativado", "delayAllowed": "Mais 5 minutos", "delayAllowedInfo": "Se expirar, permita um atraso temporário de 5 minutos", - "operation": "Operações" + "operation": "Operações", + "visitTime": "Limitar por visita", + "period": "Períodos não permitidos" }, "button": { "test": "Testar URL", @@ -185,14 +203,14 @@ "message": { "saved": "Guardado com sucesso", "noUrl": "URL limitada não preenchida", - "noTime": "Tempo por dia não preenchido", "deleteConfirm": "Deseja excluir a regra de {cond}?", "deleted": "Apagado com sucesso", "noPermissionFirefox": "Por favor, ative a permissão da área de transferência para esta extensão na página de gestão (about:addons) primeiro", "inputTestUrl": "Por favor insira o link de URL para testar primeiro", "clickTestButton": "Após a entrada, por favor, clique no botão ({buttonText})", "noRuleMatched": "O URL não atinge nenhuma regra", - "rulesMatched": "A URL atinge as seguintes regras:" + "rulesMatched": "A URL atinge as seguintes regras:", + "noRule": "Nenhuma regra preenchida" }, "verification": { "inputTip": "Esta regra já foi acionada. Para modificá-la, por favor, digite a resposta para o seguinte promotor: {prompt}", @@ -202,6 +220,8 @@ "incorrectAnswer": "Resposta incorreta", "pi": "{digitCount} dígitos de {startIndex} a {endIndex} da parte decimal de π", "confession": "Tempo é dinheiro" - } + }, + "step1": "Configurar URL", + "step2": "Configurar regra" } } \ No newline at end of file diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index fb165e0a..805112db 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -13,9 +13,13 @@ export type LimitMessage = { addTitle: string useWildcard: string urlPlaceholder: string + step1: string + step2: string item: { condition: string time: string + visitTime: string + period: string enabled: string delayAllowed: string delayAllowedInfo: string @@ -28,7 +32,7 @@ export type LimitMessage = { } message: { noUrl: string - noTime: string + noRule: string saved: string deleteConfirm: string deleted: string diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 09f7afd9..c185f59f 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -12,7 +12,7 @@ "option": "扩展选项", "behavior": "上网行为", "habit": "上网习惯", - "limit": "每日时限设置", + "limit": "时间限制", "other": "其他", "feedback": "有什么反馈吗?", "rate": "打个分吧!", @@ -32,7 +32,7 @@ "option": "擴充選項", "behavior": "上網行爲", "habit": "上網習慣", - "limit": "每日時限設置", + "limit": "時間限制", "other": "其他", "feedback": "有什麼反饋嗎?", "rate": "打個分吧!", @@ -47,7 +47,7 @@ "dataClear": "Memory Situation", "behavior": "User Behavior", "habit": "Habits", - "limit": "Daily Limit", + "limit": "Time Limit", "additional": "Additional Features", "siteManage": "Site Management", "whitelist": "Whitelist", @@ -67,7 +67,7 @@ "dataClear": "記憶状況", "behavior": "ユーザーの行動", "habit": "閲覧の習慣", - "limit": "閲覧の制限", + "limit": "時間制限", "additional": "その他の機能", "siteManage": "ウェブサイト管理", "whitelist": "Webホワイトリスト", @@ -86,7 +86,7 @@ "dataClear": "Situação da Memória", "behavior": "Comportamento do Utilizador", "habit": "Hábitos", - "limit": "Limite diário", + "limit": "Limite de Tempo", "additional": "Características Adicionais", "siteManage": "Gestão do Site", "whitelist": "Lista Ignorada", diff --git a/src/i18n/message/common/content-script-resource.json b/src/i18n/message/common/content-script-resource.json index 15fa5fad..2bde3749 100644 --- a/src/i18n/message/common/content-script-resource.json +++ b/src/i18n/message/common/content-script-resource.json @@ -5,7 +5,10 @@ "timeWithHour": "{hour} 小时 {minute} 分 {second} 秒", "timeWithMinute": "{minute} 分 {second} 秒", "timeWithSecond": "{second} 秒", - "timeLimitMsg": "您已被【{appName}】限制上网", + "timeLimitMsg": "您已被限制上网", + "visitLimitMsg": "您已超过单次浏览时间限制", + "periodLimitMsg": "您在当前时间段已被限制浏览该网页", + "limitRefMsg": "——来自于【{appName}】的友情提醒", "more5Minutes": "再看 5 分钟!!我保证!" }, "zh_TW": { @@ -15,7 +18,10 @@ "timeWithMinute": "{minute} 分 {second} 秒", "timeWithSecond": "{second} 秒", "timeLimitMsg": "您已被【{appName}】限製上網", - "more5Minutes": "再看 5 分鐘!!我保証!" + "more5Minutes": "再看 5 分鐘!!我保証!", + "visitLimitMsg": "您已超過單次瀏覽時間限制", + "periodLimitMsg": "您在目前時段已被限制瀏覽該網頁", + "limitRefMsg": "——來自於【{appName}】的友誼提醒" }, "en": { "consoleLog": "You have open {host} for {time} time(s) and browsed it for {focus} today.", @@ -23,7 +29,10 @@ "timeWithHour": "{hour} hour(s) {minute} minute(s) {second} second(s)", "timeWithMinute": "{minute} minute(s) {second} second(s)", "timeWithSecond": "{second} second(s)", - "timeLimitMsg": "You have been restricted by [{appName}]", + "timeLimitMsg": "You have exceeded the browsing time limit per day", + "visitLimitMsg": "You have exceeded the browsing time limit per visit", + "periodLimitMsg": "You have been restricted during the current time period", + "limitRefMsg": "——Friendly reminder from [{appName}]", "more5Minutes": "More 5 minutes, please!!" }, "ja": { @@ -33,7 +42,10 @@ "timeWithMinute": "{minute} 分 {second} 秒", "timeWithSecond": "{second} 秒", "timeLimitMsg": "【{appName}】によって制限されています", - "more5Minutes": "さらに5分間見てください! ! 約束します!" + "more5Minutes": "さらに5分間見てください! ! 約束します!", + "visitLimitMsg": "閲覧時間を超えています", + "periodLimitMsg": "あなたは現在の期間中に制限されています", + "limitRefMsg": "——フレンドリーリマインダー:[{appName}]" }, "pt_PT": { "closeAlert": "Você pode desligar as dicas acima na opção do {appName}!", @@ -42,6 +54,9 @@ "timeWithSecond": "{second} segundo(s)", "timeLimitMsg": "Foi restrito por [{appName}]", "more5Minutes": "Mais 5 minutos, por favor!!", - "consoleLog": "Abriu {host} por {time} vez(es) e navegou por {focus} hoje." + "consoleLog": "Abriu {host} por {time} vez(es) e navegou por {focus} hoje.", + "visitLimitMsg": "Excedeu o tempo limite de navegação por visita", + "periodLimitMsg": "Foi restrito durante o período atual", + "limitRefMsg": "— Lembrete amigável de [{appName}]" } } \ No newline at end of file diff --git a/src/i18n/message/common/content-script.ts b/src/i18n/message/common/content-script.ts index 5da6e380..cade93cf 100644 --- a/src/i18n/message/common/content-script.ts +++ b/src/i18n/message/common/content-script.ts @@ -14,6 +14,9 @@ export type ContentScriptMessage = { timeWithMinute: string timeWithSecond: string timeLimitMsg: string + visitLimitMsg: string + periodLimitMsg: string + limitRefMsg: string more5Minutes: string } diff --git a/src/i18n/message/guide/limit-resource.json b/src/i18n/message/guide/limit-resource.json index 06a1a71c..64b48661 100644 --- a/src/i18n/message/guide/limit-resource.json +++ b/src/i18n/message/guide/limit-resource.json @@ -1,7 +1,7 @@ { "en": { - "title": "Limit browsing time of everyday", - "p1": "If you want to limit the time of browsing certain URLs each day, you can do so by creating a daily time limit rule.", + "title": "Limit browsing time", + "p1": "If you want to limit the time of browsing certain URLs, you can do so by creating a time limit rule.", "step": { "title": "Four steps to create a limit rule", "enter": "First, enter the management page{link}, click the menu item {menuItem}.", @@ -11,8 +11,8 @@ } }, "zh_CN": { - "title": "限制每天的浏览时间", - "p1": "如果你想限制每天浏览某些 URL 的时长,可以通过创建每日时限规则来完成。", + "title": "限制浏览时间", + "p1": "如果你想限制浏览某些 URL 的时长,可以通过创建时限规则来完成。", "step": { "title": "简单四步创建一个限制规则", "enter": "首先进入管理页{link},点击菜单项【{menuItem}】。", @@ -22,8 +22,8 @@ } }, "zh_TW": { - "title": "限制每天的瀏覽時間", - "p1": "如果你想限制每天瀏覽某些 URL 的時長,可以通過創建每日時限規則來完成。", + "title": "限制瀏覽時間", + "p1": "如果你想限制瀏覽某些 URL 的時長,可以透過建立時限規則來完成。", "step": { "title": "簡單四步創建一個限制規則", "enter": "首先進入管理頁{link},點擊菜單項【{menuItem}】。", @@ -33,8 +33,8 @@ } }, "ja": { - "title": "毎日の閲覧時間を制限する", - "p1": "特定の URL を毎日閲覧する時間を制限したい場合は、毎日の時間制限ルールを作成することでこれを行うことができます。", + "title": "閲覧時間の制限", + "p1": "特定の URL の表示時間を制限したい場合は、時間制限ルールを作成することで制限できます。", "step": { "title": "制限ルールを作成するための 4 つのステップ", "enter": "まず、管理ページ {link} に入り、メニュー項目{menuItem} をクリックします。", @@ -44,8 +44,8 @@ } }, "pt_PT": { - "title": "Limitar tempo de navegação de todos os dias", - "p1": "Se deseja limitar o tempo de navegação de certas URL por dia, pode fazê-lo criando uma regra diária de limite de tempo.", + "title": "Limite de navegação", + "p1": "Se deseja limitar o tempo de navegação de certas URL, pode fazê-lo criando uma regra de limite de tempo.", "step": { "title": "Quatro passos para criar uma regra limite", "enter": "Primeiro, digite a página de gestão{link}, clique no item de menu {menuItem}.", diff --git a/src/i18n/message/guide/privacy-resource.json b/src/i18n/message/guide/privacy-resource.json index 4526d6ad..5c292258 100644 --- a/src/i18n/message/guide/privacy-resource.json +++ b/src/i18n/message/guide/privacy-resource.json @@ -21,7 +21,7 @@ }, "clipboard": { "name": "剪切板内容", - "usage": "在设置每日时限规则时,为了操作方便,会读取剪切板内 URL", + "usage": "在设置时限规则时,为了操作方便,会读取剪切板内 URL", "optionalReason": "需要用户手动同意" } } diff --git a/src/service/limit-service/index.ts b/src/service/limit-service/index.ts index 3dbe6eb4..1ed6fa26 100644 --- a/src/service/limit-service/index.ts +++ b/src/service/limit-service/index.ts @@ -25,9 +25,11 @@ async function select(cond?: QueryParam): Promise { const today = formatTime(new Date(), DATE_FORMAT) return (await db.all()) .filter(item => filterDisabled ? item.enabled : true) - .map(({ cond, time, enabled, wasteTime, latestDate, allowDelay }) => ({ + .map(({ cond, time, visitTime, periods, enabled, wasteTime, latestDate, allowDelay }) => ({ cond, time, + visitTime, + periods, enabled: !!enabled, waste: latestDate === today ? (wasteTime ?? 0) : 0, latestDate, @@ -37,12 +39,19 @@ async function select(cond?: QueryParam): Promise { .filter(item => !url || matches(item, url)) } +async function noticePeriodChanged() { + const tabs = await listTabs() + tabs.forEach(tab => sendMsg2Tab(tab?.id, 'limitPeriodChange', undefined) + .catch(err => console.log(err.message)) + ) +} + /** * Fired if the item is removed or disabled * - * @param item + * @param item */ -async function handleLimitChanged() { +async function noticeLimitChanged() { const allItems: timer.limit.Item[] = await select({ filterDisabled: false, url: undefined }) const tabs = await listTabs() tabs.forEach(tab => { @@ -53,20 +62,22 @@ async function handleLimitChanged() { } async function updateEnabled(item: timer.limit.Item): Promise { - const { cond, time, enabled, allowDelay } = item - const limit: timer.limit.Rule = { cond, time, enabled, allowDelay } + const { cond, time, enabled, allowDelay, visitTime, periods } = item + const limit: timer.limit.Rule = { cond, time, enabled, allowDelay, visitTime, periods } await db.save(limit, true) - await handleLimitChanged() + await noticeLimitChanged() + await noticePeriodChanged() } async function updateDelay(item: timer.limit.Item) { await db.updateDelay(item.cond, item.allowDelay) - await handleLimitChanged() + await noticeLimitChanged() } async function remove(item: timer.limit.Item): Promise { await db.remove(item.cond) - await handleLimitChanged() + await noticeLimitChanged() + await noticePeriodChanged() } async function getLimited(url: string): Promise { @@ -77,6 +88,12 @@ async function getLimited(url: string): Promise { return list } +async function getRelated(url: string): Promise { + return (await select()) + .filter(item => item.enabled) + .filter(item => matches(item, url)) +} + /** * Add time * @param url url @@ -115,13 +132,27 @@ async function moreMinutes(url: string, rules?: timer.limit.Item[]): Promise(arr: T[], count?: number, rightOrLeft?: boolean): void */ export function sum(arr: number[]): number { return arr?.reduce?.((a, b) => (a || 0) + (b || 0), 0) || 0 +} + +export function allMatch(arr: T[], predicate: (t: T) => boolean): boolean { + return !arr?.filter?.(e => !predicate?.(e))?.length +} + +export function anyMatch(arr: T[], predicate: (t: T) => boolean): boolean { + return !!arr?.filter?.(e => predicate?.(e))?.length } \ No newline at end of file diff --git a/src/util/limit.ts b/src/util/limit.ts index 68406c55..a4e14e33 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -4,5 +4,42 @@ export function matches(item: timer.limit.Item, url: string): boolean { } export function hasLimited(item: timer.limit.Item): boolean { - return (item.waste ?? 0) >= (item.time ?? 0) * 1000 -} \ No newline at end of file + const { time, waste = 0 } = item || {} + if (!time) return false + return waste >= time * 1000 +} + +export const checkImpact = (p1: timer.limit.Period, p2: timer.limit.Period): boolean => { + if (!p1 || !p2) return false + const [s1, e1] = p1 + const [s2, e2] = p2 + return (s1 >= s2 && s1 <= e2) || (s2 >= s1 && s2 <= e1) +} + +export const mergePeriod = (target: timer.limit.Period, toMerge: timer.limit.Period) => { + if (!target || !toMerge) return + target[0] = Math.min(target[0], toMerge[0]) + target[1] = Math.max(target[1], toMerge[1]) +} + +export const sortPeriod = (p1: timer.limit.Period, p2: timer.limit.Period): number => { + const [s1 = 0, e1 = 0] = p1 || [] + const [s2 = 0, e2 = 0] = p2 || [] + return s1 === s2 ? e1 - e2 : s1 - s2 +} + +const idx2Str = (time: number): string => { + time = time ?? 0 + const hour = Math.floor(time / 60) + const min = time - hour * 60 + const hourStr = (hour < 10 ? "0" : "") + hour + const minStr = (min < 10 ? "0" : "") + min + return `${hourStr}:${minStr}` +} + +export const date2Idx = (date: Date): number => date.getHours() * 60 * 60 + date.getMinutes() * 60 + date.getSeconds() + +export const period2Str = (p: timer.limit.Period): string => { + const [start, end] = p || [] + return `${idx2Str(start)}-${idx2Str(end)}` +} diff --git a/test/database/limit-database.test.ts b/test/database/limit-database.test.ts index 58083c1a..0b66d348 100644 --- a/test/database/limit-database.test.ts +++ b/test/database/limit-database.test.ts @@ -98,7 +98,7 @@ describe('limit-database', () => { expect(cond2After?.enabled).toEqual(cond2.enabled) // Not complete const cond3After = imported.find(a => a.cond === "cond3") - expect(cond3After.time).toEqual(0) + expect(cond3After.time).toBeUndefined() expect(cond3After.enabled).toBeFalsy() expect(cond3After.allowDelay).toBeFalsy() }) diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts index e3f798c9..730a81e0 100644 --- a/types/timer/limit.d.ts +++ b/types/timer/limit.d.ts @@ -1,4 +1,11 @@ declare namespace timer.limit { + /** + * Restricted periods + * [0, 1] means from 00:00 to 00:01 + * [0, 120] means from 00:00 to 02:00 + * @since 2.0.0 + */ + type Period = [number, number] /** * Limit rule in runtime * @@ -22,20 +29,15 @@ declare namespace timer.limit { /** * Time limit per visit, seconds * - * @since 1.9.5 + * @since 2.0.0 */ visitTime?: number - /** - * Whether to notification - * - * @since 1.9.5 - */ - notification?: boolean - enabled: boolean + enabled?: boolean /** * Allow to delay 5 minutes if time over */ - allowDelay: boolean + allowDelay?: boolean + periods?: Period[] } type Record = Rule & { /** diff --git a/types/timer/mq.d.ts b/types/timer/mq.d.ts index 17fe4195..3e3e349c 100644 --- a/types/timer/mq.d.ts +++ b/types/timer/mq.d.ts @@ -9,6 +9,12 @@ declare namespace timer.mq { | 'limitWaking' // @since 1.2.3 | 'limitChanged' + // @since 2.0.0 + | 'limitPeriodChange' + | 'moreVisitMinutes' + // @since 2.0.0 + | 'askVisitTime' + | 'askHitVisit' // Request by content script // @since 1.3.0 | "cs.isInWhitelist" @@ -17,6 +23,7 @@ declare namespace timer.mq { | "cs.getTodayInfo" | "cs.moreMinutes" | "cs.getLimitedRules" + | "cs.getRelatedRules" | "cs.trackTime" type ResCode = "success" | "fail" | "ignore" diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 43f3a50f..fa1bf92e 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -100,12 +100,6 @@ declare namespace timer.option { */ darkModeTimeStart?: number darkModeTimeEnd?: number - /** - * The filter of limit mark - * @since 1.3.2 - * @deprecated moved to DailyLimitOption @since 1.9.0 - */ - limitMarkFilter?: limit.FilterType } type StatisticsOption = {