@allbluecn/web-app 0.10.3 → 0.11.1
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/package.json +24 -11
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +10 -2
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
- package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +26 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +36 -13
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +122 -0
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +50 -12
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +105 -4
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import { Link, useRouter } from "@tanstack/react-router";
|
|
20
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
21
|
+
import { toast } from "sonner";
|
|
22
|
+
import {
|
|
23
|
+
BookOpenText, CornerUpRight, FileSymlink, GripVertical, LayoutGrid, Link as LinkIcon, Link2, Pencil, Plus, SquareArrowOutUpRight, Trash2, Unlink,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { DragDropProvider, useDroppable, type DragEndEvent, type DragMoveEvent } from "@dnd-kit/react";
|
|
26
|
+
import { useSortable } from "@dnd-kit/react/sortable";
|
|
27
|
+
import { Feedback } from "@dnd-kit/dom";
|
|
28
|
+
import { OptimisticSortingPlugin } from "@dnd-kit/dom/sortable";
|
|
29
|
+
import { Badge } from "@/components/ui/badge";
|
|
30
|
+
import { Button } from "@/components/ui/button";
|
|
31
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
32
|
+
import { Input } from "@/components/ui/input";
|
|
33
|
+
import {
|
|
34
|
+
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
35
|
+
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
36
|
+
} from "@/components/ui/alert-dialog";
|
|
37
|
+
import {
|
|
38
|
+
Tooltip, TooltipContent, TooltipTrigger,
|
|
39
|
+
} from "@/components/ui/tooltip";
|
|
40
|
+
import {
|
|
41
|
+
setWorkspaceKbsFn,
|
|
42
|
+
renameQuickDocFn,
|
|
43
|
+
removeQuickDocFn,
|
|
44
|
+
reorderQuickDocsFn,
|
|
45
|
+
} from "@/server/serverFns/workspace-kb";
|
|
46
|
+
import {
|
|
47
|
+
generalDocHref,
|
|
48
|
+
LABEL_COLORS,
|
|
49
|
+
QUICK_DOC_COLUMNS,
|
|
50
|
+
type KbBrief, type QuickDocRow, type WorkspaceKbCardData,
|
|
51
|
+
} from "@/server/serverFns/workspace-kb-utils";
|
|
52
|
+
import { WorkspaceKbManageDialog } from "./workspace-kb-manage-dialog";
|
|
53
|
+
import { WorkspaceQuickDocDialog } from "./workspace-quick-doc-dialog";
|
|
54
|
+
import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
|
|
55
|
+
import { m } from "@/paraglide/messages.js";
|
|
56
|
+
import { t } from "@/lib/i18n/resolver";
|
|
57
|
+
|
|
58
|
+
interface DropPreview {
|
|
59
|
+
colIdx: number;
|
|
60
|
+
insertAt: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 计算指针在某列内的插入位置(与各行中点比较) */
|
|
64
|
+
function computeInsertIndex(colEl: HTMLElement, clientY: number): number {
|
|
65
|
+
const rows = Array.from(colEl.querySelectorAll<HTMLElement>("[data-quick-doc-row]"));
|
|
66
|
+
for (let i = 0; i < rows.length; i++) {
|
|
67
|
+
const rect = rows[i].getBoundingClientRect();
|
|
68
|
+
if (clientY < rect.top + rect.height / 2) return i;
|
|
69
|
+
}
|
|
70
|
+
return rows.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 拖动中的插入位置指示线 */
|
|
74
|
+
function DropIndicator() {
|
|
75
|
+
return <div className="h-0.5 shrink-0 rounded-full bg-primary" data-drop-indicator />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
interface QuickDocRowItemProps {
|
|
80
|
+
row: QuickDocRow;
|
|
81
|
+
index: number;
|
|
82
|
+
onRename: (id: string, title: string) => void;
|
|
83
|
+
onDelete: (row: QuickDocRow) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 行链接目标(行内 Link 与「新标签打开」按钮共用);stale 行返回 null */
|
|
87
|
+
function docLinkTarget(row: QuickDocRow): { to: string; params: Record<string, string> } | null {
|
|
88
|
+
if (row.source === "doc" && row.kbId && row.docId) {
|
|
89
|
+
return { to: "/knowledge/$kbId/doc/$docId", params: { kbId: row.kbId, docId: row.docId } };
|
|
90
|
+
}
|
|
91
|
+
if (row.source === "general" && row.group && row.externalId) {
|
|
92
|
+
return { to: generalDocHref(row.group, row.externalId), params: {} };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 判断行是否有外部直接跳转 URL(非内部路由) */
|
|
98
|
+
function hasExternalUrl(row: QuickDocRow): boolean {
|
|
99
|
+
return row.source === "general" && !!row.url;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function QuickDocIcon({ row }: { row: QuickDocRow }) {
|
|
103
|
+
if (row.icon) {
|
|
104
|
+
return <LucideIconByName name={row.icon} size={14} className="shrink-0 text-muted-foreground" />;
|
|
105
|
+
}
|
|
106
|
+
if (row.faviconUrl) {
|
|
107
|
+
return (
|
|
108
|
+
<img
|
|
109
|
+
src={row.faviconUrl}
|
|
110
|
+
alt=""
|
|
111
|
+
className="size-3.5 shrink-0 rounded-sm"
|
|
112
|
+
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
|
113
|
+
/>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (row.source === "general" && row.group === "bookmark") {
|
|
117
|
+
return <LinkIcon className="size-3.5 shrink-0 text-muted-foreground" />;
|
|
118
|
+
}
|
|
119
|
+
return <BookOpenText className="size-3.5 shrink-0 text-muted-foreground" />;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const QuickDocRowItem = React.memo(function QuickDocRowItem({ row, index, onRename, onDelete }: QuickDocRowItemProps) {
|
|
123
|
+
const { handleRef, ref, isDragging } = useSortable({
|
|
124
|
+
id: row.id,
|
|
125
|
+
index,
|
|
126
|
+
group: "workspace-kb-card",
|
|
127
|
+
// move 模式:拖拽中仅对原元素做 transform,禁止 dnd-kit 插入 placeholder/移动
|
|
128
|
+
// React 管理的 DOM 节点(否则 React commit 时 removeChild 崩溃,拖入空列必现)
|
|
129
|
+
plugins: (defaults) => [
|
|
130
|
+
// 禁用乐观排序插件:它会在拖动中用 insertAdjacentElement 直接移动
|
|
131
|
+
// React 管理的行节点(与 React commit 竞争导致 removeChild 崩溃),
|
|
132
|
+
// 排序完全由 onDragEnd 的 setState 驱动(受控模式)
|
|
133
|
+
...defaults.filter((plugin) => plugin !== OptimisticSortingPlugin),
|
|
134
|
+
// move 模式不插入 placeholder;禁用 drop 动画避免松手时视觉跳动
|
|
135
|
+
Feedback.configure({ feedback: "move", dropAnimation: null }),
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
const [editing, setEditing] = React.useState(false);
|
|
139
|
+
const [draft, setDraft] = React.useState(row.displayTitle);
|
|
140
|
+
// 编辑退出后的短暂窗口内拦截 click,防止 blur 提交编辑后事件落在 Link 上导致跳转
|
|
141
|
+
const skipClickRef = React.useRef(false);
|
|
142
|
+
|
|
143
|
+
const startEdit = (e: React.MouseEvent) => {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
setDraft(row.displayTitle);
|
|
147
|
+
setEditing(true);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const commit = () => {
|
|
151
|
+
const next = draft.trim();
|
|
152
|
+
setEditing(false);
|
|
153
|
+
skipClickRef.current = true;
|
|
154
|
+
window.setTimeout(() => {
|
|
155
|
+
skipClickRef.current = false;
|
|
156
|
+
}, 200);
|
|
157
|
+
if (!next) return;
|
|
158
|
+
if (next !== row.displayTitle) {
|
|
159
|
+
onRename(row.id, next);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const titleBlock = editing ? (
|
|
164
|
+
<Input
|
|
165
|
+
autoFocus
|
|
166
|
+
value={draft}
|
|
167
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
168
|
+
onBlur={commit}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === "Enter") commit();
|
|
171
|
+
if (e.key === "Escape") setEditing(false);
|
|
172
|
+
}}
|
|
173
|
+
onClick={(e) => e.stopPropagation()}
|
|
174
|
+
className="h-6 w-full text-sm"
|
|
175
|
+
aria-label={m.workspace_kb_renameAriaLabel()}
|
|
176
|
+
/>
|
|
177
|
+
) : (
|
|
178
|
+
<span className="min-w-0 truncate text-sm">{row.displayTitle}</span>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const linkTarget = docLinkTarget(row);
|
|
182
|
+
// 标签同色系更浅背景(color-mix 透明度混合,深浅色主题自适应)
|
|
183
|
+
const labelColor = row.label ? LABEL_COLORS[row.label] : null;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
ref={ref}
|
|
188
|
+
data-quick-doc-row
|
|
189
|
+
onMouseDownCapture={(e) => {
|
|
190
|
+
// 编辑中点击行内任意位置:阻止焦点转移(Input 保持焦点,不触发 blur 提交与跳转)
|
|
191
|
+
if (editing && (e.target as HTMLElement).tagName !== "INPUT") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
}
|
|
194
|
+
}}
|
|
195
|
+
onClickCapture={(e) => {
|
|
196
|
+
if (skipClickRef.current) {
|
|
197
|
+
e.stopPropagation();
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
style={labelColor ? { backgroundColor: `color-mix(in srgb, ${labelColor.backgroundColor} var(--kb-row-mix), transparent)` } : undefined}
|
|
202
|
+
className={`group/row flex items-center gap-1.5 rounded-md border px-2 py-2 transition-colors ${isDragging ? "border-primary/40 opacity-40" : `border-transparent hover:border-border ${labelColor ? "" : "bg-muted/40 hover:bg-muted/70"}`}`}
|
|
203
|
+
>
|
|
204
|
+
<button
|
|
205
|
+
ref={handleRef}
|
|
206
|
+
type="button"
|
|
207
|
+
className="cursor-grab shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/row:opacity-100"
|
|
208
|
+
aria-label={m.workspace_kb_dragHandleAriaLabel()}
|
|
209
|
+
>
|
|
210
|
+
<GripVertical className="size-3.5" />
|
|
211
|
+
</button>
|
|
212
|
+
<QuickDocIcon row={row} />
|
|
213
|
+
{row.stale ? (
|
|
214
|
+
<div className="min-w-0 flex-1 truncate" aria-disabled>
|
|
215
|
+
{titleBlock}
|
|
216
|
+
</div>
|
|
217
|
+
) : linkTarget ? (
|
|
218
|
+
// 编辑态不渲染 Link,避免 Input 在 Link 内部导致的点击跳转问题
|
|
219
|
+
// editing=false 时 Link 重新挂载,后续点击正常触发导航
|
|
220
|
+
editing ? (
|
|
221
|
+
<span className="min-w-0 flex-1 truncate text-sm">{titleBlock}</span>
|
|
222
|
+
) : hasExternalUrl(row) ? (
|
|
223
|
+
// 有外部 URL:直接用 <a> 跳转,不走 SPA 路由
|
|
224
|
+
<a
|
|
225
|
+
href={row.url!}
|
|
226
|
+
target="_blank"
|
|
227
|
+
rel="noreferrer"
|
|
228
|
+
className="min-w-0 flex-1 truncate text-sm"
|
|
229
|
+
>
|
|
230
|
+
{titleBlock}
|
|
231
|
+
</a>
|
|
232
|
+
) : (
|
|
233
|
+
<Link
|
|
234
|
+
to={linkTarget.to}
|
|
235
|
+
params={linkTarget.params}
|
|
236
|
+
className="min-w-0 flex-1 truncate text-sm"
|
|
237
|
+
>
|
|
238
|
+
{titleBlock}
|
|
239
|
+
</Link>
|
|
240
|
+
)
|
|
241
|
+
) : (
|
|
242
|
+
<span className="min-w-0 flex-1 truncate text-sm">{titleBlock}</span>
|
|
243
|
+
)}
|
|
244
|
+
<div className={`flex shrink-0 items-center ${editing ? "" : "relative"}`}>
|
|
245
|
+
<div className={`flex items-center gap-1 ${editing ? "hidden" : "transition-opacity group-hover/row:opacity-0 group-hover/row:pointer-events-none"}`}>
|
|
246
|
+
{row.label && (
|
|
247
|
+
<Badge
|
|
248
|
+
variant="outline"
|
|
249
|
+
className="h-4 shrink-0 px-1 py-0 text-[10px] leading-none"
|
|
250
|
+
style={LABEL_COLORS[row.label]}
|
|
251
|
+
>
|
|
252
|
+
{t(`workspace_kb_label_${row.label}`)}
|
|
253
|
+
</Badge>
|
|
254
|
+
)}
|
|
255
|
+
{row.stale && (
|
|
256
|
+
<Badge variant="secondary" className="h-4 shrink-0 px-1 py-0 text-[10px] leading-none">
|
|
257
|
+
{m.workspace_kb_stale()}
|
|
258
|
+
</Badge>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
<div className={`items-center gap-0.5 pl-1 ${editing ? "flex" : "absolute inset-y-0 right-0 hidden opacity-0 transition-opacity group-hover/row:opacity-100 group-hover/row:pointer-events-auto group-hover/row:flex"}`}>
|
|
262
|
+
{linkTarget && (
|
|
263
|
+
<Tooltip>
|
|
264
|
+
<TooltipTrigger asChild>
|
|
265
|
+
<Button variant="ghost" size="sm" className="size-5 p-0" asChild>
|
|
266
|
+
<Link to={linkTarget.to} params={linkTarget.params} target="_blank" rel="noreferrer">
|
|
267
|
+
<SquareArrowOutUpRight className="size-3" />
|
|
268
|
+
</Link>
|
|
269
|
+
</Button>
|
|
270
|
+
</TooltipTrigger>
|
|
271
|
+
<TooltipContent side="bottom">{m.workspace_kb_openInNewTab()}</TooltipContent>
|
|
272
|
+
</Tooltip>
|
|
273
|
+
)}
|
|
274
|
+
<Button
|
|
275
|
+
variant="ghost"
|
|
276
|
+
size="sm"
|
|
277
|
+
className="size-5 p-0"
|
|
278
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
279
|
+
onClick={startEdit}
|
|
280
|
+
aria-label={m.workspace_kb_renameAriaLabel()}
|
|
281
|
+
>
|
|
282
|
+
<Pencil className="size-3" />
|
|
283
|
+
</Button>
|
|
284
|
+
<Button variant="ghost" size="sm" className="size-5 p-0 hover:text-destructive" onClick={() => onDelete(row)} aria-label={m.workspace_kb_deleteAriaLabel()}>
|
|
285
|
+
<Trash2 className="size-3" />
|
|
286
|
+
</Button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
interface DroppableColumnProps {
|
|
294
|
+
colIdx: number;
|
|
295
|
+
children: React.ReactNode;
|
|
296
|
+
isDragging: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function DroppableColumn({ colIdx, children, isDragging }: DroppableColumnProps) {
|
|
300
|
+
const { ref, isDropTarget } = useDroppable({
|
|
301
|
+
id: `col-${colIdx}`,
|
|
302
|
+
data: { type: "quick-doc-column", colIdx },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
ref={ref}
|
|
308
|
+
className={`flex min-h-10 flex-col gap-1 rounded-md transition-colors ${isDropTarget ? "border border-dashed border-primary/50 bg-primary/10" : isDragging ? "border border-dashed border-primary/20 bg-primary/5" : "border border-transparent"}`}
|
|
309
|
+
>
|
|
310
|
+
{children}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface WorkspaceKbCardProps {
|
|
316
|
+
workspaceId: string;
|
|
317
|
+
data: WorkspaceKbCardData;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function WorkspaceKbCard({ workspaceId, data }: WorkspaceKbCardProps) {
|
|
321
|
+
const router = useRouter();
|
|
322
|
+
const setKbs = useServerFn(setWorkspaceKbsFn);
|
|
323
|
+
const renameDoc = useServerFn(renameQuickDocFn);
|
|
324
|
+
const removeDoc = useServerFn(removeQuickDocFn);
|
|
325
|
+
const reorderDocs = useServerFn(reorderQuickDocsFn);
|
|
326
|
+
|
|
327
|
+
const [kbs, setLocalKbs] = React.useState<KbBrief[]>(data.kbs);
|
|
328
|
+
const [quickDocs, setLocalQuickDocs] = React.useState<QuickDocRow[]>(data.quickDocs);
|
|
329
|
+
const [manageOpen, setManageOpen] = React.useState(false);
|
|
330
|
+
const [addOpen, setAddOpen] = React.useState(false);
|
|
331
|
+
const [pendingDelete, setPendingDelete] = React.useState<QuickDocRow | null>(null);
|
|
332
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
333
|
+
|
|
334
|
+
React.useEffect(() => setLocalKbs(data.kbs), [data.kbs]);
|
|
335
|
+
React.useEffect(() => setLocalQuickDocs(data.quickDocs), [data.quickDocs]);
|
|
336
|
+
|
|
337
|
+
const rollback = React.useCallback((err: unknown) => {
|
|
338
|
+
toast.error((err as Error)?.message || m.workspace_kb_opFailed());
|
|
339
|
+
void router.invalidate();
|
|
340
|
+
}, [router]);
|
|
341
|
+
|
|
342
|
+
const handleRename = React.useCallback((id: string, title: string) => {
|
|
343
|
+
setLocalQuickDocs((rows) => rows.map((r) => (r.id === id ? { ...r, displayTitle: title } : r)));
|
|
344
|
+
renameDoc({ data: { quickDocId: id, displayTitle: title } }).catch((err: unknown) => {
|
|
345
|
+
rollback(err);
|
|
346
|
+
});
|
|
347
|
+
}, [renameDoc, rollback]);
|
|
348
|
+
|
|
349
|
+
const handleDelete = React.useCallback((row: QuickDocRow) => {
|
|
350
|
+
const prev = quickDocs;
|
|
351
|
+
setPendingDelete(null);
|
|
352
|
+
setLocalQuickDocs((rows) => rows.filter((r) => r.id !== row.id));
|
|
353
|
+
removeDoc({ data: { quickDocId: row.id } }).catch((err: unknown) => {
|
|
354
|
+
setLocalQuickDocs(prev);
|
|
355
|
+
rollback(err);
|
|
356
|
+
});
|
|
357
|
+
}, [quickDocs, removeDoc, rollback]);
|
|
358
|
+
|
|
359
|
+
const handleDragStart = () => setIsDragging(true);
|
|
360
|
+
const gridRef = React.useRef<HTMLDivElement | null>(null);
|
|
361
|
+
const [dropPreview, setDropPreview] = React.useState<DropPreview | null>(null);
|
|
362
|
+
const dropPreviewRef = React.useRef<DropPreview | null>(null);
|
|
363
|
+
|
|
364
|
+
const computeDropPreview = (clientX: number, clientY: number): DropPreview | null => {
|
|
365
|
+
const gridEl = gridRef.current;
|
|
366
|
+
if (!gridEl) return null;
|
|
367
|
+
const gridRect = gridEl.getBoundingClientRect();
|
|
368
|
+
if (
|
|
369
|
+
clientX < gridRect.left || clientX > gridRect.right ||
|
|
370
|
+
clientY < gridRect.top - 24 || clientY > gridRect.bottom + 24
|
|
371
|
+
) {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
const colWidth = gridRect.width / QUICK_DOC_COLUMNS;
|
|
375
|
+
const colIdx = Math.min(QUICK_DOC_COLUMNS - 1, Math.max(0, Math.floor((clientX - gridRect.left) / colWidth)));
|
|
376
|
+
const colEl = gridEl.children[colIdx] as HTMLElement | undefined;
|
|
377
|
+
const insertAt = colEl ? computeInsertIndex(colEl, clientY) : 0;
|
|
378
|
+
return { colIdx, insertAt };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const handleDragMove = (event: DragMoveEvent) => {
|
|
382
|
+
const { x, y } = event.operation.position.current;
|
|
383
|
+
const preview = computeDropPreview(x, y);
|
|
384
|
+
const prev = dropPreviewRef.current;
|
|
385
|
+
if (preview?.colIdx !== prev?.colIdx || preview?.insertAt !== prev?.insertAt) {
|
|
386
|
+
dropPreviewRef.current = preview;
|
|
387
|
+
setDropPreview(preview);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
392
|
+
setIsDragging(false);
|
|
393
|
+
const preview = dropPreviewRef.current;
|
|
394
|
+
dropPreviewRef.current = null;
|
|
395
|
+
setDropPreview(null);
|
|
396
|
+
if (event.canceled || !preview) return;
|
|
397
|
+
const source = event.operation.source;
|
|
398
|
+
if (!source) return;
|
|
399
|
+
const dragged = quickDocs.find((r) => r.id === source.id);
|
|
400
|
+
if (!dragged) return;
|
|
401
|
+
|
|
402
|
+
// 看板式精确插入:从原列移除,按指示线位置插入目标列
|
|
403
|
+
const fromCol = Math.min(Math.max(dragged.columnIndex, 0), QUICK_DOC_COLUMNS - 1);
|
|
404
|
+
const nextColumns: QuickDocRow[][] = columns.map((col) => [...col]);
|
|
405
|
+
nextColumns[fromCol] = nextColumns[fromCol].filter((r) => r.id !== dragged.id);
|
|
406
|
+
let insertAt = preview.insertAt;
|
|
407
|
+
if (preview.colIdx === fromCol) {
|
|
408
|
+
const origIdx = columns[fromCol].findIndex((r) => r.id === dragged.id);
|
|
409
|
+
if (origIdx >= 0 && origIdx < insertAt) insertAt -= 1;
|
|
410
|
+
}
|
|
411
|
+
insertAt = Math.min(insertAt, nextColumns[preview.colIdx].length);
|
|
412
|
+
nextColumns[preview.colIdx].splice(insertAt, 0, dragged);
|
|
413
|
+
|
|
414
|
+
// 重建全量布局(列归属 + 列内顺序),一次落库;本地 state 同步重写
|
|
415
|
+
// columnIndex/sortOrder(否则分组 useMemo 仍按旧列归组,UI 不动)
|
|
416
|
+
const next = nextColumns.flatMap((col, colIdx) =>
|
|
417
|
+
col.map((row, i) => ({ ...row, columnIndex: colIdx, sortOrder: i })),
|
|
418
|
+
);
|
|
419
|
+
// 守卫必须同时比较 id 序列与列归属:跨列移动可能不改变扁平顺序
|
|
420
|
+
// (如从 col0 移到相邻空 col1,id 序列不变但列归属变了)
|
|
421
|
+
if (
|
|
422
|
+
next.length === quickDocs.length &&
|
|
423
|
+
next.every((r, i) => r.id === quickDocs[i]?.id && r.columnIndex === quickDocs[i]?.columnIndex)
|
|
424
|
+
) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const items = next.map(({ id, columnIndex, sortOrder }) => ({ id, columnIndex, sortOrder }));
|
|
428
|
+
setLocalQuickDocs(next);
|
|
429
|
+
// 延迟到 dnd-kit 拖拽清理完成后再提交服务端(避免同帧 DOM 操作与 React 重渲染冲突)
|
|
430
|
+
window.setTimeout(() => {
|
|
431
|
+
reorderDocs({ data: { workspaceId, items } }).catch((err: unknown) => rollback(err));
|
|
432
|
+
}, 0);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const hasLinkedKb = kbs.length > 0;
|
|
436
|
+
|
|
437
|
+
const goToKbButton =
|
|
438
|
+
kbs.length === 0 ? (
|
|
439
|
+
<Tooltip>
|
|
440
|
+
<TooltipTrigger asChild>
|
|
441
|
+
<Button variant="ghost" size="icon" className="size-7" disabled>
|
|
442
|
+
<FileSymlink className="size-3.5" />
|
|
443
|
+
</Button>
|
|
444
|
+
</TooltipTrigger>
|
|
445
|
+
<TooltipContent side="top">{m.workspace_kb_goToKb()}</TooltipContent>
|
|
446
|
+
</Tooltip>
|
|
447
|
+
) : kbs.length === 1 ? (
|
|
448
|
+
<Tooltip>
|
|
449
|
+
<TooltipTrigger asChild>
|
|
450
|
+
<Button variant="ghost" size="icon" className="size-7" asChild>
|
|
451
|
+
<Link to="/knowledge/$kbId" params={{ kbId: kbs[0].id }}>
|
|
452
|
+
<CornerUpRight className="size-3.5" />
|
|
453
|
+
</Link>
|
|
454
|
+
</Button>
|
|
455
|
+
</TooltipTrigger>
|
|
456
|
+
<TooltipContent side="top">
|
|
457
|
+
{m.workspace_kb_goToKb()} <span className="font-medium">{kbs[0].name}</span>
|
|
458
|
+
</TooltipContent>
|
|
459
|
+
</Tooltip>
|
|
460
|
+
) : (
|
|
461
|
+
<DropdownMenu>
|
|
462
|
+
<Tooltip>
|
|
463
|
+
<TooltipTrigger asChild>
|
|
464
|
+
<DropdownMenuTrigger asChild>
|
|
465
|
+
<Button variant="ghost" size="icon" className="size-7">
|
|
466
|
+
<CornerUpRight className="size-3.5" />
|
|
467
|
+
</Button>
|
|
468
|
+
</DropdownMenuTrigger>
|
|
469
|
+
</TooltipTrigger>
|
|
470
|
+
<TooltipContent side="top">{m.workspace_kb_goToKb()}</TooltipContent>
|
|
471
|
+
</Tooltip>
|
|
472
|
+
<DropdownMenuContent align="end">
|
|
473
|
+
{kbs.map((kb) => (
|
|
474
|
+
<DropdownMenuItem key={kb.id} asChild>
|
|
475
|
+
<Link to="/knowledge/$kbId" params={{ kbId: kb.id }}>
|
|
476
|
+
<LucideIconByName name={kb.icon} size={14} className="shrink-0" />
|
|
477
|
+
<span className="truncate">{kb.name}</span>
|
|
478
|
+
</Link>
|
|
479
|
+
</DropdownMenuItem>
|
|
480
|
+
))}
|
|
481
|
+
</DropdownMenuContent>
|
|
482
|
+
</DropdownMenu>
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const isEmpty = quickDocs.length === 0;
|
|
486
|
+
|
|
487
|
+
// 看板分列:按持久化的 columnIndex 分组(quickDocs 已按 列→列内顺序 排好)
|
|
488
|
+
const columns: QuickDocRow[][] = React.useMemo(() => {
|
|
489
|
+
const result = Array.from({ length: QUICK_DOC_COLUMNS }, () => [] as QuickDocRow[]);
|
|
490
|
+
quickDocs.forEach((row) => {
|
|
491
|
+
const c = Math.min(Math.max(row.columnIndex, 0), QUICK_DOC_COLUMNS - 1);
|
|
492
|
+
result[c].push(row);
|
|
493
|
+
});
|
|
494
|
+
return result;
|
|
495
|
+
}, [quickDocs]);
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<div className="mb-6 rounded-lg border bg-card p-4">
|
|
499
|
+
<div className="mb-3 flex items-center justify-between">
|
|
500
|
+
<div className="flex items-center gap-2">
|
|
501
|
+
<LayoutGrid className="size-4 text-muted-foreground" />
|
|
502
|
+
<h2 className="text-base font-semibold">{m.workspace_home_kbTitle()}</h2>
|
|
503
|
+
</div>
|
|
504
|
+
<div className="flex items-center gap-1">
|
|
505
|
+
<Tooltip>
|
|
506
|
+
<TooltipTrigger asChild>
|
|
507
|
+
<Button
|
|
508
|
+
variant="ghost"
|
|
509
|
+
size="icon"
|
|
510
|
+
className="size-7"
|
|
511
|
+
onClick={() => setManageOpen(true)}
|
|
512
|
+
>
|
|
513
|
+
{hasLinkedKb ? <Link2 className="size-3.5" /> : <Unlink className="size-3.5" />}
|
|
514
|
+
</Button>
|
|
515
|
+
</TooltipTrigger>
|
|
516
|
+
<TooltipContent side="top">
|
|
517
|
+
{hasLinkedKb ? m.workspace_kb_linked() : m.workspace_kb_linkButton()}
|
|
518
|
+
</TooltipContent>
|
|
519
|
+
</Tooltip>
|
|
520
|
+
<Tooltip>
|
|
521
|
+
<TooltipTrigger asChild>
|
|
522
|
+
<Button variant="ghost" size="icon" className="size-7" aria-label={m.workspace_kb_addQuickDoc()} onClick={() => setAddOpen(true)}>
|
|
523
|
+
<Plus className="size-3.5" />
|
|
524
|
+
</Button>
|
|
525
|
+
</TooltipTrigger>
|
|
526
|
+
<TooltipContent side="top">{m.workspace_kb_addQuickDoc()}</TooltipContent>
|
|
527
|
+
</Tooltip>
|
|
528
|
+
{goToKbButton}
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
{quickDocs.length > 0 && (
|
|
533
|
+
<DragDropProvider onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd}>
|
|
534
|
+
<div ref={gridRef} className="grid min-h-30 grid-cols-5 items-stretch gap-1.5">
|
|
535
|
+
{columns.map((col, colIdx) => {
|
|
536
|
+
const flatBefore = columns.slice(0, colIdx).reduce((sum, c) => sum + c.length, 0);
|
|
537
|
+
return (
|
|
538
|
+
<DroppableColumn key={colIdx} colIdx={colIdx} isDragging={isDragging}>
|
|
539
|
+
{col.map((row, i) => (
|
|
540
|
+
<React.Fragment key={row.id}>
|
|
541
|
+
{isDragging && dropPreview?.colIdx === colIdx && dropPreview.insertAt === i && (
|
|
542
|
+
<DropIndicator />
|
|
543
|
+
)}
|
|
544
|
+
<QuickDocRowItem
|
|
545
|
+
row={row}
|
|
546
|
+
index={flatBefore + i}
|
|
547
|
+
onRename={handleRename}
|
|
548
|
+
onDelete={setPendingDelete}
|
|
549
|
+
/>
|
|
550
|
+
</React.Fragment>
|
|
551
|
+
))}
|
|
552
|
+
{isDragging && dropPreview?.colIdx === colIdx && dropPreview.insertAt >= col.length && (
|
|
553
|
+
<DropIndicator />
|
|
554
|
+
)}
|
|
555
|
+
</DroppableColumn>
|
|
556
|
+
);
|
|
557
|
+
})}
|
|
558
|
+
</div>
|
|
559
|
+
</DragDropProvider>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{isEmpty && (
|
|
563
|
+
<p className="py-4 text-center text-sm text-muted-foreground">{m.workspace_kb_empty()}</p>
|
|
564
|
+
)}
|
|
565
|
+
|
|
566
|
+
<WorkspaceKbManageDialog
|
|
567
|
+
workspaceId={workspaceId}
|
|
568
|
+
open={manageOpen}
|
|
569
|
+
onOpenChange={setManageOpen}
|
|
570
|
+
onUpdate={setLocalKbs}
|
|
571
|
+
/>
|
|
572
|
+
<WorkspaceQuickDocDialog
|
|
573
|
+
workspaceId={workspaceId}
|
|
574
|
+
open={addOpen}
|
|
575
|
+
onOpenChange={setAddOpen}
|
|
576
|
+
onAdded={() => void router.invalidate()}
|
|
577
|
+
/>
|
|
578
|
+
<AlertDialog open={pendingDelete !== null} onOpenChange={(o) => !o && setPendingDelete(null)}>
|
|
579
|
+
<AlertDialogContent>
|
|
580
|
+
<AlertDialogHeader>
|
|
581
|
+
<AlertDialogTitle>{m.workspace_kb_deleteConfirmTitle()}</AlertDialogTitle>
|
|
582
|
+
<AlertDialogDescription>{m.workspace_kb_deleteConfirmDesc()}</AlertDialogDescription>
|
|
583
|
+
</AlertDialogHeader>
|
|
584
|
+
<AlertDialogFooter>
|
|
585
|
+
<AlertDialogCancel onClick={() => setPendingDelete(null)}>{m.workspace_kb_cancel()}</AlertDialogCancel>
|
|
586
|
+
<AlertDialogAction onClick={() => pendingDelete && handleDelete(pendingDelete)}>
|
|
587
|
+
{m.workspace_kb_confirm()}
|
|
588
|
+
</AlertDialogAction>
|
|
589
|
+
</AlertDialogFooter>
|
|
590
|
+
</AlertDialogContent>
|
|
591
|
+
</AlertDialog>
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|