diff --git a/src/background/database/cate-database.ts b/src/background/database/cate-database.ts index 2d98ecfbe..da4370846 100644 --- a/src/background/database/cate-database.ts +++ b/src/background/database/cate-database.ts @@ -15,6 +15,10 @@ type Item = { * Name */ n: string + /** + * Auto rules + */ + a?: string[] } type Items = Record @@ -36,43 +40,59 @@ class CateDatabase extends BaseDatabase { async listAll(): Promise { const items = await this.getItems() - return Object.entries(items).map(([id, { n = '' } = {}]) => { + return Object.entries(items).map(([id, { n = '', a } = {}]) => { return { id: parseInt(id), name: n, + autoRules: a ?? [], } satisfies timer.site.Cate }) } - async add(name: string): Promise { + async add(toAdd: Omit): Promise { + const { name, autoRules } = toAdd const items = await this.getItems() const existId = Object.entries(items).find(([_, v]) => v.n === name)?.[0] if (existId) { // Exist already - return { id: parseInt(existId), name } + return { id: parseInt(existId), name, autoRules } } const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1 - items[id] = { n: name ?? items[id]?.n } + items[id] = { n: name ?? items[id]?.n, a: autoRules } await this.saveItems(items) - return { name, id } + return { name, id, autoRules } } - async update(id: number, name: string): Promise { - if (!name) return - + private async updateWithReplacer(id: number, replacer: (exist: Item) => Item): Promise { const items = await this.getItems() - const existId = Object.entries(items).find(([_, v]) => v.n === name)?.[0] + const exist = items[id] + if (!exist) return - if (existId) { + const replaced = replacer(exist) + + if (Object.entries(items).some(([vid, v]) => v.n === replaced.n && parseInt(vid) !== id)) { + // Name exist already return } - items[id] = { ...items[id] || {}, n: name } + items[id] = replaced await this.saveItems(items) } + async updateName(id: number, name: string): Promise { + await this.updateWithReplacer(id, exist => ({ ...exist, n: name })) + } + + async update(cate: timer.site.Cate): Promise { + await this.updateWithReplacer(cate.id, exist => ({ + ...exist, + n: cate.name, + a: cate.autoRules, + })) + } + async delete(id: number): Promise { const items = await this.getItems() diff --git a/src/background/install-handler/version/cate-initializer.ts b/src/background/install-handler/version/cate-initializer.ts index 09ee420e3..6574b23ba 100644 --- a/src/background/install-handler/version/cate-initializer.ts +++ b/src/background/install-handler/version/cate-initializer.ts @@ -31,7 +31,7 @@ const DEMO_ITEMS: InitialCate[] = [ async function initItem(item: InitialCate) { const { name, hosts } = item - const cate = await cateDatabase.add(name) + const cate = await cateDatabase.add({ name, autoRules: [] }) const cateId = cate.id const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)) await batchChangeCate(cateId, siteKeys) diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 9744050e5..2e57a77cc 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -100,8 +100,8 @@ class MessageDispatcher { .register('option.weekStartTime', getWeekStartTime) // Category .register('cate.all', () => cateDatabase.listAll()) - .register('cate.add', name => cateDatabase.add(name)) - .register('cate.change', ({ id, name }) => cateDatabase.update(id, name)) + .register('cate.add', data => cateDatabase.add(data)) + .register('cate.change', data => cateDatabase.update(data)) .register('cate.delete', id => cateDatabase.delete(id)) // Meta information .register('meta.installTs', getInstallTime) diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 745564ff2..076dcd63f 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -113,7 +113,8 @@ "relatedMsg": "This category has been associated with {siteCount} sites and cannot be deleted", "removeConfirm": "Confirm to delete category: {category}?", "batchChange": "Change categories", - "batchDisassociate": "Disassociate categories" + "batchDisassociate": "Disassociate categories", + "autoRules": "Auto-categorization rules" }, "form": { "emptyAlias": "Please enter site name", diff --git a/src/i18n/message/app/site-manage.ts b/src/i18n/message/app/site-manage.ts index 3b4ba75c0..2113c7159 100644 --- a/src/i18n/message/app/site-manage.ts +++ b/src/i18n/message/app/site-manage.ts @@ -23,6 +23,7 @@ export type SiteManageMessage = { batchChange: string batchDisassociate: string removeConfirm: string + autoRules: string } form: { emptyAlias: string diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 0393addef..432f79d6b 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -80,6 +80,12 @@ const SiteOption = defineComponent<{ value: timer.site.SiteInfo }>(props => { ) }, { props: ['value'] }) +const notSetCate = (): timer.site.Cate => ({ + id: CATE_NOT_SET_ID, + name: t(msg => msg.shared.cate.notSet), + autoRules: [], +}) + const TargetSelect = defineComponent(() => { const cate = useCategory() @@ -90,7 +96,7 @@ const TargetSelect = defineComponent(() => { }) const { data: allItems } = useRequest( - () => fetchItems([...cate.all, { id: CATE_NOT_SET_ID, name: t(msg => msg.shared.cate.notSet) }]), + () => fetchItems([...cate.all, notSetCate()]), { defaultValue: [[], []], deps: [() => cate.all] }, ) diff --git a/src/pages/app/components/Report/Table/index.tsx b/src/pages/app/components/Report/Table/index.tsx index b57f17b0d..7de74ac09 100644 --- a/src/pages/app/components/Report/Table/index.tsx +++ b/src/pages/app/components/Report/Table/index.tsx @@ -105,6 +105,13 @@ const _default = defineComponent((_, ctx) => { () => filter.siteMerge, ], () => table.value?.doLayout?.()) + const handleCateChange = (key: timer.site.SiteKey, newCateId: number | undefined) => { + data.value?.list + ?.filter(isSite) + ?.filter(item => siteEqual(item.siteKey, key)) + ?.forEach(i => i.cateId = newCateId) + } + return () => ( @@ -135,7 +142,7 @@ const _default = defineComponent((_, ctx) => { /> } {visible.value.group && } - {visible.value.cate && } + {visible.value.cate && } {runColVisible.value && } diff --git a/src/pages/app/components/common/Category/CategoryDialog.tsx b/src/pages/app/components/common/Category/CategoryDialog.tsx new file mode 100644 index 000000000..34452ba91 --- /dev/null +++ b/src/pages/app/components/common/Category/CategoryDialog.tsx @@ -0,0 +1,106 @@ + +import { t } from '@app/locale' +import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElInputTag, ElMessage } from 'element-plus' +import { createVNode, defineComponent, reactive, ref, render } from 'vue' + +type Props = { + cate?: timer.site.Cate + onClose?: NoArgCallback + onSave?: (cate: timer.site.Cate) => void +} + +const Component = defineComponent(props => { + const visible = ref(true) + const formData = reactive>({ ...props.cate }) + + const handleConfirm = () => { + + } + + const handleRulesChange = (rules: string[] | undefined = []) => { + if (new Set(rules).size < rules.length) { + ElMessage.warning('Rules contain duplicated items') + } else { + formData.autoRules = rules + } + } + + return () => ( + msg.button.modify) : t(msg => msg.button.create)} + width={400} + destroyOnClose + beforeClose={props.onClose} + closeOnClickModal={false} + v-slots={{ + footer: () => <> + {t(msg => msg.button.cancel)} + + {t(msg => msg.button.confirm)} + + + }} + > + + msg.siteManage.cate.name)} prop="name" required> + + + msg.siteManage.cate.autoRules)} prop="autoRules"> + + + + + ) +}, { props: ['cate', 'onClose', 'onSave'] }) + +interface DialogOptions { + cate?: timer.site.Cate +} + +function open(options: DialogOptions = {}): Promise { + const { cate } = options + + return new Promise((resolve, reject) => { + const container = document.createElement('div') + document.body.appendChild(container) + + const cleanup = () => { + render(null, container) + document.body.removeChild(container) + } + + const vnode = createVNode(Component, { + cate, + onClose: async (data: timer.site.Cate) => { + try { + resolve(data) + } catch (error) { + reject(error) + } finally { + cleanup() + } + }, + onCancel: () => { + reject() + cleanup() + } + }) + + render(vnode, container) + }) +} + +type CategoryDialogDef = typeof Component & { + open: typeof open +} + +const CategoryDialog = Component as CategoryDialogDef +CategoryDialog.open = open + +export default CategoryDialog \ No newline at end of file diff --git a/src/pages/app/components/common/Category/Editable.tsx b/src/pages/app/components/common/Category/Editable.tsx index 3938fa3e0..322a378b9 100644 --- a/src/pages/app/components/common/Category/Editable.tsx +++ b/src/pages/app/components/common/Category/Editable.tsx @@ -5,7 +5,7 @@ import { useManualRequest, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" import { supportCategory } from "@util/site" import { ElIcon, ElTag } from "element-plus" -import { computed, defineComponent, nextTick, ref } from "vue" +import { computed, defineComponent, ref } from "vue" import Select, { type Instance } from "./Select" type Props = ModelValue & { @@ -34,10 +34,9 @@ const CategoryEditable = defineComponent(props => { }, }) - const handleEditClick = (ev: MouseEvent) => { - ev.stopImmediatePropagation() + const handleEdit = () => { openEditing() - nextTick(() => selectRef.value?.openOptions?.()) + setTimeout(() => selectRef.value?.openOptions(), 100) } const selectRef = ref() @@ -64,7 +63,7 @@ const CategoryEditable = defineComponent(props => { {current.value.name} } - + diff --git a/src/pages/app/components/common/Category/Select/OptionItem.tsx b/src/pages/app/components/common/Category/Select/OptionItem.tsx index 0e2a0cf42..8ce4ace9a 100644 --- a/src/pages/app/components/common/Category/Select/OptionItem.tsx +++ b/src/pages/app/components/common/Category/Select/OptionItem.tsx @@ -1,128 +1,42 @@ -import { sendMsg2Runtime } from '@api/sw/common' -import { listSites } from "@api/sw/site" -import { useCategory } from "@app/context" -import { t } from "@app/locale" -import { Check, Close, Delete, Edit } from "@element-plus/icons-vue" -import { useManualRequest, useRequest, useState, useSwitch } from "@hooks" +import { Delete, Edit } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import { stopPropagationAfter } from "@util/document" -import { ElButton, ElInput, ElMessage, ElMessageBox } from "element-plus" -import { defineComponent, nextTick, ref } from "vue" - -const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { - const cate = useCategory() - - const [editing, openEditing, closeEditing] = useSwitch(false) - const [editingName, setEditingName] = useState(props.value.name) - const inputRef = ref() - - const { refresh: saveCate } = useManualRequest( - (name: string) => sendMsg2Runtime('cate.change', { id: props.value.id, name }), - { - onSuccess: () => { - cate.refresh() - closeEditing() - } - }, - ) - - const onSaveClick = () => { - const name = editingName.value?.trim?.() - name ? saveCate(name) : ElMessage.warning("Name is blank") - } - - const { refresh: doRemoveCate } = useManualRequest(() => sendMsg2Runtime('cate.delete', props.value.id), { - onSuccess: () => { - cate.refresh() - ElMessage.success(t(msg => msg.operation.successMsg)) - } - }) - const { data: relatedSites, loading: queryingSites } = useRequest(() => listSites({ cateIds: props.value?.id })) - - const onRemoveClick = (e: MouseEvent) => { +import { ElButton } from "element-plus" +import { defineComponent } from "vue" + +type Props = { + value: timer.site.Cate + onEdit?: ArgCallback + onDelete?: ArgCallback + onSelect?: NoArgCallback +} + +const OptionItem = defineComponent(props => { + const handleClick = (e: MouseEvent) => { e.stopPropagation() - - const siteCount = relatedSites.value?.length ?? 0 - if (siteCount) { - ElMessage.warning(t(msg => msg.siteManage.cate.relatedMsg, { siteCount })) - return - } - ElMessageBox.confirm('', { - message: t(msg => msg.siteManage.cate.removeConfirm, { category: props.value?.name }), - type: 'warning', - closeOnClickModal: false, - }).then(doRemoveCate).catch(() => { }) - } - - const onEditClick = () => { - setEditingName(props.value.name) - openEditing() - nextTick(() => inputRef.value?.focus?.()) + props.onSelect?.() } return () => ( - editing.value && ev.stopPropagation()} - > - {editing.value ? <> - - { - const { key } = ev as KeyboardEvent - if (key === 'Enter') { - onSaveClick() - } else if (key === 'Escape') { - stopPropagationAfter(ev, closeEditing) - } - }} - /> - - - stopPropagationAfter(ev, closeEditing)} - /> - - - : <> - {props.value.name} - - stopPropagationAfter(e, onEditClick)} - /> - - - } + + {props.value.name} + + + + ) -}, { props: ['value'] }) +}, { props: ['value', 'onEdit', 'onDelete', 'onSelect'] }) export default OptionItem \ No newline at end of file diff --git a/src/pages/app/components/common/Category/Select/SelectFooter.tsx b/src/pages/app/components/common/Category/Select/SelectFooter.tsx index 621a1ea97..40a260bbf 100644 --- a/src/pages/app/components/common/Category/Select/SelectFooter.tsx +++ b/src/pages/app/components/common/Category/Select/SelectFooter.tsx @@ -14,7 +14,7 @@ const SelectFooter = defineComponent(() => { const [name, setName] = useState() const { refresh: saveCate, loading } = useManualRequest( - (name: string) => sendMsg2Runtime('cate.add', name), + (name: string) => sendMsg2Runtime('cate.add', { name, autoRules: [] }), { onSuccess() { cate.refresh() diff --git a/src/pages/app/components/common/Category/Select/index.tsx b/src/pages/app/components/common/Category/Select/index.tsx index 47f2476c2..50921da8d 100644 --- a/src/pages/app/components/common/Category/Select/index.tsx +++ b/src/pages/app/components/common/Category/Select/index.tsx @@ -1,7 +1,13 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { listSites } from '@api/sw/site' import { useCategory } from "@app/context" +import { t } from "@app/locale" +import { ArrowDown, CircleClose } from "@element-plus/icons-vue" +import { useManualRequest, useState } from '@hooks' import { CATE_NOT_SET_ID } from '@util/site' -import { ElOption, ElSelect, type SelectInstance } from "element-plus" -import { defineComponent, ref } from "vue" +import { ElIcon, ElMessage, ElMessageBox, ElScrollbar, ElTooltip, useNamespace, type TooltipInstance } from "element-plus" +import { computed, defineComponent, ref } from "vue" +import CategoryDialog from '../CategoryDialog' import OptionItem from "./OptionItem" import SelectFooter from "./SelectFooter" @@ -18,33 +24,199 @@ type Props = { onChange?: ArgCallback } -const Select = defineComponent((props, ctx) => { +const useCategorySelect = (props: Props) => { const cate = useCategory() + const [visible, setVisible] = useState(false) + const [visibleLocked, setVisibleLocked] = useState(false) + const tooltipRef = ref() + + // Find selected category + const selectedCategory = computed(() => + cate.all.find(c => c.id === props.modelValue) + ) + + // Handle visible change + const handleVisibleChange = (newVisible: boolean) => { + if (visibleLocked.value && !newVisible) { + // Force keep open when locked + return + } + setVisible(newVisible) + props.onVisibleChange?.(newVisible) + } + + // Select option + const selectOption = (value: number) => { + props.onChange?.(value) + if (!visibleLocked.value) { + setVisible(false) + } + } + + // Clear selection + const handleClearClick = (e: Event) => { + e.stopPropagation() + props.onChange?.(undefined) + } + + // Delete category + const { refreshAsync: removeCate } = useManualRequest( + (id: number) => sendMsg2Runtime('cate.delete', id), + { + onSuccess: () => { + cate.refresh() + ElMessage.success(t(msg => msg.operation.successMsg)) + } + } + ) + + const deleteCategory = async (category: timer.site.Cate, e: MouseEvent) => { + e.stopPropagation() + + // Check related sites + const sites = await listSites({ cateIds: category.id }) + const siteCount = sites.length + if (siteCount) { + ElMessage.warning(t(msg => msg.siteManage.cate.relatedMsg, { siteCount })) + return + } + + // Confirm and delete + try { + await ElMessageBox.confirm('', { + message: t(msg => msg.siteManage.cate.removeConfirm, { category: category.name }), + type: 'warning', + closeOnClickModal: false, + }) + await removeCate(category.id) + } catch { + // User cancelled + } + } + + // Edit category + const editCategory = async (category: timer.site.Cate, e: MouseEvent) => { + e.stopPropagation() + + setVisibleLocked(true) + setVisible(true) + + try { + const edited = await CategoryDialog.open({ cate: category }) + await sendMsg2Runtime('cate.change', edited) + cate.refresh() + } catch { + // User cancelled + } finally { + setVisibleLocked(false) + } + } + + return { + cate, + visible, + tooltipRef, + selectedCategory, + handleVisibleChange, + selectOption, + handleClearClick, + deleteCategory, + editCategory, + } +} + +const Select = defineComponent((props, ctx) => { + const { + cate, + visible, + tooltipRef, + selectedCategory, + handleVisibleChange, + selectOption, + handleClearClick, + deleteCategory, + editCategory, + } = useCategorySelect(props) + + const ns = useNamespace('select') + + const showClearIcon = computed(() => + props.clearable && props.modelValue !== undefined && props.modelValue !== CATE_NOT_SET_ID + ) - const selectRef = ref() ctx.expose({ - openOptions: () => selectRef.value?.selectOption?.() + openOptions: () => visible.value = true } satisfies Instance) return () => ( - props.onChange?.(val)} - onVisible-change={visible => props.onVisibleChange?.(visible)} - style={{ width: props.width || '100%' }} - clearable={props.clearable} - onClear={() => props.onChange?.(undefined)} - emptyValues={[CATE_NOT_SET_ID, undefined]} - v-slots={{ footer: () => }} - > - {cate.all.map(c => ( - - - - ))} - +
+ ( +
+
+
+
+ {selectedCategory.value?.name || 'Select category'} +
+
+
+
+ {showClearIcon.value && ( + + + + + + )} + + + +
+
+ ), + content: () => ( +
+ + {cate.all.map(c => ( +
selectOption(c.id)} + > + editCategory(c, e)} + onDelete={e => deleteCategory(c, e)} + onSelect={() => selectOption(c.id)} + /> +
+ ))} +
+
+ +
+
+ ) + }} + /> +
) }, { props: ['clearable', 'modelValue', 'size', 'width', 'onVisibleChange', 'onChange'] }) diff --git a/src/pages/app/components/common/filter/CategoryFilter.tsx b/src/pages/app/components/common/filter/CategoryFilter.tsx index 438cfc3ce..f59e40ff7 100644 --- a/src/pages/app/components/common/filter/CategoryFilter.tsx +++ b/src/pages/app/components/common/filter/CategoryFilter.tsx @@ -34,4 +34,4 @@ const CategoryFilter = defineComponent(props => { ) : null }, { props: ['modelValue', 'onChange', 'disabled', 'useCache'] }) -export default CategoryFilter \ No newline at end of file +export default CategoryFilter diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index 058e62ccc..1a2f163e2 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -7,6 +7,7 @@ export * from "./useElementSize" export * from "./useLocalStorage" export * from "./useManualRequest" export * from "./useMediaSize" +export * from "./useMounted" export * from "./useProvider" export * from "./useRequest" export * from "./useScrollRequest" diff --git a/src/pages/hooks/useMounted.ts b/src/pages/hooks/useMounted.ts new file mode 100644 index 000000000..46471f921 --- /dev/null +++ b/src/pages/hooks/useMounted.ts @@ -0,0 +1,10 @@ +import { onMounted, onUnmounted } from 'vue' + +type MountedCallback = () => void | Promise | (() => void | Promise) + +export const useMounted = (callback: MountedCallback) => { + onMounted(() => { + const cleanup = callback() + if (typeof cleanup === 'function') onUnmounted(() => cleanup()) + }) +} \ No newline at end of file diff --git a/types/timer/message.d.ts b/types/timer/message.d.ts index d5a86623c..2227a768f 100644 --- a/types/timer/message.d.ts +++ b/types/timer/message.d.ts @@ -44,7 +44,7 @@ declare namespace timer.mq { & _MakeRegistry<'item.batch', core.RowKey[], core.Row[]> // Category & _MakeRegistry<'cate.all', undefined, site.Cate[]> - & _MakeRegistry<'cate.add', string, site.Cate> + & _MakeRegistry<'cate.add', Omit, site.Cate> & _MakeRegistry<'cate.change', site.Cate> & _MakeRegistry<'cate.delete', number> // Option diff --git a/types/timer/site.d.ts b/types/timer/site.d.ts index 73a7d054d..70ad05f01 100644 --- a/types/timer/site.d.ts +++ b/types/timer/site.d.ts @@ -28,6 +28,7 @@ declare namespace timer.site { type Cate = { id: number name: string + autoRules: string[] } type Query = {