From 3ab0e3e034f2f1ac3ac0334afc5ed2dd3e4ab4d5 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 20:11:57 -0700 Subject: [PATCH] feat: scope workspace routes by organization slug --- .trellis/spec/backend/authentication.md | 10 ++ .trellis/spec/frontend/components.md | 11 +++ .trellis/spec/frontend/directory-structure.md | 65 +++++++++++++ .../app/[organizationSlug]/alerts/page.tsx | 5 + .../app/[organizationSlug]/beds/page.tsx | 5 + .../app/[organizationSlug]/care/page.tsx | 5 + .../app/[organizationSlug]/dashboard/page.tsx | 5 + .../app/[organizationSlug]/devices/page.tsx | 5 + .../app/[organizationSlug]/elders/page.tsx | 5 + .../app/[organizationSlug]/emergency/page.tsx | 5 + .../app/[organizationSlug]/family/page.tsx | 5 + .../app/[organizationSlug]/health/page.tsx | 5 + app/(app)/app/[organizationSlug]/layout.tsx | 28 ++++++ .../app/[organizationSlug]/notices/page.tsx | 5 + app/(app)/app/[organizationSlug]/page.tsx | 14 +++ .../settings/audit/page.tsx | 10 ++ .../settings/global/page.tsx | 5 + .../[organizationSlug]/settings/layout.tsx | 9 ++ .../settings/organizations/[id]/page.tsx | 15 +++ .../settings/organizations/page.tsx | 12 +++ .../app/[organizationSlug]/settings/page.tsx | 5 + .../settings/roles/page.tsx | 10 ++ .../settings/status/page.tsx | 10 ++ .../settings/users/page.tsx | 10 ++ app/(app)/app/beds/page.tsx | 3 +- app/(app)/app/dashboard/page.tsx | 10 +- app/(app)/app/elders/page.tsx | 6 ++ app/(app)/app/page.tsx | 8 +- app/(app)/app/settings/audit/page.tsx | 8 +- app/(app)/app/settings/global/page.tsx | 3 +- .../app/settings/organizations/[id]/page.tsx | 7 +- app/(app)/app/settings/organizations/page.tsx | 10 +- app/(app)/app/settings/page.tsx | 6 +- app/(app)/app/settings/roles/page.tsx | 8 +- app/(app)/app/settings/status/page.tsx | 8 +- app/(app)/app/settings/users/page.tsx | 8 +- app/api/organizations/[id]/route.ts | 5 +- app/api/organizations/route.ts | 5 +- modules/auth/components/AuthPanel.tsx | 30 +++++- modules/core/server/auth.ts | 4 +- modules/core/server/permissions.ts | 19 +++- modules/core/types.ts | 6 +- modules/shared/components/AccountMenu.tsx | 12 ++- modules/shared/components/AppBreadcrumbs.tsx | 59 ++++++------ modules/shared/components/AppShell.tsx | 8 +- modules/shared/components/AppSidebarNav.tsx | 27 ++++-- modules/shared/lib/navigation.ts | 45 +++++---- modules/shared/lib/workspace-routing.ts | 92 +++++++++++++++++++ 48 files changed, 565 insertions(+), 96 deletions(-) create mode 100644 app/(app)/app/[organizationSlug]/alerts/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/beds/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/care/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/dashboard/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/devices/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/elders/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/emergency/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/family/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/health/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/layout.tsx create mode 100644 app/(app)/app/[organizationSlug]/notices/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/audit/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/global/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/layout.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/organizations/[id]/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/organizations/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/roles/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/status/page.tsx create mode 100644 app/(app)/app/[organizationSlug]/settings/users/page.tsx create mode 100644 modules/shared/lib/workspace-routing.ts diff --git a/.trellis/spec/backend/authentication.md b/.trellis/spec/backend/authentication.md index 8f7f89e..3bd80a4 100644 --- a/.trellis/spec/backend/authentication.md +++ b/.trellis/spec/backend/authentication.md @@ -23,8 +23,18 @@ This document covers backend authentication integration using better-auth, inclu - `membership: Membership | null` - `permissions: Permission[]` - `POST /api/auth/organization` request payload: `{ organizationId: string }`. +- `POST /api/auth/organization` success response payload includes: + - `organization: Organization | null` + - `organizations: AccountOrganizationOption[]` + - `permissions: Permission[]` - `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`. - All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`. +- `Organization.slug` is the canonical tenant segment for authenticated workspace URLs. + Organization create/update and setup slug generation must reject or rewrite reserved + workspace segments from `modules/shared/lib/workspace-routing.ts` so `/app/settings` and + similar product routes cannot be interpreted as tenants. +- Switching organizations should return the selected organization and available organization + options so the client can redirect to the same workspace section under the new slug. #### 4. Validation & Error Matrix - Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`. diff --git a/.trellis/spec/frontend/components.md b/.trellis/spec/frontend/components.md index f8296a6..84bcb7c 100644 --- a/.trellis/spec/frontend/components.md +++ b/.trellis/spec/frontend/components.md @@ -216,6 +216,17 @@ surface. implemented, show an honest not-connected state instead of fake provider accounts. - The sidebar nav selected state belongs in a small client component using `usePathname()`; keep the rest of `AppShell` server-rendered. +- Authenticated workspace links should preserve the active organization slug by using + `modules/shared/lib/workspace-routing.ts`. Sidebar links, breadcrumbs, permission + redirects, table search/pagination paths, login redirects, and organization switch + redirects should call `getWorkspaceHref(activeSlug, workspacePath)` instead of hard-coding + `/app/...` paths. +- The legacy unscoped `/app/...` paths remain valid fallback paths for accounts without an + active organization. When a session has an active organization, redirect into + `/app/{organizationSlug}/...` so operators can see which workspace they are using. +- Top-level workspace route names such as `dashboard`, `settings`, `elders`, and `beds` are + reserved path segments, not tenant identifiers. Keep the frontend reserved list in + `workspace-routing.ts` aligned with backend organization slug validation. ### Business Form Defaults Contract diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index c6b9c27..88b5940 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -155,6 +155,71 @@ app/(app)/ └── page.tsx -> modules/orders/ (detail view) ``` +## Scenario: Organization-Scoped Workspace Routes + +### 1. Scope / Trigger + +- Trigger: adding or changing protected app workspace routes, app-shell navigation, breadcrumbs, login redirects, organization switching, or settings pagination links. +- The current workspace URL must include the active organization slug when an organization is selected. + +### 2. Signatures + +- Canonical workspace route: `/app/[organizationSlug]/`. +- Legacy-compatible route: `/app/` may remain available while old links are migrated. +- Shared helpers live in `modules/shared/lib/workspace-routing.ts`: + - `getWorkspaceHref(organizationSlug: string | undefined, path?: string): string` + - `getWorkspacePathFromPathname(pathname: string): string` + - `getWorkspaceSlugFromPathname(pathname: string): string | undefined` + - `isWorkspacePathActive(pathname: string, itemPath: string): boolean` + +### 3. Contracts + +- `navGroups` stores workspace-local paths such as `/dashboard`, `/settings/users`, not full `/app/...` hrefs. +- `AppShell` passes the current `organization.slug` into navigation and logo links. +- `AppSidebarNav`, `AppBreadcrumbs`, settings search forms, and pagination must generate hrefs with `getWorkspaceHref(...)`. +- The `[organizationSlug]` layout must reject slug/session mismatches by redirecting to the active session organization workspace. +- Organization slugs cannot use reserved first-level workspace section keys such as `dashboard`, `settings`, `elders`, or `beds`. + +### 4. Validation & Error Matrix + +- Missing active organization slug -> helpers fall back to legacy `/app/`. +- URL slug differs from `getCurrentAuthContext().organization.slug` -> redirect to `getWorkspaceHref(activeSlug, "/dashboard")`. +- New or updated organization slug equals a reserved workspace key -> API returns validation failure. +- A page-level redirect inside protected routes -> use `getWorkspaceHref(context.organization?.slug, targetPath)`. + +### 5. Good/Base/Bad Cases + +- Good: organization switch calls `POST /api/auth/organization`, then navigates to the same workspace path under the returned active organization slug. +- Base: legacy `/app/dashboard` remains renderable for compatibility, but new app-shell links point to `/app/{slug}/dashboard`. +- Bad: hard-code `/app/settings/users` in table forms or breadcrumbs; it drops users out of the organization-scoped workspace. +- Bad: infer tenant from `localStorage`; active organization remains server-session state. + +### 6. Tests Required + +- `pnpm lint` +- `pnpm type-check` +- `pnpm build` +- Browser assertions: + - login/setup lands on `/app/{activeOrg.slug}/dashboard` + - sidebar links preserve the active organization slug + - settings search and pagination preserve the active organization slug + - switching organization moves the URL to the new slug while preserving the workspace path + - mismatched `/app/{wrongSlug}/...` redirects to the active organization workspace + +### 7. Wrong vs Correct + +#### Wrong + +```tsx +用户管理 +``` + +#### Correct + +```tsx +用户管理 +``` + ## Import Path Aliases Configure in `tsconfig.json`: diff --git a/app/(app)/app/[organizationSlug]/alerts/page.tsx b/app/(app)/app/[organizationSlug]/alerts/page.tsx new file mode 100644 index 0000000..491e6a7 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/alerts/page.tsx @@ -0,0 +1,5 @@ +import AlertsPage from "../../alerts/page"; + +export default function ScopedAlertsPage(): React.ReactElement { + return AlertsPage(); +} diff --git a/app/(app)/app/[organizationSlug]/beds/page.tsx b/app/(app)/app/[organizationSlug]/beds/page.tsx new file mode 100644 index 0000000..61ceb4c --- /dev/null +++ b/app/(app)/app/[organizationSlug]/beds/page.tsx @@ -0,0 +1,5 @@ +import BedsPage from "../../beds/page"; + +export default async function ScopedBedsPage(): Promise { + return BedsPage(); +} diff --git a/app/(app)/app/[organizationSlug]/care/page.tsx b/app/(app)/app/[organizationSlug]/care/page.tsx new file mode 100644 index 0000000..2d726b6 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/care/page.tsx @@ -0,0 +1,5 @@ +import CarePage from "../../care/page"; + +export default function ScopedCarePage(): React.ReactElement { + return CarePage(); +} diff --git a/app/(app)/app/[organizationSlug]/dashboard/page.tsx b/app/(app)/app/[organizationSlug]/dashboard/page.tsx new file mode 100644 index 0000000..502074e --- /dev/null +++ b/app/(app)/app/[organizationSlug]/dashboard/page.tsx @@ -0,0 +1,5 @@ +import DashboardPage from "../../dashboard/page"; + +export default async function ScopedDashboardPage(): Promise { + return DashboardPage(); +} diff --git a/app/(app)/app/[organizationSlug]/devices/page.tsx b/app/(app)/app/[organizationSlug]/devices/page.tsx new file mode 100644 index 0000000..a24b9d2 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/devices/page.tsx @@ -0,0 +1,5 @@ +import DevicesPage from "../../devices/page"; + +export default function ScopedDevicesPage(): React.ReactElement { + return DevicesPage(); +} diff --git a/app/(app)/app/[organizationSlug]/elders/page.tsx b/app/(app)/app/[organizationSlug]/elders/page.tsx new file mode 100644 index 0000000..1a17c70 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/elders/page.tsx @@ -0,0 +1,5 @@ +import EldersPage from "../../elders/page"; + +export default async function ScopedEldersPage(): Promise { + return EldersPage(); +} diff --git a/app/(app)/app/[organizationSlug]/emergency/page.tsx b/app/(app)/app/[organizationSlug]/emergency/page.tsx new file mode 100644 index 0000000..77057b4 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/emergency/page.tsx @@ -0,0 +1,5 @@ +import EmergencyPage from "../../emergency/page"; + +export default function ScopedEmergencyPage(): React.ReactElement { + return EmergencyPage(); +} diff --git a/app/(app)/app/[organizationSlug]/family/page.tsx b/app/(app)/app/[organizationSlug]/family/page.tsx new file mode 100644 index 0000000..309dbd1 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/family/page.tsx @@ -0,0 +1,5 @@ +import FamilyPage from "../../family/page"; + +export default function ScopedFamilyPage(): React.ReactElement { + return FamilyPage(); +} diff --git a/app/(app)/app/[organizationSlug]/health/page.tsx b/app/(app)/app/[organizationSlug]/health/page.tsx new file mode 100644 index 0000000..8f3e8d3 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/health/page.tsx @@ -0,0 +1,5 @@ +import HealthPage from "../../health/page"; + +export default function ScopedHealthPage(): React.ReactElement { + return HealthPage(); +} diff --git a/app/(app)/app/[organizationSlug]/layout.tsx b/app/(app)/app/[organizationSlug]/layout.tsx new file mode 100644 index 0000000..c60f54f --- /dev/null +++ b/app/(app)/app/[organizationSlug]/layout.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; + +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; + +type OrganizationWorkspaceLayoutProps = { + children: React.ReactNode; + params: Promise<{ + organizationSlug: string; + }>; +}; + +export default async function OrganizationWorkspaceLayout({ + children, + params, +}: OrganizationWorkspaceLayoutProps): Promise { + const [{ organizationSlug }, context] = await Promise.all([params, getCurrentAuthContext()]); + if (!context) { + redirect("/login"); + } + + const activeSlug = context.organization?.slug; + if (activeSlug && activeSlug !== organizationSlug) { + redirect(getWorkspaceHref(activeSlug, "/dashboard")); + } + + return <>{children}; +} diff --git a/app/(app)/app/[organizationSlug]/notices/page.tsx b/app/(app)/app/[organizationSlug]/notices/page.tsx new file mode 100644 index 0000000..59386eb --- /dev/null +++ b/app/(app)/app/[organizationSlug]/notices/page.tsx @@ -0,0 +1,5 @@ +import NoticesPage from "../../notices/page"; + +export default function ScopedNoticesPage(): React.ReactElement { + return NoticesPage(); +} diff --git a/app/(app)/app/[organizationSlug]/page.tsx b/app/(app)/app/[organizationSlug]/page.tsx new file mode 100644 index 0000000..120d81e --- /dev/null +++ b/app/(app)/app/[organizationSlug]/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; + +type ScopedAppIndexPageProps = { + params: Promise<{ + organizationSlug: string; + }>; +}; + +export default async function ScopedAppIndexPage({ params }: ScopedAppIndexPageProps): Promise { + const { organizationSlug } = await params; + redirect(getWorkspaceHref(organizationSlug, "/dashboard")); +} diff --git a/app/(app)/app/[organizationSlug]/settings/audit/page.tsx b/app/(app)/app/[organizationSlug]/settings/audit/page.tsx new file mode 100644 index 0000000..04671bb --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/audit/page.tsx @@ -0,0 +1,10 @@ +import AuditPage from "../../../settings/audit/page"; +import type { SearchParams } from "@/modules/settings/lib/pagination"; + +type ScopedAuditPageProps = { + searchParams?: Promise; +}; + +export default async function ScopedAuditPage({ searchParams }: ScopedAuditPageProps): Promise { + return AuditPage({ searchParams }); +} diff --git a/app/(app)/app/[organizationSlug]/settings/global/page.tsx b/app/(app)/app/[organizationSlug]/settings/global/page.tsx new file mode 100644 index 0000000..33de789 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/global/page.tsx @@ -0,0 +1,5 @@ +import GlobalSettingsPage from "../../../settings/global/page"; + +export default async function ScopedGlobalSettingsPage(): Promise { + return GlobalSettingsPage(); +} diff --git a/app/(app)/app/[organizationSlug]/settings/layout.tsx b/app/(app)/app/[organizationSlug]/settings/layout.tsx new file mode 100644 index 0000000..235cd0e --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/layout.tsx @@ -0,0 +1,9 @@ +import SettingsLayout from "../../settings/layout"; + +type ScopedSettingsLayoutProps = { + children: React.ReactNode; +}; + +export default function ScopedSettingsLayout({ children }: ScopedSettingsLayoutProps): React.ReactElement { + return {children}; +} diff --git a/app/(app)/app/[organizationSlug]/settings/organizations/[id]/page.tsx b/app/(app)/app/[organizationSlug]/settings/organizations/[id]/page.tsx new file mode 100644 index 0000000..155eca2 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/organizations/[id]/page.tsx @@ -0,0 +1,15 @@ +import OrganizationDetailPage from "../../../../settings/organizations/[id]/page"; + +type ScopedOrganizationDetailPageProps = { + params: Promise<{ + organizationSlug: string; + id: string; + }>; +}; + +export default async function ScopedOrganizationDetailPage({ + params, +}: ScopedOrganizationDetailPageProps): Promise { + const { id } = await params; + return OrganizationDetailPage({ params: Promise.resolve({ id }) }); +} diff --git a/app/(app)/app/[organizationSlug]/settings/organizations/page.tsx b/app/(app)/app/[organizationSlug]/settings/organizations/page.tsx new file mode 100644 index 0000000..8e7e931 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/organizations/page.tsx @@ -0,0 +1,12 @@ +import OrganizationsPage from "../../../settings/organizations/page"; +import type { SearchParams } from "@/modules/settings/lib/pagination"; + +type ScopedOrganizationsPageProps = { + searchParams?: Promise; +}; + +export default async function ScopedOrganizationsPage({ + searchParams, +}: ScopedOrganizationsPageProps): Promise { + return OrganizationsPage({ searchParams }); +} diff --git a/app/(app)/app/[organizationSlug]/settings/page.tsx b/app/(app)/app/[organizationSlug]/settings/page.tsx new file mode 100644 index 0000000..d793865 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/page.tsx @@ -0,0 +1,5 @@ +import SettingsPage from "../../settings/page"; + +export default async function ScopedSettingsPage(): Promise { + return SettingsPage(); +} diff --git a/app/(app)/app/[organizationSlug]/settings/roles/page.tsx b/app/(app)/app/[organizationSlug]/settings/roles/page.tsx new file mode 100644 index 0000000..2696ff2 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/roles/page.tsx @@ -0,0 +1,10 @@ +import RolesPage from "../../../settings/roles/page"; +import type { SearchParams } from "@/modules/settings/lib/pagination"; + +type ScopedRolesPageProps = { + searchParams?: Promise; +}; + +export default async function ScopedRolesPage({ searchParams }: ScopedRolesPageProps): Promise { + return RolesPage({ searchParams }); +} diff --git a/app/(app)/app/[organizationSlug]/settings/status/page.tsx b/app/(app)/app/[organizationSlug]/settings/status/page.tsx new file mode 100644 index 0000000..0d2ae77 --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/status/page.tsx @@ -0,0 +1,10 @@ +import StatusPage from "../../../settings/status/page"; +import type { SearchParams } from "@/modules/settings/lib/pagination"; + +type ScopedStatusPageProps = { + searchParams?: Promise; +}; + +export default async function ScopedStatusPage({ searchParams }: ScopedStatusPageProps): Promise { + return StatusPage({ searchParams }); +} diff --git a/app/(app)/app/[organizationSlug]/settings/users/page.tsx b/app/(app)/app/[organizationSlug]/settings/users/page.tsx new file mode 100644 index 0000000..e63ae1a --- /dev/null +++ b/app/(app)/app/[organizationSlug]/settings/users/page.tsx @@ -0,0 +1,10 @@ +import UsersPage from "../../../settings/users/page"; +import type { SearchParams } from "@/modules/settings/lib/pagination"; + +type ScopedUsersPageProps = { + searchParams?: Promise; +}; + +export default async function ScopedUsersPage({ searchParams }: ScopedUsersPageProps): Promise { + return UsersPage({ searchParams }); +} diff --git a/app/(app)/app/beds/page.tsx b/app/(app)/app/beds/page.tsx index 32cd359..7f038c4 100644 --- a/app/(app)/app/beds/page.tsx +++ b/app/(app)/app/beds/page.tsx @@ -9,6 +9,7 @@ import { } from "@/modules/core/server/operations"; import { hasPermission } from "@/modules/core/server/permissions"; import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; export default async function BedsPage(): Promise { const context = await getCurrentAuthContext(); @@ -17,7 +18,7 @@ export default async function BedsPage(): Promise { } if (!hasPermission(context.permissions, "facility:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } const organizationId = context.organization?.id; diff --git a/app/(app)/app/dashboard/page.tsx b/app/(app)/app/dashboard/page.tsx index 291e0d1..5291c60 100644 --- a/app/(app)/app/dashboard/page.tsx +++ b/app/(app)/app/dashboard/page.tsx @@ -7,10 +7,13 @@ import { listOperationalElders, listOperationalIncidents, } from "@/modules/core/server/operations"; -import type { BedStatus } from "@/modules/core/types"; +import { hasPermission } from "@/modules/core/server/permissions"; +import type { BedStatus, Permission } from "@/modules/core/types"; import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"]; +const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read"]; export default async function DashboardPage(): Promise { const context = await getCurrentAuthContext(); @@ -19,6 +22,11 @@ export default async function DashboardPage(): Promise { } const organizationId = context.organization?.id; + const canViewDashboard = DASHBOARD_PERMISSIONS.some((permission) => hasPermission(context.permissions, permission)); + if (!canViewDashboard) { + redirect(getWorkspaceHref(context.organization?.slug, "/family")); + } + const [elders, beds, admissions, incidents] = await Promise.all([ listOperationalElders(organizationId), listOperationalBeds(organizationId), diff --git a/app/(app)/app/elders/page.tsx b/app/(app)/app/elders/page.tsx index 422d4d9..906e14b 100644 --- a/app/(app)/app/elders/page.tsx +++ b/app/(app)/app/elders/page.tsx @@ -2,7 +2,9 @@ import { redirect } from "next/navigation"; import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { listOperationalElders } from "@/modules/core/server/operations"; +import { hasPermission } from "@/modules/core/server/permissions"; import { EldersClient } from "@/modules/elders/components/EldersClient"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; export default async function EldersPage(): Promise { const context = await getCurrentAuthContext(); @@ -10,6 +12,10 @@ export default async function EldersPage(): Promise { redirect("/login"); } + if (!hasPermission(context.permissions, "elder:read")) { + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); + } + const elders = await listOperationalElders(context.organization?.id); return ; diff --git a/app/(app)/app/page.tsx b/app/(app)/app/page.tsx index 5aa81cd..17cf136 100644 --- a/app/(app)/app/page.tsx +++ b/app/(app)/app/page.tsx @@ -1,5 +1,9 @@ import { redirect } from "next/navigation"; -export default function AppIndexPage(): never { - redirect("/app/dashboard"); +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; + +export default async function AppIndexPage(): Promise { + const context = await getCurrentAuthContext(); + redirect(getWorkspaceHref(context?.organization?.slug, "/dashboard")); } diff --git a/app/(app)/app/settings/audit/page.tsx b/app/(app)/app/settings/audit/page.tsx index f35ccf9..ad2275d 100644 --- a/app/(app)/app/settings/audit/page.tsx +++ b/app/(app)/app/settings/audit/page.tsx @@ -7,6 +7,7 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { hasPermission } from "@/modules/core/server/permissions"; import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar"; import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type AuditPageProps = { searchParams?: Promise; @@ -19,9 +20,10 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi } if (!hasPermission(context.permissions, "audit:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } + const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/audit"); const params = (await searchParams) ?? {}; const query = getSearchParam(params, "q"); const page = getPage(params); @@ -47,7 +49,7 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi diff --git a/app/(app)/app/settings/global/page.tsx b/app/(app)/app/settings/global/page.tsx index 25c6fb1..e43aecc 100644 --- a/app/(app)/app/settings/global/page.tsx +++ b/app/(app)/app/settings/global/page.tsx @@ -4,6 +4,7 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth"; import { hasPermission } from "@/modules/core/server/permissions"; import { getSystemSettings } from "@/modules/core/server/system-settings"; import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; export default async function GlobalSettingsPage(): Promise { const context = await getCurrentAuthContext(); @@ -12,7 +13,7 @@ export default async function GlobalSettingsPage(): Promise } if (!hasPermission(context.permissions, "platform:manage")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } const settings = await getSystemSettings(); diff --git a/app/(app)/app/settings/organizations/[id]/page.tsx b/app/(app)/app/settings/organizations/[id]/page.tsx index 37f141d..fb0553b 100644 --- a/app/(app)/app/settings/organizations/[id]/page.tsx +++ b/app/(app)/app/settings/organizations/[id]/page.tsx @@ -15,6 +15,7 @@ import { listSettingsOrganizations, } from "@/modules/core/server/settings"; import { OrganizationDetailClient } from "@/modules/settings/components/OrganizationDetailClient"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type OrganizationDetailPageProps = { params: Promise<{ @@ -31,7 +32,7 @@ export default async function OrganizationDetailPage({ } if (!hasPermission(context.permissions, "organization:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } const { id } = await params; @@ -42,7 +43,7 @@ export default async function OrganizationDetailPage({ } if (!context.account.platformRoleId && context.organization?.id && context.organization.id !== organization.id) { - redirect("/app/settings/organizations"); + redirect(getWorkspaceHref(context.organization?.slug, "/settings/organizations")); } const [roles, organizationMemberships, organizationAccounts, invitations, joinRequests] = await Promise.all([ @@ -72,7 +73,7 @@ export default async function OrganizationDetailPage({
- + diff --git a/app/(app)/app/settings/organizations/page.tsx b/app/(app)/app/settings/organizations/page.tsx index fbc69f7..b6b565a 100644 --- a/app/(app)/app/settings/organizations/page.tsx +++ b/app/(app)/app/settings/organizations/page.tsx @@ -12,6 +12,7 @@ import { } from "@/modules/settings/components/OrganizationManagementClient"; import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar"; import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type OrganizationsPageProps = { searchParams?: Promise; @@ -24,9 +25,10 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP } if (!hasPermission(context.permissions, "organization:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } + const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/organizations"); const params = (await searchParams) ?? {}; const query = getSearchParam(params, "q"); const page = getPage(params); @@ -64,7 +66,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP {organization.name} @@ -118,7 +120,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP diff --git a/app/(app)/app/settings/page.tsx b/app/(app)/app/settings/page.tsx index 289ce3d..7a4be63 100644 --- a/app/(app)/app/settings/page.tsx +++ b/app/(app)/app/settings/page.tsx @@ -1,5 +1,9 @@ import { redirect } from "next/navigation"; +import { getCurrentAuthContext } from "@/modules/core/server/auth"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; + export default async function SettingsPage(): Promise { - redirect("/app/settings/users"); + const context = await getCurrentAuthContext(); + redirect(getWorkspaceHref(context?.organization?.slug, "/settings/users")); } diff --git a/app/(app)/app/settings/roles/page.tsx b/app/(app)/app/settings/roles/page.tsx index 3034904..79b547e 100644 --- a/app/(app)/app/settings/roles/page.tsx +++ b/app/(app)/app/settings/roles/page.tsx @@ -7,6 +7,7 @@ import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/mod import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient"; import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar"; import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type RolesPageProps = { searchParams?: Promise; @@ -19,9 +20,10 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi } if (!hasPermission(context.permissions, "role:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } + const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/roles"); const params = (await searchParams) ?? {}; const query = getSearchParam(params, "q"); const page = getPage(params); @@ -48,7 +50,7 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi diff --git a/app/(app)/app/settings/status/page.tsx b/app/(app)/app/settings/status/page.tsx index 9c904f5..cc55d48 100644 --- a/app/(app)/app/settings/status/page.tsx +++ b/app/(app)/app/settings/status/page.tsx @@ -15,6 +15,7 @@ import { import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions"; import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar"; import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type StatusPageProps = { searchParams?: Promise; @@ -27,9 +28,10 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro } if (!hasPermission(context.permissions, "incident:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } + const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/status"); const params = (await searchParams) ?? {}; const query = getSearchParam(params, "q"); const page = getPage(params); @@ -93,7 +95,7 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro diff --git a/app/(app)/app/settings/users/page.tsx b/app/(app)/app/settings/users/page.tsx index 351c0f5..fa399c6 100644 --- a/app/(app)/app/settings/users/page.tsx +++ b/app/(app)/app/settings/users/page.tsx @@ -17,6 +17,7 @@ import { TablePagination, TableToolbar } from "@/modules/settings/components/Tab import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient"; import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination"; import { UserAvatar } from "@/modules/shared/components/UserAvatar"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type UsersPageProps = { searchParams?: Promise; @@ -37,9 +38,10 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi } if (!hasPermission(context.permissions, "account:read")) { - redirect("/app/dashboard"); + redirect(getWorkspaceHref(context.organization?.slug, "/dashboard")); } + const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/users"); const params = (await searchParams) ?? {}; const query = getSearchParam(params, "q"); const page = getPage(params); @@ -90,7 +92,7 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi diff --git a/app/api/organizations/[id]/route.ts b/app/api/organizations/[id]/route.ts index c1ef062..91a522d 100644 --- a/app/api/organizations/[id]/route.ts +++ b/app/api/organizations/[id]/route.ts @@ -6,6 +6,7 @@ import { requirePermission } from "@/modules/core/server/auth"; import { getDatabase } from "@/modules/core/server/db"; import { organizations } from "@/modules/core/server/schema"; import type { OrganizationStatus } from "@/modules/core/types"; +import { RESERVED_WORKSPACE_SLUGS } from "@/modules/shared/lib/workspace-routing"; type RouteContext = { params: Promise<{ @@ -38,7 +39,7 @@ function readStatus(value: unknown): OrganizationStatus | null { } function isValidSlug(value: string): boolean { - return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-"); + return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-") && !RESERVED_WORKSPACE_SLUGS.has(value); } export async function PATCH(request: Request, context: RouteContext): Promise { @@ -74,7 +75,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -18,7 +19,7 @@ function readString(source: Record, key: string): string { } function isValidSlug(value: string): boolean { - return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-"); + return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-") && !RESERVED_WORKSPACE_SLUGS.has(value); } export async function GET(): Promise { @@ -75,7 +76,7 @@ export async function POST(request: Request): Promise { return jsonFailure("机构标识不能为空"); } if (!isValidSlug(slug)) { - return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,且不能以连字符结尾"); + return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,不能以连字符结尾,且不能使用系统路径保留字"); } const database = getDatabase(); diff --git a/modules/auth/components/AuthPanel.tsx b/modules/auth/components/AuthPanel.tsx index eb352c1..e05fc09 100644 --- a/modules/auth/components/AuthPanel.tsx +++ b/modules/auth/components/AuthPanel.tsx @@ -11,7 +11,8 @@ import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import type { ApiResult } from "@/modules/core/server/api"; -import type { Organization } from "@/modules/core/types"; +import type { AccountOrganizationOption, Organization } from "@/modules/core/types"; +import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing"; type AuthMode = "login" | "register" | "setup"; @@ -45,6 +46,27 @@ async function readApiResult>(response: Respon } } +type SessionPayload = { + account: unknown | null; + organization: Organization | null; + organizations: AccountOrganizationOption[]; +}; + +function getSessionWorkspaceHref(session: SessionPayload, preferredHref: string): string { + const activeOrganization = session.organization ?? session.organizations.find((organization) => organization.isActive); + return getWorkspaceHref(activeOrganization?.slug, getWorkspacePathFromPathname(preferredHref)); +} + +async function resolveWorkspaceRedirect(preferredHref: string): Promise { + const sessionResponse = await fetch("/api/auth/session"); + const session = await readApiResult(sessionResponse); + if (!session.success) { + return preferredHref; + } + + return getSessionWorkspaceHref(session, preferredHref); +} + function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { const router = useRouter(); const searchParams = useSearchParams(); @@ -69,7 +91,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { fetch("/api/organizations"), ]); const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse); - const session = await readApiResult<{ account: unknown | null }>(sessionResponse); + const session = await readApiResult(sessionResponse); const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse); if (!isMounted || !bootstrap.success || !session.success) { @@ -97,7 +119,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { } if (session.account) { - router.replace("/app/dashboard"); + router.replace(getSessionWorkspaceHref(session, "/app/dashboard")); } } @@ -159,7 +181,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement { } router.refresh(); - router.replace(isLogin ? redirectTo : "/app/dashboard"); + router.replace(await resolveWorkspaceRedirect(isLogin ? redirectTo : "/app/dashboard")); } if (hasAccounts === null) { diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts index 7e847da..a570455 100644 --- a/modules/core/server/auth.ts +++ b/modules/core/server/auth.ts @@ -27,6 +27,7 @@ import type { PublicAccount, Session, } from "@/modules/core/types"; +import { RESERVED_WORKSPACE_SLUGS } from "@/modules/shared/lib/workspace-routing"; const SESSION_COOKIE = "teatea_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; @@ -154,7 +155,8 @@ function createSlug(name: string): string { .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); - return base.length > 0 ? base : `org-${Date.now()}`; + const slug = base.length > 0 ? base : `org-${Date.now()}`; + return RESERVED_WORKSPACE_SLUGS.has(slug) ? `org-${slug}` : slug; } export async function setSessionCookie(sessionId: string): Promise { diff --git a/modules/core/server/permissions.ts b/modules/core/server/permissions.ts index 81b237d..7fc1f2c 100644 --- a/modules/core/server/permissions.ts +++ b/modules/core/server/permissions.ts @@ -1,7 +1,7 @@ import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { getDatabase } from "@/modules/core/server/db"; -import { permissions, rolePermissions, roles } from "@/modules/core/server/schema"; +import { organizations, permissions, rolePermissions, roles } from "@/modules/core/server/schema"; import { ensureSystemSettings } from "@/modules/core/server/system-settings"; import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types"; import { PERMISSIONS, ROLE_LABELS } from "@/modules/core/types"; @@ -126,9 +126,21 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [ { key: "viewer", scope: "organization", - description: "只读访客,查看老人、床位和入住信息。", + description: "普通用户,只读查看老人、床位和入住信息。", permissions: ["facility:read", "admission:read", "elder:read"], }, + { + key: "family", + scope: "organization", + description: "亲属账号,预留给家属端与授权老人动态访问。", + permissions: [], + }, + { + key: "resident", + scope: "organization", + description: "本人账号,预留给老人本人查看个人服务与健康信息。", + permissions: [], + }, ]; export async function ensureSystemDefaults(): Promise { @@ -181,6 +193,9 @@ export async function ensureSystemDefaults(): Promise { if (inserts.length > 0) { await database.insert(rolePermissions).values(inserts).onConflictDoNothing(); } + + const organizationRows = await database.select({ id: organizations.id }).from(organizations); + await Promise.all(organizationRows.map((organization) => seedOrganizationRoles(organization.id))); } export async function seedOrganizationRoles(organizationId: string): Promise { diff --git a/modules/core/types.ts b/modules/core/types.ts index a944072..74b2844 100644 --- a/modules/core/types.ts +++ b/modules/core/types.ts @@ -9,6 +9,8 @@ export const ROLE_IDS = [ "manager", "caregiver", "viewer", + "family", + "resident", ] as const; export type RoleId = (typeof ROLE_IDS)[number]; @@ -20,7 +22,9 @@ export const ROLE_LABELS: Record = { org_admin: "机构管理员", manager: "运营主管", caregiver: "照护人员", - viewer: "只读访客", + viewer: "普通用户", + family: "亲属", + resident: "本人", }; export const PERMISSIONS = [ diff --git a/modules/shared/components/AccountMenu.tsx b/modules/shared/components/AccountMenu.tsx index e58bc1f..8bebbfe 100644 --- a/modules/shared/components/AccountMenu.tsx +++ b/modules/shared/components/AccountMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { Building2, Check, ChevronUp, Ellipsis, Link2, LogOut, Settings } from "lucide-react"; import { FormEvent, useEffect, useRef, useState } from "react"; @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; import type { ApiResult } from "@/modules/core/server/api"; import type { AccountOrganizationOption, Organization, PublicAccount } from "@/modules/core/types"; import { UserAvatar } from "@/modules/shared/components/UserAvatar"; +import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing"; import { cn } from "@/lib/utils"; type AccountMenuProps = { @@ -25,6 +26,7 @@ export function AccountMenu({ organizations, }: AccountMenuProps): React.ReactElement { const router = useRouter(); + const pathname = usePathname(); const menuRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [isOrganizationOpen, setIsOrganizationOpen] = useState(false); @@ -71,7 +73,10 @@ export function AccountMenu({ headers: { "content-type": "application/json" }, body: JSON.stringify({ organizationId }), }); - const result = (await response.json()) as ApiResult>; + const result = (await response.json()) as ApiResult<{ + organization: Organization | null; + organizations: AccountOrganizationOption[]; + }>; setIsPending(false); if (!result.success) { @@ -80,6 +85,9 @@ export function AccountMenu({ } setIsOrganizationOpen(false); + const activeOrganization = result.organization ?? result.organizations.find((item) => item.isActive); + const workspacePath = getWorkspacePathFromPathname(pathname); + router.replace(getWorkspaceHref(activeOrganization?.slug, workspacePath)); router.refresh(); } diff --git a/modules/shared/components/AppBreadcrumbs.tsx b/modules/shared/components/AppBreadcrumbs.tsx index 49da7eb..16a736d 100644 --- a/modules/shared/components/AppBreadcrumbs.tsx +++ b/modules/shared/components/AppBreadcrumbs.tsx @@ -4,55 +4,60 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { ChevronRight } from "lucide-react"; +import { getWorkspaceHref, getWorkspacePathFromPathname, getWorkspaceSlugFromPathname } from "@/modules/shared/lib/workspace-routing"; + type BreadcrumbItem = { href?: string; label: string; }; const routeLabels: Record = { - "/app/dashboard": "运营看板", - "/app/elders": "老人档案", - "/app/beds": "床位房间", - "/app/health": "健康照护", - "/app/care": "护理服务", - "/app/emergency": "安全应急", - "/app/devices": "设备运维", - "/app/notices": "公告通知", - "/app/alerts": "规则预警", - "/app/family": "家属服务", - "/app/settings/global": "全局配置", - "/app/settings/organizations": "机构管理", - "/app/settings/users": "用户管理", - "/app/settings/roles": "角色权限", - "/app/settings/audit": "审计日志", - "/app/settings/status": "运行状态", + "/dashboard": "运营看板", + "/elders": "老人档案", + "/beds": "床位房间", + "/health": "健康照护", + "/care": "护理服务", + "/emergency": "安全应急", + "/devices": "设备运维", + "/notices": "公告通知", + "/alerts": "规则预警", + "/family": "家属服务", + "/settings/global": "全局配置", + "/settings/organizations": "机构管理", + "/settings/users": "用户管理", + "/settings/roles": "角色权限", + "/settings/audit": "审计日志", + "/settings/status": "运行状态", }; function getBreadcrumbs(pathname: string): BreadcrumbItem[] { - if (pathname === "/app" || pathname === "/app/dashboard") { + const workspacePath = getWorkspacePathFromPathname(pathname); + const organizationSlug = getWorkspaceSlugFromPathname(pathname); + + if (workspacePath === "/dashboard") { return [{ label: "工作台" }]; } - if (pathname.startsWith("/app/settings/organizations/")) { + if (workspacePath.startsWith("/settings/organizations/")) { return [ - { href: "/app/dashboard", label: "工作台" }, - { href: "/app/settings/users", label: "系统设置" }, - { href: "/app/settings/organizations", label: "机构管理" }, + { href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" }, + { href: getWorkspaceHref(organizationSlug, "/settings/users"), label: "管理系统" }, + { href: getWorkspaceHref(organizationSlug, "/settings/organizations"), label: "机构管理" }, { label: "机构详情" }, ]; } - if (pathname.startsWith("/app/settings")) { + if (workspacePath.startsWith("/settings")) { return [ - { href: "/app/dashboard", label: "工作台" }, - { href: "/app/settings/users", label: "系统设置" }, - { label: routeLabels[pathname] ?? "设置" }, + { href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" }, + { href: getWorkspaceHref(organizationSlug, "/settings/users"), label: "管理系统" }, + { label: routeLabels[workspacePath] ?? "设置" }, ]; } return [ - { href: "/app/dashboard", label: "工作台" }, - { label: routeLabels[pathname] ?? "工作区" }, + { href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" }, + { label: routeLabels[workspacePath] ?? "工作区" }, ]; } diff --git a/modules/shared/components/AppShell.tsx b/modules/shared/components/AppShell.tsx index dfba69b..5302c14 100644 --- a/modules/shared/components/AppShell.tsx +++ b/modules/shared/components/AppShell.tsx @@ -8,6 +8,7 @@ import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs"; import { AppSidebarNav } from "@/modules/shared/components/AppSidebarNav"; import { UserAvatar } from "@/modules/shared/components/UserAvatar"; import { navItems } from "@/modules/shared/lib/navigation"; +import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing"; type AppShellProps = { children: React.ReactNode; @@ -24,11 +25,14 @@ export function AppShell({ organizations, permissions, }: AppShellProps): React.ReactElement { + const organizationSlug = organization?.slug; + const dashboardHref = getWorkspaceHref(organizationSlug, "/dashboard"); + return (