Files
writer-work-flow/apps/web/lib/settings/useKimiOauth.ts
Yaojia Wang c6651b74b9 fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。
P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。
P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。
P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、
  rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、
  client.test 真断言。
codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
2026-06-21 19:32:49 +02:00

140 lines
4.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import {
authOpenUrl,
connectPhase,
toConnectionStatus,
toDeviceDisplay,
type ConnectPhase,
type DeviceDisplay,
} from "./kimiOauth";
const START = "/settings/providers/kimi-code/oauth/start";
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
export interface UseKimiOauth {
// 派生连接阶段idle/awaiting/connected/error驱动 UI。
phase: ConnectPhase;
// device 启动后展示给用户的信息user_code + 验证 URL未启动→null。
device: DeviceDisplay | null;
// 已连接时的 access token 过期时刻ISO8601可能为 null
expiresAt: string | null;
// 任一在途请求start/disconnect或正在轮询。
busy: boolean;
// 错误文案device 启动失败 / 轮询失败 / 过期 / 拒绝)。
error: string | null;
// 发起连接POST start → 拿 user_code + job_id → 开浏览器 + 轮询 job。
connect: () => Promise<void>;
// 断开POST disconnect → 复位为未连接。
disconnect: () => Promise<void>;
}
// 在浏览器打开授权页device complete URL 优先。SSR/无 window 时静默跳过。
function openVerification(device: DeviceDisplay): void {
if (typeof window === "undefined") return;
window.open(authOpenUrl(device), "_blank", "noopener,noreferrer");
}
// Kimi Code OAuth device-flow 连接编排K1.4)。
// connectPOST .../oauth/start202→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
// status 进页传入 initialConnected/initialExpiresAtServer Component 取)。
export function useKimiOauth(args: {
initialConnected: boolean;
initialExpiresAt: string | null;
}): UseKimiOauth {
const [connected, setConnected] = useState(args.initialConnected);
const [expiresAt, setExpiresAt] = useState<string | null>(
args.initialExpiresAt,
);
const [device, setDevice] = useState<DeviceDisplay | null>(null);
const [started, setStarted] = useState(false);
const [inFlight, setInFlight] = useState(false);
const poll = useJobPoll();
const toast = useToast();
const startedRef = useRef(false);
const phase = connectPhase({ started, connected, poll });
// refreshStatus 无依赖、稳定useCallback []);声明在 effect 之前以便纳入依赖。
const refreshStatus = useCallback(async (): Promise<boolean> => {
const { data, error } = await api.GET(
"/settings/providers/kimi-code/oauth/status",
);
if (error || !data) return false;
const status = toConnectionStatus(data);
setExpiresAt(status.expiresAt);
return status.connected;
}, []);
// 轮询终态done 且 job.connected → 已连接刷新状态error → 提示。
// refreshStatus / toast 均稳定poll.error 随状态变化——全部纳入依赖,无 stale-closure。
useEffect(() => {
if (!startedRef.current) return;
if (poll.status === "done") {
void refreshStatus().then((ok) => {
if (ok) {
setConnected(true);
toast("已连接 Kimi Code。", "success");
}
});
}
if (poll.status === "error") {
toast(`连接失败:${poll.error ?? "授权未完成或已过期"}`, "error");
}
}, [poll.status, poll.error, refreshStatus, toast]);
const connect = useCallback<UseKimiOauth["connect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(START, {});
if (error || !data) {
toast("发起 Kimi Code 连接失败,请稍后重试。", "error");
return;
}
const dev = toDeviceDisplay(data);
setDevice(dev);
setStarted(true);
startedRef.current = true;
openVerification(dev);
poll.poll(data.job_id);
} finally {
setInFlight(false);
}
}, [poll, toast]);
const disconnect = useCallback<UseKimiOauth["disconnect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(DISCONNECT, {});
if (error || !data) {
toast("断开 Kimi Code 失败,请稍后重试。", "error");
return;
}
poll.reset();
startedRef.current = false;
setStarted(false);
setDevice(null);
setConnected(false);
setExpiresAt(null);
toast("已断开 Kimi Code。", "success");
} finally {
setInFlight(false);
}
}, [poll, toast]);
return {
phase,
device,
expiresAt,
busy: inFlight || (startedRef.current && poll.status === "polling"),
error: poll.status === "error" ? poll.error : null,
connect,
disconnect,
};
}