Add Feishu online document conversion

This commit is contained in:
wuyang6
2026-05-11 17:18:40 +08:00
parent 64ad7aa97a
commit 2984cc44e8
7 changed files with 1045 additions and 8 deletions
@@ -12,8 +12,10 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ClockIcon,
CloudUploadIcon,
DownloadIcon,
FileTextIcon,
Loader2Icon,
MessageSquareTextIcon,
PanelRightCloseIcon,
WrenchIcon,
@@ -219,6 +221,9 @@ type SessionFile = {
size: number;
modified_at: string;
download_url: string;
online_doc_url?: string | null;
online_doc_title?: string | null;
online_doc_updated_at?: number | null;
};
type SessionFilesPayload = {
@@ -383,19 +388,157 @@ function FileSection({
}
function SessionFileRow({ file }: { file: SessionFile }) {
const [onlineDoc, setOnlineDoc] = useState<{
status: "idle" | "loading" | "auth" | "done" | "error";
message?: string;
url?: string | null;
}>({
status: file.online_doc_url ? "done" : "idle",
message: file.online_doc_url ? "已转在线文档" : undefined,
url: file.online_doc_url ?? null,
});
const canCreateOnlineDoc = isOnlineDocSupported(file.name);
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
async function createOnlineDoc() {
if (!canCreateOnlineDoc || onlineDoc.status === "loading") return;
let pendingWindow: Window | null = null;
try {
pendingWindow = window.open("about:blank", "_blank");
if (pendingWindow) {
pendingWindow.document.write("正在创建飞书在线文档...");
}
} catch {
pendingWindow = null;
}
setOnlineDoc({ status: "loading", message: "正在转换..." });
try {
const response = await fetch("/api/claw/files/online-doc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ path: file.path, title: file.name }),
});
const payload = (await response.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (response.status === 409) {
const loginResponse = await fetch("/api/claw/integrations/feishu", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "login" }),
});
const loginPayload = (await loginResponse
.json()
.catch(() => ({}))) as FeishuLoginPayload;
if (!loginResponse.ok) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message:
extractErrorMessage(
loginPayload as unknown as Record<string, unknown>,
) ?? "飞书授权启动失败",
});
return;
}
const loginUrl = loginPayload.login?.login_url ?? null;
if (loginUrl) {
if (pendingWindow) {
try {
pendingWindow.opener = null;
} catch {
// 有些浏览器不允许改 opener,不影响后续跳转。
}
pendingWindow.location.href = loginUrl;
} else {
window.open(loginUrl, "_blank", "noopener,noreferrer");
}
} else if (pendingWindow) {
pendingWindow.close();
}
setOnlineDoc({
status: "auth",
message: loginUrl ? "完成授权后再点一次" : "需要先完成飞书授权",
});
return;
}
if (!response.ok) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message: extractErrorMessage(payload) ?? "转换失败",
});
return;
}
const url = typeof payload.url === "string" ? payload.url : null;
if (url) {
if (pendingWindow) {
try {
pendingWindow.opener = null;
} catch {
// 有些浏览器不允许改 opener,不影响后续跳转。
}
pendingWindow.location.href = url;
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
} else {
pendingWindow?.close();
}
setOnlineDoc({
status: "done",
message: url ? "已生成在线文档" : "已生成",
url,
});
window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
} catch (error) {
pendingWindow?.close();
setOnlineDoc({
status: "error",
message: error instanceof Error ? error.message : "转换失败",
});
}
}
return (
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileTextIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm" title={file.name}>
{file.name}
</div>
{onlineDocUrl ? (
<a
href={onlineDocUrl}
target="_blank"
rel="noreferrer"
className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline"
title={`${file.name} · 打开在线文档`}
>
{file.name}
</a>
) : (
<div className="truncate font-medium text-sm" title={file.name}>
{file.name}
</div>
)}
<div className="text-muted-foreground text-xs">
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
{formatBytes(file.size)}
</div>
{onlineDoc.message ? (
<div
className={cn(
"truncate text-xs",
onlineDoc.status === "error"
? "text-destructive"
: "text-muted-foreground",
)}
title={onlineDoc.message}
>
{onlineDoc.message}
</div>
) : null}
</div>
<a
href={file.download_url}
@@ -414,10 +557,50 @@ function SessionFileRow({ file }: { file: SessionFile }) {
>
<MessageSquareTextIcon className="size-4" />
</button>
{canCreateOnlineDoc ? (
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`${file.name} 转为在线文档`}
title="转为在线文档"
disabled={onlineDoc.status === "loading"}
onClick={createOnlineDoc}
>
{onlineDoc.status === "loading" ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<CloudUploadIcon className="size-4" />
)}
</button>
) : null}
</div>
);
}
type FeishuLoginPayload = {
login?: {
login_url?: string | null;
user_code?: string | null;
};
};
function isOnlineDocSupported(fileName: string) {
return ["csv", "docx", "md", "txt", "xlsx"].includes(
fileExtension(fileName).toLowerCase(),
);
}
function extractErrorMessage(payload: Record<string, unknown>) {
const detail = payload.detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object") {
const message = (detail as { message?: unknown }).message;
if (typeof message === "string") return message;
}
const error = payload.error;
return typeof error === "string" ? error : null;
}
function askAboutFile(file: SessionFile) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {