Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d4c2826
📝 docs(link): link 视图对齐 wanta 设计文档
TaTaLiao Aug 10, 2026
0199647
📋 plan(link): link 视图对齐 wanta 实施计划
TaTaLiao Aug 10, 2026
7856e5c
✨ feat(link): 品牌 logo 分层链(lobehub+simple-icons 四档兜底)
TaTaLiao Aug 10, 2026
1aadc09
🔧 chore(link): 清理生成脚本死 import(tmpdir/join/mkdtemp/rm)
TaTaLiao Aug 10, 2026
80bba51
✨ feat(ui): ToggleGroup 与 SearchField 原语
TaTaLiao Aug 10, 2026
a8fb18c
💄 refactor(link): ProviderCard 紧凑行+状态光晕点+选中态
TaTaLiao Aug 10, 2026
543f532
🐛 fix(link): ProviderCard 加 h-[68px] 恢复 cardHeight 不变量
TaTaLiao Aug 10, 2026
000eee5
✨ feat(link): LinkToolbar 搜索+筛选 ToggleGroup
TaTaLiao Aug 10, 2026
34fd07f
✨ feat(link): LinkCatalog 左栏(工具栏+虚拟化网格)
TaTaLiao Aug 10, 2026
2d707cc
🐛 fix(link): LinkCatalog gridRef 恒挂载修复 ResizeObserver 生命周期
TaTaLiao Aug 10, 2026
094ad69
♻️ refactor(link): 抽出 LinkConnectDialog/SecretField/link-auth
TaTaLiao Aug 10, 2026
a5f4941
✨ feat(link): LinkAccountsList 账户卡片
TaTaLiao Aug 10, 2026
871f557
✨ feat(link): LinkDetailPane 右栏详情面板
TaTaLiao Aug 10, 2026
4e13044
♻️ refactor(link): LinkView 重写为 Split-view 双栏(移除运行记录 UI,保留数据层)
TaTaLiao Aug 10, 2026
58582d6
💄 refactor(link): 导航图标 PlugZap → Plug 对齐 wanta
TaTaLiao Aug 10, 2026
44835fe
🐛 fix(link): 文本 token --lume-text-N → --text-N 修复层级失效
TaTaLiao Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-virtual": "^3.13.6",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
"@embedpdf/models": "^2.14.4",
Expand Down Expand Up @@ -50,6 +49,7 @@
"@lume/ui": "workspace:*",
"@pierre/diffs": "1.3.0-rc.3",
"@radix-ui/react-select": "^2.2.6",
"@tanstack/react-virtual": "^3.13.6",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-mention": "^3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
Expand All @@ -67,6 +67,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"shadcn": "^4.2.0",
"simple-icons": "^16.28.0",
"sonner": "^2.0.0",
"tailwind-merge": "^3.5.0",
"throttle-debounce": "^5.0.2",
Expand All @@ -80,6 +81,7 @@
"@types/throttle-debounce": "^5.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.1.0",
"tar-stream": "^3.2.0",
"typescript": "^5.7.2",
"vite": "^6.3.0"
}
Expand Down
70 changes: 70 additions & 0 deletions apps/web/scripts/generate-link-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 构建期:读 OpenConnector v1.3.3 service 列表 + simple-icons,生成 service→{path,hex} 映射。
import { writeFile } from "node:fs/promises";
import { extract } from "tar-stream";
import { Readable } from "node:stream";
import { gunzipSync } from "node:zlib";
import * as simpleIcons from "simple-icons";

const ARCHIVE_URL =
"https://codeload.github.com/oomol-lab/open-connector/tar.gz/refs/tags/v1.3.3";

// service(小写)→ simple-icons slug 手工修正(不一致时填)
const SLUG_OVERRIDES = {
active_campaign: "activecampaign",
google_calendar: "googlecalendar",
microsoft_teams: "microsoftteams",
};

function serviceToSlug(service) {
if (SLUG_OVERRIDES[service]) return SLUG_OVERRIDES[service];
return service.replaceAll("_", "-");
}

const services = [];

