功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
+140 -67
View File
@@ -1,11 +1,13 @@
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { AIConfigDiffReviewPanel } from "../components/AIConfigDiffReviewPanel";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { isPlatformAdmin } from "../contracts/workspace";
import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
@@ -44,7 +46,7 @@ const providerPresets: ProviderPreset[] = [
{ kind: "custom", label: "自定义" }
];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
export function AiProvidersPage({ initialState, session, operations }: AiProvidersPageProps = {}) {
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
@@ -56,59 +58,59 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
const [confirmStatus, setConfirmStatus] = useState<AiProviderResponse | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [busyProviderIds, setBusyProviderIds] = useState<Set<string>>(() => new Set());
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
const refreshProviders = useCallback(async () => {
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
return;
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled) {
return;
}
const nextSelectedId =
initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId)
? initialState.selectedId
: "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
setListState("loading");
setListError("");
try {
const response = await platformApiClient.listAiProviders();
const nextSelectedId = initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId) ? initialState.selectedId : "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
} catch (error: unknown) {
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
}
}, [initialState]);
return () => {
cancelled = true;
};
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
useEffect(() => {
void refreshProviders();
}, [refreshProviders]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
const canManage = Boolean(session && isPlatformAdmin(session));
function setProviderBusy(providerId: string, busy: boolean) {
setBusyProviderIds((current) => {
const next = new Set(current);
if (busy) next.add(providerId);
else next.delete(providerId);
return next;
});
}
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -120,6 +122,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else if (key === "apiKeyRef") {
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
} else if (key === "baseUrl") {
setForm((current) => ({ ...current, baseUrl: event.target.value, baseUrlConfigured: current.baseUrlConfigured || Boolean(event.target.value.trim()) }));
} else {
updateForm(key, event.target.value);
}
@@ -127,6 +131,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function selectProvider(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
@@ -136,6 +143,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function startCreate() {
if (!canManage) {
return;
}
setSelectedId("");
setForm(emptyAiProviderForm());
setFormMode("create");
@@ -165,7 +175,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
if (!completed.name.trim()) {
missing.push("名称");
}
if (!completed.baseUrl.trim()) {
if (!completed.baseUrl.trim() && !completed.baseUrlConfigured) {
missing.push("Base URL");
}
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
@@ -186,10 +196,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canManage) {
return;
}
setViewState("saving");
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
const intent = existing ? "更新 AI 提供商" : "创建 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: providerId, requester: session?.displayName });
try {
const saved = existing
@@ -202,6 +217,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setListState("ready");
setListError("");
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
if (operationId) operations?.succeed(operationId, `${saved.name} 已保存`);
setFormCheck(null);
setFormMode(null);
} catch (error) {
@@ -209,55 +225,98 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const message = errorMessage(error, "保存失败");
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
if (operationId) operations?.fail(operationId, message, operationId);
}
}
async function handleStatus(provider: AiProviderResponse) {
async function handleStatus(provider: AiProviderResponse): Promise<boolean> {
if (!canManage) {
return false;
}
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
const intent = nextStatus === "active" ? "启用 AI 提供商" : "停用 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
if (operationId) operations?.succeed(operationId, `${updated.name}${updated.status === "disabled" ? "停用" : "启用"}`);
return true;
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
const message = errorMessage(error, "状态更新失败");
setAction({ providerId: provider.id, label: "status", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
return false;
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleRetire(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setConfirmBusy(true);
const operationId = operations?.begin({ intent: "退役 AI 提供商", targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
if (operationId) operations?.succeed(operationId, `${retired.name} 已退役`);
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
const message = errorMessage(error, "退役失败,可能仍被引用");
setAction({ providerId: provider.id, label: "retire", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
} finally {
setConfirmBusy(false);
setProviderBusy(provider.id, false);
}
}
async function handleTest(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch (error) {
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleModels(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
} catch (error) {
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
function requestStatusChange(provider: AiProviderResponse) {
if (!canManage || busyProviderIds.has(provider.id)) {
return;
}
setExpandedProviderId(null);
setConfirmStatus(provider);
}
async function handleSavedFormTest() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
@@ -319,21 +378,21 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
<span className={cx("page-status", viewState === "api" && canManage && "page-status-ready")}>{!canManage ? (viewState === "local-development" ? "本地开发 / 只读" : "只读模式") : viewStateLabel(viewState)}</span>
</header>
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
<div className="page-summary-chip summary-tone-neutral">
<dt></dt>
<dd>{metrics.total}</dd>
<dd>{listState === "ready" ? metrics.total : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-success">
<dt></dt>
<dd>{metrics.active}</dd>
<dd>{listState === "ready" ? metrics.active : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt></dt>
<dd>{metrics.models}</dd>
<dd>{listState === "ready" ? metrics.models : "--"}</dd>
</div>
</dl>
@@ -343,15 +402,16 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{filterLabel(item)}
</button>
))}
<button type="button" className="icon-command" title="新增提供商" onClick={startCreate}>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "新增提供商" : "当前账号没有管理权限"} onClick={startCreate}>
<Candy size={16} />
<span></span>
</button>
</div>
{!canManage && <ResultBadge status="pending" label="当前账号为只读模式,保存、状态和测试动作需要平台管理员权限" />}
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => void refreshProviders()} />}
{listState === "ready" && providers.length === 0 && (
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
@@ -375,7 +435,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{filteredProviders.map((provider) => (
<tr key={provider.id} className={cx(provider.id === selectedId && "provider-row-active")}>
<td>
<button type="button" className="table-link-button" onClick={() => selectProvider(provider)}>
<button type="button" className="table-link-button" disabled={!canManage} onClick={() => selectProvider(provider)}>
{provider.name}
</button>
<span className="provider-id">{provider.id}</span>
@@ -391,15 +451,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</td>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "测试"}</span>
</button>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "模型"}</span>
</button>
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<WandSparkles size={15} />
<span></span>
</button>
@@ -408,6 +468,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
aria-expanded={expandedProviderId === provider.id}
aria-label={`${provider.name} 更多操作`}
disabled={!canManage || busyProviderIds.has(provider.id)}
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
>
<MoreHorizontal size={15} />
@@ -416,7 +477,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{expandedProviderId === provider.id && (
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
<button type="button" role="menuitem" onClick={() => requestStatusChange(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
@@ -426,7 +487,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
setConfirmRetire(provider);
if (canManage) setConfirmRetire(provider);
}}
>
<UserRoundMinus size={14} />
@@ -443,7 +504,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
)}
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
<AIConfigDiffReviewPanel />
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
@@ -496,7 +559,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
<label>
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} placeholder={form.baseUrlConfigured ? "已配置;留空保持不变" : formDefaults.baseUrl} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label>
<label>
@@ -533,12 +596,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
<div className="confirm-actions">
<button type="button" onClick={closeForm}>
<button type="button" disabled={viewState === "saving"} onClick={closeForm}>
</button>
<button type="submit" className="confirm-primary">
<button type="submit" className="confirm-primary" disabled={!canManage || viewState === "saving"}>
<WandSparkles size={16} />
<span></span>
<span>{viewState === "saving" ? "保存中…" : "保存配置"}</span>
</button>
</div>
</form>
@@ -551,9 +614,19 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onCancel={() => { if (!confirmBusy) setConfirmRetire(null); }}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
<ConfirmDialog
open={confirmStatus !== null}
title="确认提供商状态变更"
description={`确认将 ${confirmStatus?.name ?? "该提供商"} ${confirmStatus?.status === "active" ? "停用" : "启用"}?平台会返回持久状态,失败时保持当前状态。`}
confirmLabel={confirmStatus?.status === "active" ? "确认停用" : "确认启用"}
danger={confirmStatus?.status === "active"}
busy={confirmStatus ? busyProviderIds.has(confirmStatus.id) : false}
onCancel={() => { if (!confirmStatus || !busyProviderIds.has(confirmStatus.id)) setConfirmStatus(null); }}
onConfirm={() => { if (confirmStatus) { void handleStatus(confirmStatus).then((succeeded) => { if (succeeded) setConfirmStatus(null); }); } }}
/>
</section>
);
}