create-windy 0.2.34 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +167 -17
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +5 -0
- package/template/README.md +3 -1
- package/template/apps/agent-server/Dockerfile +1 -0
- package/template/apps/agent-server/README.md +18 -5
- package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
- package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
- package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
- package/template/apps/agent-server/pyproject.toml +1 -1
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
- package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +1 -2
- package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
- package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
- package/template/apps/server/package.json +4 -1
- package/template/apps/server/src/agent/run-facade.ts +2 -7
- package/template/apps/server/src/agent/tool-registry.ts +2 -6
- package/template/apps/server/src/application-services.ts +50 -0
- package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
- package/template/apps/server/src/audit/search-summary.ts +2 -6
- package/template/apps/server/src/auth/credential-restriction.ts +1 -0
- package/template/apps/server/src/auth/password.ts +30 -21
- package/template/apps/server/src/auth/routes.test.ts +44 -3
- package/template/apps/server/src/auth/routes.ts +6 -1
- package/template/apps/server/src/auth/runtime.ts +4 -1
- package/template/apps/server/src/auth/service.ts +25 -7
- package/template/apps/server/src/configuration/bootstrap.ts +20 -1
- package/template/apps/server/src/configuration/definition.test.ts +71 -0
- package/template/apps/server/src/configuration/definition.ts +242 -12
- package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
- package/template/apps/server/src/configuration/repository.ts +27 -2
- package/template/apps/server/src/configuration/routes.ts +4 -1
- package/template/apps/server/src/configuration/service.test.ts +100 -0
- package/template/apps/server/src/configuration/service.ts +50 -10
- package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
- package/template/apps/server/src/data-governance/export/service.ts +2 -6
- package/template/apps/server/src/example-modules.ts +4 -1
- package/template/apps/server/src/guards.ts +18 -5
- package/template/apps/server/src/http/request-source.test.ts +85 -0
- package/template/apps/server/src/http/request-source.ts +90 -0
- package/template/apps/server/src/index.ts +23 -6
- package/template/apps/server/src/license/routes.ts +2 -0
- package/template/apps/server/src/license/runtime-service.ts +10 -11
- package/template/apps/server/src/module-bootstrap.ts +2 -2
- package/template/apps/server/src/module-host/request-context.ts +6 -6
- package/template/apps/server/src/module-host/storage-scope.ts +2 -9
- package/template/apps/server/src/module-host/user-directory-port.ts +2 -6
- package/template/apps/server/src/notification/channel-adapter.ts +46 -0
- package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
- package/template/apps/server/src/notification/delivery-policy.ts +37 -0
- package/template/apps/server/src/notification/delivery-repository.ts +198 -0
- package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
- package/template/apps/server/src/notification/delivery-worker.ts +88 -0
- package/template/apps/server/src/notification/drizzle-management.ts +180 -0
- package/template/apps/server/src/notification/drizzle-query.ts +160 -0
- package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
- package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
- package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
- package/template/apps/server/src/notification/extension-routes.ts +111 -0
- package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
- package/template/apps/server/src/notification/memory-support.ts +72 -0
- package/template/apps/server/src/notification/preference-repository.ts +95 -0
- package/template/apps/server/src/notification/recipient-directory.ts +104 -0
- package/template/apps/server/src/notification/repository.test.ts +171 -0
- package/template/apps/server/src/notification/repository.ts +179 -32
- package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
- package/template/apps/server/src/notification/route-support.ts +225 -0
- package/template/apps/server/src/notification/routes.test.ts +182 -0
- package/template/apps/server/src/notification/routes.ts +225 -132
- package/template/apps/server/src/notification/service.test.ts +104 -0
- package/template/apps/server/src/notification/service.ts +140 -0
- package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
- package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
- package/template/apps/server/src/persistence.integration.test.ts +41 -2
- package/template/apps/server/src/route-guards.ts +1 -0
- package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
- package/template/apps/server/src/runtime-development.ts +1 -0
- package/template/apps/server/src/runtime.test.ts +23 -9
- package/template/apps/server/src/runtime.ts +39 -2
- package/template/apps/server/src/scheduler/audit.ts +2 -11
- package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
- package/template/apps/server/src/scheduler/definitions.ts +7 -0
- package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
- package/template/apps/server/src/scheduler/routes.ts +2 -7
- package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
- package/template/apps/server/src/search/opensearch-client.ts +145 -0
- package/template/apps/server/src/search/service.ts +2 -6
- package/template/apps/server/src/settings/routes.ts +15 -3
- package/template/apps/server/src/settings/runtime.ts +10 -1
- package/template/apps/server/src/storage/runtime.ts +13 -0
- package/template/apps/server/src/system/audit-query.ts +33 -4
- package/template/apps/server/src/system/audit-routes.test.ts +58 -4
- package/template/apps/server/src/system/audit-routes.ts +6 -5
- package/template/apps/server/src/system/department-organization.ts +320 -0
- package/template/apps/server/src/system/department-routes.test.ts +207 -48
- package/template/apps/server/src/system/department-routes.ts +114 -33
- package/template/apps/server/src/system/drizzle-repository.ts +15 -7
- package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
- package/template/apps/server/src/system/drizzle-system.ts +37 -9
- package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
- package/template/apps/server/src/system/identity-route-config.ts +7 -2
- package/template/apps/server/src/system/mutation-runner.ts +23 -0
- package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
- package/template/apps/server/src/system/resource-access-response.ts +12 -6
- package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
- package/template/apps/server/src/system/route-helpers.ts +2 -5
- package/template/apps/server/src/system/route-types.ts +12 -3
- package/template/apps/server/src/system/routes-validation.test.ts +26 -0
- package/template/apps/server/src/system/routes.test.ts +10 -2
- package/template/apps/server/src/system/routes.ts +70 -36
- package/template/apps/server/src/system/seed.ts +7 -0
- package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
- package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
- package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
- package/template/apps/server/src/work-order/search-provider.ts +60 -1
- package/template/apps/server/src/work-order/service.ts +42 -10
- package/template/apps/web/src/app/error-pages.ts +9 -0
- package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
- package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
- package/template/apps/web/src/components/common/FormDialog.vue +16 -2
- package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
- package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
- package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
- package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
- package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
- package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
- package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
- package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
- package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
- package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
- package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
- package/template/apps/web/src/lib/date-time.ts +22 -10
- package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
- package/template/apps/web/src/main.ts +14 -1
- package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
- package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
- package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
- package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
- package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
- package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
- package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
- package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
- package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
- package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
- package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
- package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
- package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
- package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
- package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
- package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
- package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
- package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
- package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
- package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
- package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
- package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
- package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
- package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
- package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
- package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
- package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
- package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
- package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
- package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
- package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
- package/template/apps/web/src/pages/system/resource-config.ts +12 -4
- package/template/apps/web/src/router/error-navigation.ts +50 -0
- package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
- package/template/apps/web/src/router/index.ts +71 -0
- package/template/apps/web/src/router/index.webtest.ts +29 -0
- package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
- package/template/apps/web/src/services/auth-api.ts +5 -0
- package/template/apps/web/src/services/http.ts +43 -6
- package/template/apps/web/src/services/http.webtest.ts +70 -0
- package/template/apps/web/src/services/notification-api.ts +85 -2
- package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
- package/template/apps/web/src/services/search-api.ts +39 -0
- package/template/apps/web/src/services/system-api.ts +18 -0
- package/template/apps/web/vitest.config.ts +1 -0
- package/template/docker-compose.yml +66 -0
- package/template/docs/architecture/ai-runtime.md +14 -5
- package/template/docs/architecture/search-provider-protocol.md +38 -2
- package/template/docs/development/custom-error-pages.md +78 -0
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/platform/agent-runtime.md +32 -6
- package/template/docs/platform/audit-reliability.md +46 -2
- package/template/docs/platform/module-configuration.md +13 -4
- package/template/docs/platform/notifications.md +67 -11
- package/template/docs/platform/organization-membership.md +17 -3
- package/template/docs/platform/system-crud-api.md +16 -0
- package/template/docs/platform/web-system-crud.md +6 -2
- package/template/package.json +4 -0
- package/template/packages/config/index.test.ts +45 -0
- package/template/packages/config/package.json +1 -1
- package/template/packages/config/src/platform.ts +169 -48
- package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
- package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
- package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
- package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
- package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
- package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
- package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
- package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
- package/template/packages/database/drizzle/meta/_journal.json +28 -0
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/schema/identity.ts +7 -0
- package/template/packages/database/src/schema/notifications.ts +135 -0
- package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
- package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-api-permissions.ts +54 -0
- package/template/packages/modules/src/system-audit-actions.ts +10 -0
- package/template/packages/modules/src/system-features.ts +1 -1
- package/template/packages/modules/src/system-module-catalog.ts +2 -1
- package/template/packages/modules/src/system-modules.test.ts +13 -1
- package/template/packages/modules/src/system-modules.ts +12 -0
- package/template/packages/modules/src/system-permissions.ts +9 -5
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/audit.ts +25 -0
- package/template/packages/shared/src/module-configuration.ts +5 -0
- package/template/packages/shared/src/notification.ts +92 -1
- package/template/packages/shared/src/password.ts +7 -2
- package/template/packages/shared/src/platform-settings.ts +2 -0
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage/src/upload-service.test.ts +16 -0
- package/template/packages/storage/src/upload-service.ts +5 -1
- package/template/scripts/notification-postgres-regression.ts +27 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readonly, shallowRef, watch, type Ref } from "vue";
|
|
2
|
+
import { searchPlatform, type SearchResultGroup } from "@/services/search-api";
|
|
3
|
+
|
|
4
|
+
const SEARCH_DELAY_MS = 250;
|
|
5
|
+
|
|
6
|
+
export function useGlobalSearch(query: Ref<string>) {
|
|
7
|
+
const groups = shallowRef<readonly SearchResultGroup[]>([]);
|
|
8
|
+
const loading = shallowRef(false);
|
|
9
|
+
const partial = shallowRef(false);
|
|
10
|
+
const error = shallowRef("");
|
|
11
|
+
let cancelActiveRequest = () => {};
|
|
12
|
+
|
|
13
|
+
watch(
|
|
14
|
+
() => query.value.trim(),
|
|
15
|
+
(keyword, _, onCleanup) => {
|
|
16
|
+
cancelActiveRequest();
|
|
17
|
+
groups.value = [];
|
|
18
|
+
partial.value = false;
|
|
19
|
+
error.value = "";
|
|
20
|
+
if (!keyword) {
|
|
21
|
+
loading.value = false;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
loading.value = true;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = window.setTimeout(() => {
|
|
28
|
+
void searchPlatform(keyword, controller.signal)
|
|
29
|
+
.then((result) => {
|
|
30
|
+
groups.value = result.groups;
|
|
31
|
+
partial.value = result.partial;
|
|
32
|
+
})
|
|
33
|
+
.catch((cause: unknown) => {
|
|
34
|
+
if (controller.signal.aborted) return;
|
|
35
|
+
error.value =
|
|
36
|
+
cause instanceof Error ? cause.message : "业务搜索暂不可用";
|
|
37
|
+
})
|
|
38
|
+
.finally(() => {
|
|
39
|
+
if (!controller.signal.aborted) loading.value = false;
|
|
40
|
+
});
|
|
41
|
+
}, SEARCH_DELAY_MS);
|
|
42
|
+
|
|
43
|
+
const cancel = () => {
|
|
44
|
+
window.clearTimeout(timer);
|
|
45
|
+
controller.abort();
|
|
46
|
+
};
|
|
47
|
+
cancelActiveRequest = cancel;
|
|
48
|
+
onCleanup(cancel);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function reset() {
|
|
53
|
+
cancelActiveRequest();
|
|
54
|
+
cancelActiveRequest = () => {};
|
|
55
|
+
groups.value = [];
|
|
56
|
+
partial.value = false;
|
|
57
|
+
error.value = "";
|
|
58
|
+
loading.value = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
groups: readonly(groups),
|
|
63
|
+
loading: readonly(loading),
|
|
64
|
+
partial: readonly(partial),
|
|
65
|
+
error: readonly(error),
|
|
66
|
+
reset,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { computed, readonly, shallowRef } from "vue";
|
|
2
|
+
import type { PlatformBrandingSettings } from "@southwind-ai/shared";
|
|
3
|
+
import { setDateTimeZone } from "@/lib/date-time";
|
|
2
4
|
// @windy-module system.settings begin
|
|
3
5
|
import {
|
|
4
6
|
loadPublicBranding,
|
|
@@ -6,13 +8,10 @@ import {
|
|
|
6
8
|
} from "@/services/platform-settings-api";
|
|
7
9
|
// @windy-module system.settings end
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
softwareName:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const fallback: PlatformBrandingSettings = { softwareName: "Windy Platform" };
|
|
11
|
+
const fallback: PlatformBrandingSettings = {
|
|
12
|
+
softwareName: "Windy Platform",
|
|
13
|
+
timeZone: "Asia/Shanghai",
|
|
14
|
+
};
|
|
16
15
|
const branding = shallowRef<PlatformBrandingSettings>({ ...fallback });
|
|
17
16
|
const loading = shallowRef(false);
|
|
18
17
|
const saving = shallowRef(false);
|
|
@@ -65,7 +64,9 @@ function applyBranding(next: PlatformBrandingSettings) {
|
|
|
65
64
|
softwareName: next.softwareName.trim() || fallback.softwareName,
|
|
66
65
|
logoUrl: next.logoUrl?.trim() || undefined,
|
|
67
66
|
description: next.description?.trim() || undefined,
|
|
67
|
+
timeZone: next.timeZone?.trim() || fallback.timeZone,
|
|
68
68
|
};
|
|
69
|
+
setDateTimeZone(branding.value.timeZone || fallback.timeZone!);
|
|
69
70
|
document.title = branding.value.softwareName;
|
|
70
71
|
loaded = true;
|
|
71
72
|
return branding.value;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LoaderCircleIcon } from "@lucide/vue";
|
|
3
|
+
import type { SearchResultGroup } from "@/services/search-api";
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
groups: readonly SearchResultGroup[];
|
|
7
|
+
loading: boolean;
|
|
8
|
+
partial: boolean;
|
|
9
|
+
error: string;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
select: [routeTemplate: string, params: Record<string, string>];
|
|
14
|
+
}>();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<section
|
|
19
|
+
v-if="loading || error || partial || groups.length"
|
|
20
|
+
class="border-t px-2 pb-2 pt-3"
|
|
21
|
+
aria-label="业务搜索结果"
|
|
22
|
+
>
|
|
23
|
+
<div class="mb-2 flex items-center justify-between px-3">
|
|
24
|
+
<p class="text-xs font-medium text-muted-foreground">业务数据</p>
|
|
25
|
+
<LoaderCircleIcon
|
|
26
|
+
v-if="loading"
|
|
27
|
+
class="size-3.5 animate-spin text-muted-foreground"
|
|
28
|
+
aria-label="正在搜索业务数据"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
<p v-if="error" class="px-3 py-3 text-sm text-destructive">
|
|
32
|
+
{{ error }}
|
|
33
|
+
</p>
|
|
34
|
+
<p v-else-if="partial" class="px-3 pb-2 text-xs text-amber-600">
|
|
35
|
+
部分业务搜索源暂不可用,以下结果可能不完整。
|
|
36
|
+
</p>
|
|
37
|
+
<div v-for="group in groups" :key="group.providerKey" class="mb-2">
|
|
38
|
+
<p class="px-3 py-1 text-xs text-muted-foreground">
|
|
39
|
+
{{ group.label }}
|
|
40
|
+
</p>
|
|
41
|
+
<button
|
|
42
|
+
v-for="hit in group.hits"
|
|
43
|
+
:key="`${group.providerKey}:${hit.id}`"
|
|
44
|
+
type="button"
|
|
45
|
+
class="flex w-full flex-col rounded-md px-3 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
|
|
46
|
+
@click="emit('select', hit.deepLink.routeTemplate, hit.deepLink.params)"
|
|
47
|
+
>
|
|
48
|
+
<span class="truncate font-medium">{{ hit.maskedTitle }}</span>
|
|
49
|
+
<span
|
|
50
|
+
v-if="hit.maskedExcerpt"
|
|
51
|
+
class="mt-0.5 line-clamp-1 text-xs text-muted-foreground"
|
|
52
|
+
>
|
|
53
|
+
{{ hit.maskedExcerpt }}
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
</template>
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
} from "@/components/ui/dialog";
|
|
23
23
|
import { Input } from "@/components/ui/input";
|
|
24
24
|
import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
|
|
25
|
+
import { useGlobalSearch } from "@/composables/useGlobalSearch";
|
|
26
|
+
import GlobalSearchResults from "./GlobalSearchResults.vue";
|
|
25
27
|
import { authorizedSidebarMenus } from "./navigation";
|
|
26
28
|
|
|
27
29
|
interface SearchableNavigationItem {
|
|
@@ -36,6 +38,7 @@ const route = useRoute();
|
|
|
36
38
|
const access = useAccessSnapshot();
|
|
37
39
|
const open = shallowRef(false);
|
|
38
40
|
const query = shallowRef("");
|
|
41
|
+
const businessSearch = useGlobalSearch(query);
|
|
39
42
|
|
|
40
43
|
const items = computed(() =>
|
|
41
44
|
access.snapshot.value
|
|
@@ -55,8 +58,12 @@ const results = computed(() => {
|
|
|
55
58
|
});
|
|
56
59
|
|
|
57
60
|
watch(open, async (isOpen) => {
|
|
58
|
-
if (!isOpen)
|
|
61
|
+
if (!isOpen) {
|
|
62
|
+
businessSearch.reset();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
59
65
|
query.value = "";
|
|
66
|
+
businessSearch.reset();
|
|
60
67
|
await nextTick();
|
|
61
68
|
document.querySelector<HTMLInputElement>("[data-navigation-search]")?.focus();
|
|
62
69
|
});
|
|
@@ -79,6 +86,19 @@ async function select(item: SearchableNavigationItem) {
|
|
|
79
86
|
if (route.path !== item.path) await router.push(item.path);
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
async function selectBusiness(
|
|
90
|
+
routeTemplate: string,
|
|
91
|
+
params: Record<string, string>,
|
|
92
|
+
) {
|
|
93
|
+
const path = Object.entries(params).reduce(
|
|
94
|
+
(result, [key, value]) =>
|
|
95
|
+
result.replace(`:${key}`, encodeURIComponent(value)),
|
|
96
|
+
routeTemplate,
|
|
97
|
+
);
|
|
98
|
+
open.value = false;
|
|
99
|
+
if (route.path !== path) await router.push(path);
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
function selectFirst() {
|
|
83
103
|
const first = results.value[0];
|
|
84
104
|
if (first) void select(first);
|
|
@@ -114,9 +134,9 @@ function flattenMenus(
|
|
|
114
134
|
</DialogTrigger>
|
|
115
135
|
<DialogContent class="gap-0 overflow-hidden p-0 sm:max-w-xl">
|
|
116
136
|
<DialogHeader class="border-b px-5 py-4 text-left">
|
|
117
|
-
<DialogTitle
|
|
137
|
+
<DialogTitle>全局搜索</DialogTitle>
|
|
118
138
|
<DialogDescription
|
|
119
|
-
|
|
139
|
+
>同时搜索当前账号有权访问的菜单与业务数据。</DialogDescription
|
|
120
140
|
>
|
|
121
141
|
</DialogHeader>
|
|
122
142
|
<div class="shrink-0 border-b p-4">
|
|
@@ -128,17 +148,26 @@ function flattenMenus(
|
|
|
128
148
|
v-model="query"
|
|
129
149
|
data-navigation-search
|
|
130
150
|
class="pl-9"
|
|
131
|
-
placeholder="
|
|
151
|
+
placeholder="输入功能名称、路径或业务关键词"
|
|
132
152
|
@keyup.enter="selectFirst"
|
|
133
153
|
/>
|
|
134
154
|
</div>
|
|
135
155
|
</div>
|
|
136
156
|
<ModalBody class="p-2">
|
|
137
157
|
<p
|
|
138
|
-
v-if="
|
|
158
|
+
v-if="
|
|
159
|
+
query.trim() &&
|
|
160
|
+
results.length === 0 &&
|
|
161
|
+
businessSearch.groups.value.length === 0 &&
|
|
162
|
+
!businessSearch.loading.value &&
|
|
163
|
+
!businessSearch.error.value
|
|
164
|
+
"
|
|
139
165
|
class="px-3 py-10 text-center text-sm text-muted-foreground"
|
|
140
166
|
>
|
|
141
|
-
|
|
167
|
+
没有找到可访问的菜单或业务数据
|
|
168
|
+
</p>
|
|
169
|
+
<p class="px-3 py-1 text-xs font-medium text-muted-foreground">
|
|
170
|
+
平台导航
|
|
142
171
|
</p>
|
|
143
172
|
<button
|
|
144
173
|
v-for="item in results"
|
|
@@ -157,6 +186,13 @@ function flattenMenus(
|
|
|
157
186
|
item.path
|
|
158
187
|
}}</span>
|
|
159
188
|
</button>
|
|
189
|
+
<GlobalSearchResults
|
|
190
|
+
:groups="businessSearch.groups.value"
|
|
191
|
+
:loading="businessSearch.loading.value"
|
|
192
|
+
:partial="businessSearch.partial.value"
|
|
193
|
+
:error="businessSearch.error.value"
|
|
194
|
+
@select="selectBusiness"
|
|
195
|
+
/>
|
|
160
196
|
</ModalBody>
|
|
161
197
|
<div class="shrink-0 border-t px-5 py-2 text-xs text-muted-foreground">
|
|
162
198
|
快捷键 Ctrl/⌘ K
|
|
@@ -5,6 +5,10 @@ import { createMemoryHistory, createRouter } from "vue-router";
|
|
|
5
5
|
import type { WebAccessSnapshot } from "./navigation";
|
|
6
6
|
import NavigationSearchDialog from "./NavigationSearchDialog.vue";
|
|
7
7
|
|
|
8
|
+
const searchMocks = vi.hoisted(() => ({
|
|
9
|
+
search: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
8
12
|
const snapshot = shallowRef<WebAccessSnapshot>({
|
|
9
13
|
actor: {
|
|
10
14
|
id: "user_1",
|
|
@@ -40,10 +44,14 @@ const snapshot = shallowRef<WebAccessSnapshot>({
|
|
|
40
44
|
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
41
45
|
useAccessSnapshot: () => ({ snapshot }),
|
|
42
46
|
}));
|
|
47
|
+
vi.mock("@/services/search-api", () => ({
|
|
48
|
+
searchPlatform: searchMocks.search,
|
|
49
|
+
}));
|
|
43
50
|
|
|
44
51
|
afterEach(() => {
|
|
45
52
|
document.body.innerHTML = "";
|
|
46
53
|
vi.clearAllMocks();
|
|
54
|
+
vi.useRealTimers();
|
|
47
55
|
});
|
|
48
56
|
|
|
49
57
|
describe("NavigationSearchDialog", () => {
|
|
@@ -81,4 +89,67 @@ describe("NavigationSearchDialog", () => {
|
|
|
81
89
|
expect(router.currentRoute.value.path).toBe("/system/roles");
|
|
82
90
|
wrapper.unmount();
|
|
83
91
|
});
|
|
92
|
+
|
|
93
|
+
test("输入关键词后展示授权 SearchProvider 结果并打开业务深链", async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
searchMocks.search.mockResolvedValue({
|
|
96
|
+
groups: [
|
|
97
|
+
{
|
|
98
|
+
providerKey: "case.record.search",
|
|
99
|
+
label: "案件",
|
|
100
|
+
hits: [
|
|
101
|
+
{
|
|
102
|
+
id: "case_1",
|
|
103
|
+
resourceType: "case",
|
|
104
|
+
maskedTitle: "案…一",
|
|
105
|
+
maskedExcerpt: "涉…",
|
|
106
|
+
providerKey: "case.record.search",
|
|
107
|
+
deepLink: {
|
|
108
|
+
surface: "business",
|
|
109
|
+
routeTemplate: "/cases/:id",
|
|
110
|
+
params: { id: "case_1" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
partial: false,
|
|
117
|
+
failures: [],
|
|
118
|
+
});
|
|
119
|
+
const router = createRouter({
|
|
120
|
+
history: createMemoryHistory(),
|
|
121
|
+
routes: [
|
|
122
|
+
{ path: "/", component: { template: "<div />" } },
|
|
123
|
+
{ path: "/cases/:id", component: { template: "<div />" } },
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
await router.push("/");
|
|
127
|
+
await router.isReady();
|
|
128
|
+
const wrapper = mount(NavigationSearchDialog, {
|
|
129
|
+
attachTo: document.body,
|
|
130
|
+
global: { plugins: [router] },
|
|
131
|
+
});
|
|
132
|
+
await wrapper.get('button[aria-label="搜索平台功能"]').trigger("click");
|
|
133
|
+
await flushPromises();
|
|
134
|
+
const input = document.body.querySelector<HTMLInputElement>(
|
|
135
|
+
"[data-navigation-search]",
|
|
136
|
+
)!;
|
|
137
|
+
input.value = "案件";
|
|
138
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
139
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
140
|
+
await flushPromises();
|
|
141
|
+
|
|
142
|
+
expect(searchMocks.search).toHaveBeenCalledWith(
|
|
143
|
+
"案件",
|
|
144
|
+
expect.any(AbortSignal),
|
|
145
|
+
);
|
|
146
|
+
const result = Array.from(document.body.querySelectorAll("button")).find(
|
|
147
|
+
(button) => button.textContent?.includes("案…一"),
|
|
148
|
+
);
|
|
149
|
+
expect(result).toBeDefined();
|
|
150
|
+
result?.click();
|
|
151
|
+
await flushPromises();
|
|
152
|
+
expect(router.currentRoute.value.path).toBe("/cases/case_1");
|
|
153
|
+
wrapper.unmount();
|
|
154
|
+
});
|
|
84
155
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { NotificationInboxItem } from "@southwind-ai/shared";
|
|
3
|
-
import { BellIcon } from "@lucide/vue";
|
|
3
|
+
import { BellIcon, SettingsIcon } from "@lucide/vue";
|
|
4
4
|
import { computed, onMounted, shallowRef } from "vue";
|
|
5
5
|
import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
@@ -20,9 +20,13 @@ import {
|
|
|
20
20
|
} from "@/components/ui/sheet";
|
|
21
21
|
import { formatDateTime } from "@/lib/date-time";
|
|
22
22
|
import { evaluateWebAccess } from "@/layout/navigation";
|
|
23
|
+
import NotificationMenuItem from "./NotificationMenuItem.vue";
|
|
24
|
+
import NotificationPreferencesDialog from "./NotificationPreferencesDialog.vue";
|
|
23
25
|
import {
|
|
26
|
+
favoriteNotification,
|
|
24
27
|
listNotifications,
|
|
25
28
|
markNotificationRead,
|
|
29
|
+
unfavoriteNotification,
|
|
26
30
|
} from "@/services/notification-api";
|
|
27
31
|
|
|
28
32
|
const notifications = shallowRef<NotificationInboxItem[]>([]);
|
|
@@ -31,6 +35,8 @@ const selected = shallowRef<NotificationInboxItem>();
|
|
|
31
35
|
const detailOpen = shallowRef(false);
|
|
32
36
|
const loading = shallowRef(false);
|
|
33
37
|
const error = shallowRef("");
|
|
38
|
+
const preferencesOpen = shallowRef(false);
|
|
39
|
+
const favoritePendingIds = shallowRef<ReadonlySet<string>>(new Set());
|
|
34
40
|
const unreadCount = computed(
|
|
35
41
|
() => notifications.value.filter(({ read }) => !read).length,
|
|
36
42
|
);
|
|
@@ -83,6 +89,52 @@ async function openNotification(notification: NotificationInboxItem) {
|
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
}
|
|
92
|
+
|
|
93
|
+
async function toggleFavorite(notification: NotificationInboxItem) {
|
|
94
|
+
if (favoritePendingIds.value.has(notification.id)) return;
|
|
95
|
+
const previous = {
|
|
96
|
+
favorite: notification.favorite,
|
|
97
|
+
favoritedAt: notification.favoritedAt,
|
|
98
|
+
};
|
|
99
|
+
const desired = !notification.favorite;
|
|
100
|
+
setFavoritePending(notification.id, true);
|
|
101
|
+
patchNotification(notification.id, {
|
|
102
|
+
favorite: desired,
|
|
103
|
+
favoritedAt: desired ? new Date().toISOString() : undefined,
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
const result = desired
|
|
107
|
+
? await favoriteNotification(notification.id)
|
|
108
|
+
: await unfavoriteNotification(notification.id);
|
|
109
|
+
patchNotification(notification.id, {
|
|
110
|
+
favorite: result.favorite,
|
|
111
|
+
favoritedAt: result.favoritedAt,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
patchNotification(notification.id, previous);
|
|
115
|
+
} finally {
|
|
116
|
+
setFavoritePending(notification.id, false);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function patchNotification(
|
|
121
|
+
id: string,
|
|
122
|
+
patch: Pick<NotificationInboxItem, "favorite" | "favoritedAt">,
|
|
123
|
+
) {
|
|
124
|
+
notifications.value = notifications.value.map((item) =>
|
|
125
|
+
item.id === id ? { ...item, ...patch } : item,
|
|
126
|
+
);
|
|
127
|
+
if (selected.value?.id === id) {
|
|
128
|
+
selected.value = { ...selected.value, ...patch };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function setFavoritePending(id: string, pending: boolean) {
|
|
133
|
+
const next = new Set(favoritePendingIds.value);
|
|
134
|
+
if (pending) next.add(id);
|
|
135
|
+
else next.delete(id);
|
|
136
|
+
favoritePendingIds.value = next;
|
|
137
|
+
}
|
|
86
138
|
</script>
|
|
87
139
|
|
|
88
140
|
<template>
|
|
@@ -105,43 +157,31 @@ async function openNotification(notification: NotificationInboxItem) {
|
|
|
105
157
|
<DropdownMenuContent align="end" class="w-80 p-0">
|
|
106
158
|
<DropdownMenuLabel class="flex items-center justify-between px-4 py-3">
|
|
107
159
|
<span>站内通知</span>
|
|
108
|
-
<span class="
|
|
109
|
-
|
|
160
|
+
<span class="flex items-center gap-2">
|
|
161
|
+
<span class="text-xs font-normal text-muted-foreground">
|
|
162
|
+
{{ unreadCount }} 条未读
|
|
163
|
+
</span>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size="icon-sm"
|
|
167
|
+
aria-label="通知偏好"
|
|
168
|
+
@click.stop="preferencesOpen = true"
|
|
169
|
+
>
|
|
170
|
+
<SettingsIcon />
|
|
171
|
+
</Button>
|
|
110
172
|
</span>
|
|
111
173
|
</DropdownMenuLabel>
|
|
112
174
|
<DropdownMenuSeparator />
|
|
113
175
|
|
|
114
176
|
<div v-if="notifications.length" class="max-h-96 overflow-y-auto py-1">
|
|
115
|
-
<
|
|
177
|
+
<NotificationMenuItem
|
|
116
178
|
v-for="notification in notifications"
|
|
117
179
|
:key="notification.id"
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
@
|
|
122
|
-
|
|
123
|
-
<span
|
|
124
|
-
class="mt-1.5 size-2 shrink-0 rounded-full"
|
|
125
|
-
:class="notification.read ? 'bg-muted-foreground/30' : 'bg-primary'"
|
|
126
|
-
aria-hidden="true"
|
|
127
|
-
/>
|
|
128
|
-
<span class="min-w-0 flex-1 space-y-1">
|
|
129
|
-
<span class="flex items-center justify-between gap-3">
|
|
130
|
-
<span class="truncate text-sm font-medium text-foreground">
|
|
131
|
-
{{ notification.title }}
|
|
132
|
-
</span>
|
|
133
|
-
<span class="shrink-0 text-xs text-muted-foreground">
|
|
134
|
-
{{ formatDateTime(notification.publishedAt) }}
|
|
135
|
-
</span>
|
|
136
|
-
</span>
|
|
137
|
-
<span class="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
|
138
|
-
{{ notification.summary }}
|
|
139
|
-
</span>
|
|
140
|
-
<span class="text-xs text-muted-foreground">
|
|
141
|
-
{{ notification.category }}
|
|
142
|
-
</span>
|
|
143
|
-
</span>
|
|
144
|
-
</button>
|
|
180
|
+
:item="notification"
|
|
181
|
+
:favorite-pending="favoritePendingIds.has(notification.id)"
|
|
182
|
+
@open="openNotification"
|
|
183
|
+
@toggle-favorite="toggleFavorite"
|
|
184
|
+
/>
|
|
145
185
|
</div>
|
|
146
186
|
|
|
147
187
|
<div v-else class="px-4 py-8 text-center">
|
|
@@ -171,4 +211,6 @@ async function openNotification(notification: NotificationInboxItem) {
|
|
|
171
211
|
</article>
|
|
172
212
|
</SheetContent>
|
|
173
213
|
</Sheet>
|
|
214
|
+
|
|
215
|
+
<NotificationPreferencesDialog v-model:open="preferencesOpen" />
|
|
174
216
|
</template>
|
|
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
20
20
|
updatedAt: "2026-07-14T08:00:00.000Z",
|
|
21
21
|
updatedBy: "admin",
|
|
22
22
|
read: false,
|
|
23
|
+
favorite: false,
|
|
23
24
|
},
|
|
24
25
|
],
|
|
25
26
|
total: 1,
|
|
@@ -31,11 +32,29 @@ const mocks = vi.hoisted(() => ({
|
|
|
31
32
|
alreadyRead: false,
|
|
32
33
|
readAt: "2026-07-14T08:01:00.000Z",
|
|
33
34
|
})),
|
|
35
|
+
favorite: vi.fn(async () => ({
|
|
36
|
+
success: true,
|
|
37
|
+
favorite: true,
|
|
38
|
+
changed: true,
|
|
39
|
+
favoritedAt: "2026-07-14T08:02:00.000Z",
|
|
40
|
+
})),
|
|
41
|
+
unfavorite: vi.fn(async () => ({
|
|
42
|
+
success: true,
|
|
43
|
+
favorite: false,
|
|
44
|
+
changed: true,
|
|
45
|
+
})),
|
|
46
|
+
beforeEach: vi.fn(() => vi.fn()),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock("vue-router", () => ({
|
|
50
|
+
useRouter: () => ({ beforeEach: mocks.beforeEach }),
|
|
34
51
|
}));
|
|
35
52
|
|
|
36
53
|
vi.mock("@/services/notification-api", () => ({
|
|
37
54
|
listNotifications: mocks.list,
|
|
38
55
|
markNotificationRead: mocks.markRead,
|
|
56
|
+
favoriteNotification: mocks.favorite,
|
|
57
|
+
unfavoriteNotification: mocks.unfavorite,
|
|
39
58
|
}));
|
|
40
59
|
|
|
41
60
|
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
@@ -69,6 +88,9 @@ describe("站内通知", () => {
|
|
|
69
88
|
mocks.allowed = true;
|
|
70
89
|
mocks.list.mockClear();
|
|
71
90
|
mocks.markRead.mockClear();
|
|
91
|
+
mocks.favorite.mockClear();
|
|
92
|
+
mocks.unfavorite.mockClear();
|
|
93
|
+
mocks.beforeEach.mockClear();
|
|
72
94
|
});
|
|
73
95
|
|
|
74
96
|
test("Feature 或阅读权限不可用时隐藏入口且不请求通知接口", async () => {
|
|
@@ -86,7 +108,7 @@ describe("站内通知", () => {
|
|
|
86
108
|
await wrapper.get('button[aria-label="站内通知"]').trigger("click");
|
|
87
109
|
|
|
88
110
|
const item = document.body.querySelector<HTMLButtonElement>(
|
|
89
|
-
"[data-notification-id='notice_1']",
|
|
111
|
+
"[data-notification-id='notice_1'] > button",
|
|
90
112
|
);
|
|
91
113
|
expect(item).not.toBeNull();
|
|
92
114
|
item?.click();
|
|
@@ -99,4 +121,25 @@ describe("站内通知", () => {
|
|
|
99
121
|
expect(mocks.markRead).toHaveBeenCalledWith("notice_1");
|
|
100
122
|
wrapper.unmount();
|
|
101
123
|
});
|
|
124
|
+
|
|
125
|
+
test("收藏更新不改变通知已读状态", async () => {
|
|
126
|
+
const wrapper = mount(NotificationMenu, { attachTo: document.body });
|
|
127
|
+
await flushPromises();
|
|
128
|
+
await wrapper.get('button[aria-label="站内通知"]').trigger("click");
|
|
129
|
+
|
|
130
|
+
const favorite = document.body.querySelector<HTMLButtonElement>(
|
|
131
|
+
"[data-notification-id='notice_1'] button[aria-label='收藏通知']",
|
|
132
|
+
);
|
|
133
|
+
favorite?.click();
|
|
134
|
+
await flushPromises();
|
|
135
|
+
|
|
136
|
+
expect(mocks.favorite).toHaveBeenCalledWith("notice_1");
|
|
137
|
+
expect(mocks.markRead).not.toHaveBeenCalled();
|
|
138
|
+
expect(
|
|
139
|
+
document.body.querySelector(
|
|
140
|
+
"[data-notification-id='notice_1'] button[aria-label='取消收藏通知']",
|
|
141
|
+
),
|
|
142
|
+
).not.toBeNull();
|
|
143
|
+
wrapper.unmount();
|
|
144
|
+
});
|
|
102
145
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NotificationInboxItem } from "@southwind-ai/shared";
|
|
3
|
+
import { StarIcon } from "@lucide/vue";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { formatDateTime } from "@/lib/date-time";
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
item: NotificationInboxItem;
|
|
9
|
+
favoritePending: boolean;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
open: [item: NotificationInboxItem];
|
|
14
|
+
toggleFavorite: [item: NotificationInboxItem];
|
|
15
|
+
}>();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div
|
|
20
|
+
:data-notification-id="item.id"
|
|
21
|
+
class="flex items-start gap-1 px-2 transition-colors hover:bg-muted"
|
|
22
|
+
>
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
class="flex min-w-0 flex-1 gap-3 px-2 py-3 text-left outline-none focus-visible:bg-muted"
|
|
26
|
+
@click="emit('open', item)"
|
|
27
|
+
>
|
|
28
|
+
<span
|
|
29
|
+
class="mt-1.5 size-2 shrink-0 rounded-full"
|
|
30
|
+
:class="item.read ? 'bg-muted-foreground/30' : 'bg-primary'"
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
/>
|
|
33
|
+
<span class="min-w-0 flex-1 space-y-1">
|
|
34
|
+
<span class="flex items-center justify-between gap-3">
|
|
35
|
+
<span class="truncate text-sm font-medium text-foreground">
|
|
36
|
+
{{ item.title }}
|
|
37
|
+
</span>
|
|
38
|
+
<span class="shrink-0 text-xs text-muted-foreground">
|
|
39
|
+
{{ formatDateTime(item.publishedAt) }}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
42
|
+
<span class="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
|
43
|
+
{{ item.summary }}
|
|
44
|
+
</span>
|
|
45
|
+
<span class="text-xs text-muted-foreground">{{ item.category }}</span>
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
<Button
|
|
49
|
+
variant="ghost"
|
|
50
|
+
size="icon-sm"
|
|
51
|
+
class="mt-2 shrink-0"
|
|
52
|
+
:disabled="favoritePending"
|
|
53
|
+
:aria-label="item.favorite ? '取消收藏通知' : '收藏通知'"
|
|
54
|
+
@click="emit('toggleFavorite', item)"
|
|
55
|
+
>
|
|
56
|
+
<StarIcon
|
|
57
|
+
:class="item.favorite ? 'fill-current text-primary' : undefined"
|
|
58
|
+
/>
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|