async function fetchServiceList() {
const res = await fetch(ARCHIVE_URL);
if (!res.ok || !res.body) throw new Error(`fetch ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const gz = gunzipSync(buf);
await new Promise((resolve, reject) => {
const extractor = extract();
extractor.on("entry", (header, stream, next) => {
// 仅关注 providers 目录下的 definition 路径,取目录名
const m = header.name.match(/open-connector-[^/]+\/src\/providers\/([^/]+)\/definition\.ts$/);
if (m) {
const svc = m[1];
if (!services.includes(svc)) services.push(svc);
}
stream.on("data", () => {});
stream.on("end", next);
});
extractor.on("finish", resolve);
extractor.on("error", reject);
Readable.from(gz).pipe(extractor);
});
return services.sort();
}

const map = {};
for (const service of await fetchServiceList()) {
const slug = serviceToSlug(service);
// simple-icons 导出形如 siSlack(驼峰)
const exportName =
"si" + slug.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
const icon = simpleIcons[exportName];
if (icon && icon.path) {
map[service.toLowerCase()] = { path: icon.path, hex: icon.hex };
}
}

const out = `// 自动生成(scripts/generate-link-icons.mjs)。勿手改。OpenConnector v1.3.3 × simple-icons。
export const LINK_ICONS: Record<string, { path: string; hex: string }> = ${JSON.stringify(
map,
)};\n`;
await writeFile(
new URL("../src/lib/generated/link-icons.ts", import.meta.url),
out,
"utf8",
);
console.log(`generated ${Object.keys(map).length} link icons`);
4 changes: 2 additions & 2 deletions apps/web/src/components/app-shell/LumeSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
Bot,
ListTodo,
Sparkles,
PlugZap,
Plug,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type {
Expand Down Expand Up @@ -365,7 +365,7 @@ function renderIcon(icon: string, size: number) {
case 'sparkles':
return <Sparkles size={size} />
case 'plug':
return <PlugZap size={size} />
return <Plug size={size} />
case 'folder':
return <Folder size={size} />
case 'trash':
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/link/LinkAccountsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { LinkConnectionSummary } from "@lume/shared";
import { authLabel } from "@/lib/link-auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

interface LinkAccountsListProps {
connections: LinkConnectionSummary[];
onReconnect: (connectionName: string) => void;
onRequestDelete: (connectionName: string) => void;
}

export function LinkAccountsList({ connections, onReconnect, onRequestDelete }: LinkAccountsListProps) {
if (connections.length === 0) return null;
return (
<div className="space-y-2">
<div className="text-sm font-medium text-[var(--text-1)]">已连接账户({connections.length})</div>
<div className="space-y-1.5">
{connections.map((conn) => (
<div key={conn.connectionName} className="flex items-center justify-between gap-2 rounded-md border border-[var(--lume-border-subtle)] bg-card px-3 py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-[var(--text-1)]">{conn.connectionName}</span>
{conn.default && <Badge variant="secondary">默认</Badge>}
</div>
<div className="truncate text-xs text-[var(--text-3)]">
{conn.profile?.displayName || conn.profile?.accountId || authLabel(conn.authType)}
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="outline" size="sm" onClick={() => onReconnect(conn.connectionName)}>重连</Button>
<Button variant="ghost" size="sm" className="text-[var(--lume-danger)]" onClick={() => onRequestDelete(conn.connectionName)}>断开</Button>
</div>
</div>
))}
</div>
</div>
);
}
156 changes: 156 additions & 0 deletions apps/web/src/components/link/LinkCatalog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { computeColumnCount, PROVIDER_GRID, rowCount } from "@/lib/provider-grid";
import { linkServicePriority } from "@/lib/provider-ranking";
import type { LinkConnectionSummary, LinkProviderSummary } from "@lume/shared";
import { LinkToolbar, type FilterCounts, type LinkFilter } from "./LinkToolbar";
import { ProviderCard } from "./ProviderCard";

interface LinkCatalogProps {
providers: LinkProviderSummary[];
connections: LinkConnectionSummary[];
query: string;
onQueryChange: (v: string) => void;
filter: LinkFilter;
onFilterChange: (v: LinkFilter) => void;
selectedService: string | null;
onOpen: (service: string) => void;
}

function providerStatus(provider: LinkProviderSummary, configuredServices: Set<string>, authTypes: string[]) {
const configured = configuredServices.has(provider.service);
const noSetup = authTypes.includes("no_auth");
return { configured, noSetup, needsAttention: false };
}

export function LinkCatalog({
providers, connections, query, onQueryChange, filter, onFilterChange, selectedService, onOpen,
}: LinkCatalogProps) {
const configuredServices = useMemo(
() => new Set(connections.filter((c) => c.configured).map((c) => c.service)),
[connections],
);

const annotated = useMemo(
() =>
providers.map((p) => ({
provider: p,
status: providerStatus(p, configuredServices, p.authTypes ?? []),
})),
[providers, configuredServices],
);

const counts: FilterCounts = useMemo(
() => ({
all: annotated.length,
connected: annotated.filter((a) => a.status.configured).length,
noSetup: annotated.filter((a) => a.status.noSetup).length,
needsAttention: annotated.filter((a) => a.status.needsAttention).length,
}),
[annotated],
);

const visible = useMemo(() => {
return annotated
.filter(({ provider, status }) => {
const matchesQuery =
!query ||
`${provider.displayName} ${provider.service} ${provider.description ?? ""}`
.toLowerCase()
.includes(query.toLowerCase());
const matchesFilter =
filter === "all" ||
(filter === "connected" && status.configured) ||
(filter === "noSetup" && status.noSetup) ||
(filter === "needsAttention" && status.needsAttention);
return matchesQuery && matchesFilter;
})
.sort((a, b) => {
const configuredRank = Number(b.status.configured) - Number(a.status.configured);
if (configuredRank !== 0) return configuredRank;
const priority = linkServicePriority(a.provider.service) - linkServicePriority(b.provider.service);
if (priority !== 0) return priority;
return a.provider.displayName.localeCompare(b.provider.displayName);
});
}, [annotated, query, filter]);

const scrollRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);

useLayoutEffect(() => {
const node = gridRef.current;
if (!node || typeof ResizeObserver === "undefined") return;
setContainerWidth(node.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
setContainerWidth(entries[0]?.contentRect.width ?? 0);
});
observer.observe(node);
return () => observer.disconnect();
}, []);

const columns = computeColumnCount(containerWidth);
const rows = rowCount(visible.length, columns);
const rowVirtualizer = useVirtualizer({
count: rows,
getScrollElement: () => scrollRef.current,
estimateSize: () => PROVIDER_GRID.cardHeight + PROVIDER_GRID.gap,
overscan: PROVIDER_GRID.overscanRows,
});

return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b border-[var(--lume-border-subtle)] px-3 py-2">
<LinkToolbar
query={query}
onQueryChange={onQueryChange}
filter={filter}
onFilterChange={onFilterChange}
counts={counts}
/>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto p-3">
{visible.length === 0 ? (
<div className="grid gap-1 rounded-lg border border-[var(--lume-border-subtle)] bg-muted/30 px-3 py-3">
<div className="text-sm font-medium text-[var(--text-1)]">无匹配连接器</div>
<div className="text-xs text-[var(--text-3)]">尝试更换关键词或清除筛选。</div>
</div>
) : null}
{/*
gridRef 必须恒挂载:useLayoutEffect deps=[] 只在首挂时 attach ResizeObserver。
若放进 visible.length>0 分支,empty↔non-empty 切换会卸载 gridRef → cleanup disconnect
→ 重挂时 effect 不重跑 → containerWidth 冻结 0、列数降级 1、窗口缩放失效。
空态下 rows=0 → height=0、无 virtual item,div 占位零成本。
*/}
<div ref={gridRef} className="relative" style={{ height: rows ? rowVirtualizer.getTotalSize() : 0 }}>
{rowVirtualizer.getVirtualItems().map((vRow) => (
<div
key={vRow.key}
className="absolute left-0 top-0 grid gap-3"
style={{
transform: `translateY(${vRow.start}px)`,
width: "100%",
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: columns }).map((_, col) => {
const entry = visible[vRow.index * columns + col];
if (!entry) return null;
return (
<ProviderCard
key={entry.provider.service}
provider={entry.provider}
configured={entry.status.configured}
needsAttention={entry.status.needsAttention}
selected={entry.provider.service === selectedService}
onOpen={onOpen}
/>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
Loading
Loading